Repository: athena-framework/athena Branch: master Commit: ac9732d451be Files: 1321 Total size: 3.8 MB Directory structure: gitextract_igikmbi3/ ├── .ameba.yml ├── .changes/ │ ├── clock/ │ │ ├── v0.2.0.md │ │ └── v0.3.0.md │ ├── console/ │ │ ├── v0.4.1.md │ │ ├── v0.4.2.md │ │ └── v0.4.3.md │ ├── contracts/ │ │ └── v0.1.0.md │ ├── dependency-injection/ │ │ ├── v0.4.3.md │ │ ├── v0.4.4.md │ │ └── v0.4.5.md │ ├── dotenv/ │ │ ├── v0.2.0.md │ │ └── v0.2.1.md │ ├── event-dispatcher/ │ │ ├── v0.3.1.md │ │ ├── v0.4.0.md │ │ └── v0.4.1.md │ ├── framework/ │ │ ├── v0.20.1.md │ │ ├── v0.21.0.md │ │ ├── v0.21.1.md │ │ └── v0.22.0.md │ ├── header.tpl.md │ ├── http/ │ │ └── v0.1.0.md │ ├── http-kernel/ │ │ └── v0.1.0.md │ ├── image-size/ │ │ └── v0.1.4.md │ ├── mercure/ │ │ └── v0.1.0.md │ ├── mercure-bundle/ │ │ └── v0.1.0.md │ ├── mime/ │ │ ├── v0.2.0.md │ │ └── v0.2.1.md │ ├── negotiation/ │ │ └── v0.2.0.md │ ├── routing/ │ │ ├── v0.1.10.md │ │ ├── v0.1.11.md │ │ ├── v0.1.12.md │ │ └── v0.2.0.md │ ├── serializer/ │ │ ├── v0.4.1.md │ │ ├── v0.4.2.md │ │ └── v0.4.3.md │ ├── spec/ │ │ ├── v0.3.11.md │ │ ├── v0.4.0.md │ │ ├── v0.4.1.md │ │ └── v0.4.2.md │ ├── unreleased/ │ │ ├── .gitkeep │ │ └── event-dispatcher-Changed-20260502-225424.yaml │ └── validator/ │ ├── v0.4.0.md │ ├── v0.4.1.md │ └── v0.5.0.md ├── .changie.yaml ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── build_and_publish_docs.yml │ ├── ci.yml │ ├── sync.yml │ └── tag_and_create_release.yml ├── .gitignore ├── .python-version ├── .typos.toml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── UPGRADING.md ├── codecov.yml ├── docs/ │ ├── README.md │ ├── api_reference.md │ ├── bundle_reference.md │ ├── css/ │ │ ├── index.css │ │ └── monorepo.css │ ├── getting_started/ │ │ ├── README.md │ │ ├── commands.md │ │ ├── configuration.md │ │ ├── error_handling.md │ │ ├── middleware.md │ │ ├── routing.md │ │ ├── testing.md │ │ └── validation.md │ ├── guides/ │ │ ├── README.md │ │ └── proxies.md │ ├── index.cr │ ├── templates/ │ │ └── crystal/ │ │ └── material/ │ │ ├── schema.html │ │ └── type.html │ └── why_athena.md ├── gen_doc_stubs.py ├── justfile ├── mkdocs-common.yml ├── mkdocs.yml ├── pyproject.toml ├── scripts/ │ └── test.sh ├── shard.dev.yml ├── shard.prod.yml ├── shard.yml └── src/ ├── bundles/ │ └── mercure/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── shard.yml │ ├── spec/ │ │ ├── authorization_spec.cr │ │ ├── bundle_spec.cr │ │ ├── discovery_spec.cr │ │ ├── listeners/ │ │ │ ├── add_link_header_spec.cr │ │ │ └── set_cookie_spec.cr │ │ └── spec_helper.cr │ └── src/ │ ├── athena-mercure_bundle.cr │ ├── authorization.cr │ ├── discovery.cr │ └── listeners/ │ ├── add_link_header.cr │ └── set_cookie.cr └── components/ ├── clock/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── UPGRADING.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── athena-clock_spec.cr │ │ ├── aware_spec.cr │ │ ├── mock_clock_spec.cr │ │ ├── native_spec.cr │ │ └── spec_helper.cr │ └── src/ │ ├── athena-clock.cr │ ├── aware.cr │ ├── interface.cr │ ├── native.cr │ └── spec.cr ├── console/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── UPGRADING.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── application_spec.cr │ │ ├── application_tester_spec.cr │ │ ├── command_spec.cr │ │ ├── command_tester_spec.cr │ │ ├── commands/ │ │ │ ├── complete_spec.cr │ │ │ ├── dump_completion_spec.cr │ │ │ ├── help_spec.cr │ │ │ ├── lazy_spec.cr │ │ │ └── list_spec.cr │ │ ├── compiler_spec.cr │ │ ├── completion/ │ │ │ ├── input_spec.cr │ │ │ └── output/ │ │ │ ├── bash_spec.cr │ │ │ ├── completion_output_test_case.cr │ │ │ ├── fish_spec.cr │ │ │ └── zsh_spec.cr │ │ ├── cursor_spec.cr │ │ ├── descriptor/ │ │ │ ├── abstract_descriptor_test_case.cr │ │ │ ├── application_spec.cr │ │ │ ├── object_provider.cr │ │ │ └── text_spec.cr │ │ ├── fixtures/ │ │ │ ├── applications/ │ │ │ │ ├── descriptor1.cr │ │ │ │ └── descriptor2.cr │ │ │ ├── commands/ │ │ │ │ ├── annotation_configured.cr │ │ │ │ ├── annotation_configured_aliases.cr │ │ │ │ ├── annotation_configured_hidden.cr │ │ │ │ ├── annotation_configured_hidden_field.cr │ │ │ │ ├── bar_buc.cr │ │ │ │ ├── descriptor1.cr │ │ │ │ ├── descriptor2.cr │ │ │ │ ├── descriptor3.cr │ │ │ │ ├── descriptor4.cr │ │ │ │ ├── foo.cr │ │ │ │ ├── foo1.cr │ │ │ │ ├── foo2.cr │ │ │ │ ├── foo3.cr │ │ │ │ ├── foo4.cr │ │ │ │ ├── foo6.cr │ │ │ │ ├── foo_bar.cr │ │ │ │ ├── foo_hidden.cr │ │ │ │ ├── foo_opt.cr │ │ │ │ ├── foo_same_case_lowercase.cr │ │ │ │ ├── foo_same_case_uppercase.cr │ │ │ │ ├── foo_subnamespaced1.cr │ │ │ │ ├── foo_subnamespaced2.cr │ │ │ │ ├── foo_without_alias.cr │ │ │ │ ├── io.cr │ │ │ │ ├── test.cr │ │ │ │ ├── test_ambiguous_command_registering1.cr │ │ │ │ └── test_ambiguous_command_registering2.cr │ │ │ ├── helper/ │ │ │ │ └── table/ │ │ │ │ ├── borderless.txt │ │ │ │ ├── borderless_vertical.txt │ │ │ │ ├── box.txt │ │ │ │ ├── compact.txt │ │ │ │ ├── compact_vertical.txt │ │ │ │ ├── default.txt │ │ │ │ ├── default_cells_with_colspan.txt │ │ │ │ ├── default_cells_with_formatting_tags.txt │ │ │ │ ├── default_cells_with_non_formatting_tags.txt │ │ │ │ ├── default_cells_with_rowspan.txt │ │ │ │ ├── default_cells_with_rowspan_and_colspan.txt │ │ │ │ ├── default_cells_with_rowspan_and_colspan_and_alignment.txt │ │ │ │ ├── default_cells_with_rowspan_and_colspan_and_custom_format.txt │ │ │ │ ├── default_cells_with_rowspan_and_colspan_and_fgbg.txt │ │ │ │ ├── default_cells_with_rowspan_and_colspan_and_line_breaks.txt │ │ │ │ ├── default_cells_with_rowspan_and_colspan_no_separators.txt │ │ │ │ ├── default_cells_with_rowspan_and_colspan_separator_in_rowspan.txt │ │ │ │ ├── default_colspan_and_table_cell_with_comment_style.txt │ │ │ │ ├── default_formatted_row_with_line_breaks.txt │ │ │ │ ├── default_headerless.txt │ │ │ │ ├── default_line_break_after_colspan_cell.txt │ │ │ │ ├── default_line_breaks_after_colspan_cell.txt │ │ │ │ ├── default_missing_cell_values.txt │ │ │ │ ├── default_multiline_cells.txt │ │ │ │ ├── default_multiple_header_lines.txt │ │ │ │ ├── default_no_rows.txt │ │ │ │ ├── default_row_with_multiple_cells.txt │ │ │ │ ├── double_box_separator.txt │ │ │ │ ├── markdown.txt │ │ │ │ └── suggested_vertical.txt │ │ │ ├── style/ │ │ │ │ ├── backslashes.txt │ │ │ │ ├── block.txt │ │ │ │ ├── block_line_endings.txt │ │ │ │ ├── block_no_prefix_type.txt │ │ │ │ ├── block_padding.txt │ │ │ │ ├── block_prefix_no_type.txt │ │ │ │ ├── blocks.txt │ │ │ │ ├── closing_tag.txt │ │ │ │ ├── definition_list.txt │ │ │ │ ├── emojis.txt │ │ │ │ ├── empty_buffer.txt │ │ │ │ ├── horizontal_table.txt │ │ │ │ ├── long_line_block.txt │ │ │ │ ├── long_line_block_wrapping.txt │ │ │ │ ├── long_line_comment.txt │ │ │ │ ├── long_line_comment_decorated.txt │ │ │ │ ├── multi_line_block.txt │ │ │ │ ├── nested_tag_prefix.txt │ │ │ │ ├── non_interactive_question.txt │ │ │ │ ├── table.txt │ │ │ │ ├── table_horizontal.txt │ │ │ │ ├── table_vertical.txt │ │ │ │ ├── text_block_blank_line.txt │ │ │ │ ├── title_block.txt │ │ │ │ ├── titles.txt │ │ │ │ └── titles_text.txt │ │ │ └── text/ │ │ │ ├── application_1.txt │ │ │ ├── application_2.txt │ │ │ ├── application_alternative_namespace.txt │ │ │ ├── application_filtered_namespace.txt │ │ │ ├── application_renderexception1.txt │ │ │ ├── application_renderexception2.txt │ │ │ ├── application_renderexception3.txt │ │ │ ├── application_renderexception3_decorated.txt │ │ │ ├── application_renderexception4.txt │ │ │ ├── application_renderexception_doublewidth1.txt │ │ │ ├── application_renderexception_escapeslines.txt │ │ │ ├── application_renderexception_linebreaks.txt │ │ │ ├── application_renderexception_synopsis_escapeslines.txt │ │ │ ├── application_run1.txt │ │ │ ├── application_run2.txt │ │ │ ├── application_run3.txt │ │ │ ├── application_run4.txt │ │ │ ├── application_run5.txt │ │ │ ├── command_1.txt │ │ │ ├── command_2.txt │ │ │ ├── input_argument_1.txt │ │ │ ├── input_argument_2.txt │ │ │ ├── input_argument_3.txt │ │ │ ├── input_argument_4.txt │ │ │ ├── input_argument_with_style.txt │ │ │ ├── input_definition_1.txt │ │ │ ├── input_definition_2.txt │ │ │ ├── input_definition_3.txt │ │ │ ├── input_definition_4.txt │ │ │ ├── input_option_1.txt │ │ │ ├── input_option_2.txt │ │ │ ├── input_option_3.txt │ │ │ ├── input_option_4.txt │ │ │ ├── input_option_5.txt │ │ │ ├── input_option_6.txt │ │ │ ├── input_option_with_style.txt │ │ │ └── input_option_with_style_array.txt │ │ ├── formatter/ │ │ │ ├── null_spec.cr │ │ │ ├── null_style_spec.cr │ │ │ ├── output_formatter_spec.cr │ │ │ ├── output_formatter_style_spec.cr │ │ │ └── output_formatter_style_stack_spec.cr │ │ ├── helper/ │ │ │ ├── abstract_question_helper_test_case.cr │ │ │ ├── athena_question_spec.cr │ │ │ ├── formatter_spec.cr │ │ │ ├── helper_set_spec.cr │ │ │ ├── helper_spec.cr │ │ │ ├── output_wrapper_spec.cr │ │ │ ├── progress_bar_spec.cr │ │ │ ├── progress_indicator_spec.cr │ │ │ ├── question_spec.cr │ │ │ ├── table_spec.cr │ │ │ └── table_style_spec.cr │ │ ├── input/ │ │ │ ├── argument_spec.cr │ │ │ ├── argv_spec.cr │ │ │ ├── definition_spec.cr │ │ │ ├── hash_spec.cr │ │ │ ├── input_spec.cr │ │ │ ├── option_spec.cr │ │ │ ├── string_line_spec.cr │ │ │ └── value/ │ │ │ ├── array_spec.cr │ │ │ ├── bool_spec.cr │ │ │ ├── nil_spec.cr │ │ │ ├── number_spec.cr │ │ │ └── string_spec.cr │ │ ├── output/ │ │ │ ├── console_section_output_spec.cr │ │ │ ├── io_spec.cr │ │ │ ├── null_spec.cr │ │ │ └── output_spec.cr │ │ ├── question/ │ │ │ ├── choice_spec.cr │ │ │ ├── confirmation_spec.cr │ │ │ ├── multiple_choice_spec.cr │ │ │ └── question_spec.cr │ │ ├── spec_helper.cr │ │ ├── style/ │ │ │ └── athena_style_spec.cr │ │ └── terminal_spec.cr │ └── src/ │ ├── annotations.cr │ ├── application.cr │ ├── athena-console.cr │ ├── command.cr │ ├── commands/ │ │ ├── complete.cr │ │ ├── dump_completion.cr │ │ ├── generic.cr │ │ ├── help.cr │ │ ├── lazy.cr │ │ └── list.cr │ ├── completion/ │ │ ├── input.cr │ │ ├── output/ │ │ │ ├── bash.cr │ │ │ ├── completion.bash │ │ │ ├── completion.fish │ │ │ ├── completion.zsh │ │ │ ├── fish.cr │ │ │ ├── interface.cr │ │ │ └── zsh.cr │ │ └── suggestions.cr │ ├── cursor.cr │ ├── descriptor/ │ │ ├── application.cr │ │ ├── context.cr │ │ ├── descriptor.cr │ │ ├── interface.cr │ │ └── text.cr │ ├── exception/ │ │ ├── command_not_found.cr │ │ ├── invalid_argument.cr │ │ ├── invalid_option.cr │ │ ├── logic.cr │ │ ├── missing_input.cr │ │ ├── namespace_not_found.cr │ │ └── runtime.cr │ ├── ext/ │ │ └── terminal.cr │ ├── formatter/ │ │ ├── interface.cr │ │ ├── null.cr │ │ ├── null_style.cr │ │ ├── output.cr │ │ ├── output_formatter_style_stack.cr │ │ ├── output_style.cr │ │ ├── output_style_interface.cr │ │ └── wrappable_interface.cr │ ├── helper/ │ │ ├── athena_question.cr │ │ ├── descriptor_helper.cr │ │ ├── formatter.cr │ │ ├── helper.cr │ │ ├── helper_set.cr │ │ ├── interface.cr │ │ ├── output_wrapper.cr │ │ ├── progress_bar.cr │ │ ├── progress_indicator.cr │ │ ├── question.cr │ │ ├── table.cr │ │ ├── table_cell_style.cr │ │ └── table_style.cr │ ├── input/ │ │ ├── argument.cr │ │ ├── argv.cr │ │ ├── definition.cr │ │ ├── hash.cr │ │ ├── input.cr │ │ ├── interface.cr │ │ ├── option.cr │ │ ├── streamable.cr │ │ ├── string_line.cr │ │ └── value/ │ │ ├── array.cr │ │ ├── bool.cr │ │ ├── nil.cr │ │ ├── number.cr │ │ ├── string.cr │ │ └── value.cr │ ├── loader/ │ │ ├── factory.cr │ │ └── interface.cr │ ├── output/ │ │ ├── console_output.cr │ │ ├── console_output_interface.cr │ │ ├── interface.cr │ │ ├── io.cr │ │ ├── null.cr │ │ ├── output.cr │ │ ├── section.cr │ │ ├── sized_buffer.cr │ │ ├── type.cr │ │ └── verbosity.cr │ ├── question/ │ │ ├── abstract_choice.cr │ │ ├── base.cr │ │ ├── choice.cr │ │ ├── confirmation.cr │ │ ├── multiple_choice.cr │ │ └── question.cr │ ├── spec/ │ │ └── expectations/ │ │ └── command_is_successful.cr │ ├── spec.cr │ ├── style/ │ │ ├── athena.cr │ │ ├── interface.cr │ │ └── output.cr │ └── terminal.cr ├── contracts/ │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ └── .gitkeep │ └── src/ │ ├── alias.cr │ ├── athena-contracts.cr │ ├── contracts/ │ │ └── event_dispatcher/ │ │ ├── event.cr │ │ ├── interface.cr │ │ └── stoppable_event.cr │ └── event_dispatcher.cr ├── dependency_injection/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── UPGRADING.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── abstract_bundle_spec.cr │ │ ├── athena-dependency_injection_spec.cr │ │ ├── compiler_passes/ │ │ │ ├── auto_wire_spec.cr │ │ │ ├── define_getters_spec.cr │ │ │ ├── inline_service_definitions_spec.cr │ │ │ ├── merge_configs_spec.cr │ │ │ ├── merge_extension_config_spec.cr │ │ │ ├── namespaced_spec.cr │ │ │ ├── normalize_definitions_spec.cr │ │ │ ├── optional_services_spec.cr │ │ │ ├── parameters_spec.cr │ │ │ ├── process_aliases_spec.cr │ │ │ ├── process_auto_configurations_spec.cr │ │ │ ├── process_bindings_spec.cr │ │ │ ├── process_parameters_spec.cr │ │ │ ├── proxy_spec.cr │ │ │ ├── register_services_spec.cr │ │ │ ├── remove_unused_services_spec.cr │ │ │ ├── resolve_parameter_placeholders_spec.cr │ │ │ ├── resolve_values_spec.cr │ │ │ ├── untyped_with_default_spec.cr │ │ │ ├── validate_arguments_spec.cr │ │ │ └── validate_generics_spec.cr │ │ ├── extension_spec.cr │ │ ├── spec_helper.cr │ │ └── spec_spec.cr │ └── src/ │ ├── abstract_bundle.cr │ ├── annotation_configurations.cr │ ├── annotations.cr │ ├── athena-dependency_injection.cr │ ├── compiler_passes/ │ │ ├── analyze_service_references.cr │ │ ├── auto_wire.cr │ │ ├── define_getters.cr │ │ ├── inline_service_definitions.cr │ │ ├── merge_configs.cr │ │ ├── merge_extension_config.cr │ │ ├── normalize_definitions.cr │ │ ├── process_aliases.cr │ │ ├── process_annotation_bindings.cr │ │ ├── process_autoconfigure_annotations.cr │ │ ├── process_bindings.cr │ │ ├── process_parameters.cr │ │ ├── process_tags.cr │ │ ├── register_services.cr │ │ ├── remove_unused_services.cr │ │ ├── resolve_parameter_placeholders.cr │ │ ├── resolve_tagged_iterators.cr │ │ ├── resolve_values.cr │ │ ├── validate_arguments.cr │ │ └── validate_generics.cr │ ├── extension.cr │ ├── proxy.cr │ ├── service_container.cr │ └── spec.cr ├── dotenv/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── UPGRADING.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── athena-dotenv_spec.cr │ │ └── spec_helper.cr │ └── src/ │ ├── athena-dotenv.cr │ └── exception/ │ ├── format.cr │ ├── logic.cr │ └── path.cr ├── event_dispatcher/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── UPGRADING.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── callable_spec.cr │ │ ├── compiler_spec.cr │ │ ├── event_dispatcher_spec.cr │ │ ├── generic_event_spec.cr │ │ └── spec_helper.cr │ └── src/ │ ├── annotations.cr │ ├── athena-event_dispatcher.cr │ ├── callable.cr │ ├── event.cr │ ├── event_dispatcher.cr │ ├── event_dispatcher_interface.cr │ ├── generic_event.cr │ └── spec.cr ├── framework/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── UPGRADING.md │ ├── docs/ │ │ └── .gitkeep │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── argument_resolver_controller_spec.cr │ │ ├── assets/ │ │ │ ├── file-big.txt │ │ │ ├── file-small.txt │ │ │ ├── foo.txt │ │ │ ├── greeting.ecr │ │ │ ├── layout.ecr │ │ │ └── openssl/ │ │ │ ├── openssl.crt │ │ │ └── openssl.key │ │ ├── athena_spec.cr │ │ ├── bundle_spec.cr │ │ ├── commands/ │ │ │ ├── debug_event_dispatcher_spec.cr │ │ │ ├── debug_router_match_spec.cr │ │ │ └── debug_router_spec.cr │ │ ├── compiler_spec.cr │ │ ├── controller/ │ │ │ ├── redirect_spec.cr │ │ │ └── value_resolvers/ │ │ │ ├── enum_spec.cr │ │ │ ├── query_parameter_spec.cr │ │ │ ├── request_body_spec.cr │ │ │ ├── time_spec.cr │ │ │ └── uuid_spec.cr │ │ ├── controller_spec.cr │ │ ├── controllers/ │ │ │ ├── argument_resolver_controller.cr │ │ │ ├── custom_annotation_controller.cr │ │ │ ├── file_upload_controller.cr │ │ │ ├── prefix_controller.cr │ │ │ ├── routing_controller.cr │ │ │ └── view_controller.cr │ │ ├── custom_annotation_spec.cr │ │ ├── ext/ │ │ │ ├── console/ │ │ │ │ └── register_commands_spec.cr │ │ │ └── routing/ │ │ │ └── annotation_route_loader_spec.cr │ │ ├── file_parser_spec.cr │ │ ├── file_upload_controller_spec.cr │ │ ├── listeners/ │ │ │ ├── cors_spec.cr │ │ │ ├── file_spec.cr │ │ │ ├── format_spec.cr │ │ │ └── view_spec.cr │ │ ├── prefix_spec.cr │ │ ├── routing_spec.cr │ │ ├── spec/ │ │ │ ├── expectations/ │ │ │ │ ├── request/ │ │ │ │ │ └── attribute_equals_spec.cr │ │ │ │ └── response/ │ │ │ │ ├── cookie_value_equals_spec.cr │ │ │ │ ├── format_equals_spec.cr │ │ │ │ ├── has_cookie_spec.cr │ │ │ │ ├── has_header_spec.cr │ │ │ │ ├── has_status_spec.cr │ │ │ │ ├── header_equals_spec.cr │ │ │ │ ├── is_redirected_spec.cr │ │ │ │ ├── is_successful_spec.cr │ │ │ │ └── is_unprocessable_spec.cr │ │ │ └── web_test_case_spec.cr │ │ ├── spec_helper.cr │ │ ├── view/ │ │ │ ├── context_spec.cr │ │ │ ├── format_negotiator_spec.cr │ │ │ ├── view_handler_spec.cr │ │ │ └── view_spec.cr │ │ └── view_controller_spec.cr │ └── src/ │ ├── annotation_resolver.cr │ ├── annotations.cr │ ├── athena.cr │ ├── bundle.cr │ ├── commands/ │ │ ├── debug_event_dispatcher.cr │ │ ├── debug_router.cr │ │ └── debug_router_match.cr │ ├── compiler_passes/ │ │ └── expose_controller_services.cr │ ├── controller/ │ │ ├── redirect.cr │ │ └── value_resolvers/ │ │ ├── enum.cr │ │ ├── interface.cr │ │ ├── query_parameter.cr │ │ ├── request_body.cr │ │ ├── time.cr │ │ └── uuid.cr │ ├── controller.cr │ ├── ext/ │ │ ├── clock.cr │ │ ├── console/ │ │ │ ├── application.cr │ │ │ ├── compiler_passes/ │ │ │ │ └── register_commands.cr │ │ │ ├── container_command_loader.cr │ │ │ ├── descriptor/ │ │ │ │ ├── descriptor.cr │ │ │ │ └── text.cr │ │ │ └── helper/ │ │ │ └── descriptor_helper.cr │ │ ├── console.cr │ │ ├── event_dispatcher.cr │ │ ├── http.cr │ │ ├── http_kernel.cr │ │ ├── routing/ │ │ │ ├── annotation_route_loader.cr │ │ │ ├── redirectable_url_matcher.cr │ │ │ └── router.cr │ │ ├── routing.cr │ │ ├── serializer.cr │ │ ├── validator/ │ │ │ └── validation_failed_exception.cr │ │ └── validator.cr │ ├── file_parser.cr │ ├── listeners/ │ │ ├── cors.cr │ │ ├── file.cr │ │ ├── format.cr │ │ └── view.cr │ ├── logging.cr │ ├── spec/ │ │ ├── abstract_browser.cr │ │ ├── api_test_case.cr │ │ ├── expectations/ │ │ │ ├── http.cr │ │ │ ├── request/ │ │ │ │ └── attribute_equals.cr │ │ │ └── response/ │ │ │ ├── base.cr │ │ │ ├── cookie_value_equals.cr │ │ │ ├── format_equals.cr │ │ │ ├── has_cookie.cr │ │ │ ├── has_header.cr │ │ │ ├── has_status.cr │ │ │ ├── header_equals.cr │ │ │ ├── is_redirected.cr │ │ │ ├── is_successful.cr │ │ │ └── is_unprocessable.cr │ │ ├── http_browser.cr │ │ └── web_test_case.cr │ ├── spec.cr │ └── view/ │ ├── configurable_view_handler_interface.cr │ ├── context.cr │ ├── format_handler_interface.cr │ ├── format_negotiator.cr │ ├── view.cr │ ├── view_handler.cr │ └── view_handler_interface.cr ├── http/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── assets/ │ │ │ ├── .unknownextension │ │ │ ├── case-sensitive-mime-type.xlsm │ │ │ ├── directory/ │ │ │ │ └── .empty │ │ │ ├── file-big.txt │ │ │ ├── file-small.txt │ │ │ ├── foo.txt │ │ │ ├── fööö.html │ │ │ ├── test │ │ │ └── webkitdirectory/ │ │ │ ├── nested/ │ │ │ │ └── test.txt │ │ │ └── test.txt │ │ ├── binary_file_response_spec.cr │ │ ├── ext/ │ │ │ └── conversion_types_spec.cr │ │ ├── file_spec.cr │ │ ├── header_utils_spec.cr │ │ ├── ip_utils_spec.cr │ │ ├── parameter_bag_spec.cr │ │ ├── redirect_response_spec.cr │ │ ├── request_matcher/ │ │ │ ├── attributes_spec.cr │ │ │ ├── header_spec.cr │ │ │ ├── hostname_spec.cr │ │ │ ├── method_spec.cr │ │ │ ├── path_spec.cr │ │ │ └── query_parameter_spec.cr │ │ ├── request_matcher_spec.cr │ │ ├── request_spec.cr │ │ ├── response_headers_spec.cr │ │ ├── response_spec.cr │ │ ├── spec_helper.cr │ │ ├── streamed_response_spec.cr │ │ └── uploaded_file_spec.cr │ └── src/ │ ├── abstract_file.cr │ ├── athena-http.cr │ ├── binary_file_response.cr │ ├── exception/ │ │ ├── conflicting_headers.cr │ │ ├── file.cr │ │ ├── file_not_found.cr │ │ ├── file_size_limit_exceeded.cr │ │ ├── logic.cr │ │ ├── request_exception_interface.cr │ │ └── suspicious_operation.cr │ ├── ext/ │ │ └── conversion_types.cr │ ├── file.cr │ ├── header_utils.cr │ ├── ip_utils.cr │ ├── parameter_bag.cr │ ├── redirect_response.cr │ ├── request.cr │ ├── request_matcher/ │ │ ├── attributes.cr │ │ ├── header.cr │ │ ├── hostname.cr │ │ ├── method.cr │ │ ├── path.cr │ │ └── query_parameter.cr │ ├── request_matcher.cr │ ├── request_store.cr │ ├── response.cr │ ├── response_headers.cr │ ├── streamed_response.cr │ └── uploaded_file.cr ├── http_kernel/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── controller/ │ │ │ ├── argument_resolver_spec.cr │ │ │ ├── parameter_metadata_spec.cr │ │ │ └── value_resolvers/ │ │ │ ├── default_value_spec.cr │ │ │ ├── request_attribute_spec.cr │ │ │ └── request_spec.cr │ │ ├── error_renderer_spec.cr │ │ ├── exception/ │ │ │ ├── bad_gateway_spec.cr │ │ │ ├── http_exception_spec.cr │ │ │ ├── service_unavailable_spec.cr │ │ │ ├── too_many_requests_spec.cr │ │ │ └── unauthorized_spec.cr │ │ ├── http_kernel_spec.cr │ │ ├── listeners/ │ │ │ └── error_spec.cr │ │ └── spec_helper.cr │ └── src/ │ ├── action.cr │ ├── action_resolver.cr │ ├── action_resolver_interface.cr │ ├── athena-http_kernel.cr │ ├── controller/ │ │ ├── argument_resolver.cr │ │ ├── argument_resolver_interface.cr │ │ ├── parameter_metadata.cr │ │ └── value_resolvers/ │ │ ├── default_value.cr │ │ ├── interface.cr │ │ ├── request.cr │ │ └── request_attribute.cr │ ├── error_renderer.cr │ ├── error_renderer_interface.cr │ ├── events/ │ │ ├── action_event.cr │ │ ├── exception_event.cr │ │ ├── request_aware.cr │ │ ├── request_event.cr │ │ ├── response_event.cr │ │ ├── settable_response.cr │ │ ├── terminate_event.cr │ │ └── view_event.cr │ ├── exception/ │ │ ├── bad_gateway.cr │ │ ├── bad_request.cr │ │ ├── conflict.cr │ │ ├── forbidden.cr │ │ ├── gone.cr │ │ ├── http_exception.cr │ │ ├── length_required.cr │ │ ├── logic.cr │ │ ├── method_not_allowed.cr │ │ ├── not_acceptable.cr │ │ ├── not_found.cr │ │ ├── not_implemented.cr │ │ ├── precondition_failed.cr │ │ ├── service_unavailable.cr │ │ ├── stop_format_listener.cr │ │ ├── too_many_requests.cr │ │ ├── unauthorized.cr │ │ ├── unprocessable_entity.cr │ │ └── unsupported_media_type.cr │ ├── http_kernel.cr │ └── listeners/ │ ├── error.cr │ └── routing.cr ├── image_size/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── athena-image_size_spec.cr │ │ ├── images/ │ │ │ ├── cur/ │ │ │ │ └── 32x256_8_0.cur │ │ │ ├── mng/ │ │ │ │ └── 61x42_0_0.mng │ │ │ ├── png/ │ │ │ │ └── 192x110_8_0.apng │ │ │ ├── psd/ │ │ │ │ └── 16x20_8_3.psd │ │ │ ├── swf/ │ │ │ │ └── 450x200_0_0.swf │ │ │ └── tiff/ │ │ │ ├── big-endian.68x49_8_1.tiff │ │ │ └── little-endian.40x68_8_1.tiff │ │ └── spec_helper.cr │ └── src/ │ ├── athena-image_size.cr │ ├── extractors/ │ │ ├── abstract_ico.cr │ │ ├── abstract_png.cr │ │ ├── abstract_tiff.cr │ │ ├── apng.cr │ │ ├── bmp.cr │ │ ├── cur.cr │ │ ├── extractor.cr │ │ ├── gif.cr │ │ ├── ico.cr │ │ ├── ii_tiff.cr │ │ ├── jpeg.cr │ │ ├── mm_tiff.cr │ │ ├── mng.cr │ │ ├── png.cr │ │ ├── psd.cr │ │ ├── svg.cr │ │ ├── swf.cr │ │ └── webp.cr │ ├── image.cr │ └── image_format.cr ├── mercure/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── authorization_spec.cr │ │ ├── discovery_spec.cr │ │ ├── hub/ │ │ │ ├── hub_spec.cr │ │ │ └── registry_spec.cr │ │ ├── spec_helper.cr │ │ ├── token_factory/ │ │ │ └── jwt_spec.cr │ │ └── token_provider/ │ │ ├── callable_spec.cr │ │ ├── factory_spec.cr │ │ └── static_spec.cr │ └── src/ │ ├── athena-mercure.cr │ ├── authorization.cr │ ├── discovery.cr │ ├── exception/ │ │ ├── invalid_argument.cr │ │ └── runtime.cr │ ├── hub/ │ │ ├── hub.cr │ │ ├── interface.cr │ │ └── registry.cr │ ├── spec.cr │ ├── token_factory/ │ │ ├── interface.cr │ │ └── jwt.cr │ ├── token_provider/ │ │ ├── callable.cr │ │ ├── factory.cr │ │ ├── interface.cr │ │ └── static.cr │ └── update.cr ├── mercure-bundle/ │ ├── README.md │ ├── docs/ │ │ └── README.md │ └── mkdocs.yml ├── mime/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── UPGRADING.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── abstract_types_guesser_test_case.cr │ │ ├── address_spec.cr │ │ ├── draft_email_spec.cr │ │ ├── email_spec.cr │ │ ├── encoder/ │ │ │ ├── base64_content_spec.cr │ │ │ ├── eight_bit_content_spec.cr │ │ │ ├── idn_address_spec.cr │ │ │ ├── quoted_printable_content_spec.cr │ │ │ ├── quoted_printable_mime_header_spec.cr │ │ │ └── rfc2231_spec.cr │ │ ├── fixtures/ │ │ │ ├── content.txt │ │ │ ├── mimetypes/ │ │ │ │ ├── -test │ │ │ │ ├── .unknownextension │ │ │ │ ├── abc.csv │ │ │ │ ├── directory/ │ │ │ │ │ └── .empty │ │ │ │ ├── other-file.example │ │ │ │ └── test │ │ │ ├── samples/ │ │ │ │ └── charsets/ │ │ │ │ ├── iso-2022-jp/ │ │ │ │ │ └── one.txt │ │ │ │ ├── iso-8859-1/ │ │ │ │ │ └── one.txt │ │ │ │ └── utf-8/ │ │ │ │ ├── one.txt │ │ │ │ ├── three.txt │ │ │ │ └── two.txt │ │ │ └── test.docx │ │ ├── header/ │ │ │ ├── collection_spec.cr │ │ │ ├── date_spec.cr │ │ │ ├── identification_spec.cr │ │ │ ├── mailbox_list_spec.cr │ │ │ ├── mailbox_spec.cr │ │ │ ├── parameterized_spec.cr │ │ │ ├── path_spec.cr │ │ │ └── unstructured_spec.cr │ │ ├── magic_types_guesser_spec.cr │ │ ├── message_converter_spec.cr │ │ ├── message_spec.cr │ │ ├── native_types_guessuer_spec.cr │ │ ├── part/ │ │ │ ├── data_spec.cr │ │ │ ├── file_spec.cr │ │ │ ├── message_spec.cr │ │ │ ├── multipart/ │ │ │ │ ├── alternative_spec.cr │ │ │ │ ├── digest_spec.cr │ │ │ │ ├── form_spec.cr │ │ │ │ ├── mixed_spec.cr │ │ │ │ └── related_spec.cr │ │ │ └── text_spec.cr │ │ ├── spec_helper.cr │ │ └── types_spec.cr │ └── src/ │ ├── address.cr │ ├── athena-mime.cr │ ├── draft_email.cr │ ├── email.cr │ ├── encoder/ │ │ ├── address_encoder_interface.cr │ │ ├── base64_content.cr │ │ ├── content_encoder_interface.cr │ │ ├── eight_bit_content.cr │ │ ├── encoder_interface.cr │ │ ├── idn_address.cr │ │ ├── mime_header_encoder_interface.cr │ │ ├── quoted_printable_content.cr │ │ ├── quoted_printable_mime_header.cr │ │ └── rfc2231.cr │ ├── exception/ │ │ ├── header_not_found.cr │ │ ├── invalid_argument.cr │ │ ├── logic.cr │ │ └── runtime.cr │ ├── header/ │ │ ├── abstract.cr │ │ ├── collection.cr │ │ ├── date.cr │ │ ├── identification.cr │ │ ├── interface.cr │ │ ├── mailbox.cr │ │ ├── mailbox_list.cr │ │ ├── parameterized.cr │ │ ├── path.cr │ │ └── unstructured.cr │ ├── magic_types_guesser.cr │ ├── message.cr │ ├── message_converter.cr │ ├── native_types_guesser.cr │ ├── part/ │ │ ├── abstract.cr │ │ ├── abstract_multipart.cr │ │ ├── data.cr │ │ ├── file.cr │ │ ├── message.cr │ │ ├── multipart/ │ │ │ ├── alternative.cr │ │ │ ├── digest.cr │ │ │ ├── form.cr │ │ │ ├── mixed.cr │ │ │ └── related.cr │ │ └── text.cr │ ├── types/ │ │ └── data.cr │ ├── types.cr │ ├── types_guesser_interface.cr │ └── types_interface.cr ├── negotiation/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── UPGRADING.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── accept_language_spec.cr │ │ ├── accept_match_spec.cr │ │ ├── accept_spec.cr │ │ ├── base_accept_spec.cr │ │ ├── charset_negotiator_spec.cr │ │ ├── encoding_negotiator_spec.cr │ │ ├── language_negotiator_spec.cr │ │ ├── negotiator_spec.cr │ │ ├── negotiator_test_case.cr │ │ └── spec_helper.cr │ └── src/ │ ├── abstract_negotiator.cr │ ├── accept.cr │ ├── accept_charset.cr │ ├── accept_encoding.cr │ ├── accept_language.cr │ ├── accept_match.cr │ ├── athena-negotiation.cr │ ├── base_accept.cr │ ├── charset_negotiator.cr │ ├── encoding_negotiator.cr │ ├── exception/ │ │ ├── invalid_argument.cr │ │ ├── invalid_language.cr │ │ └── invalid_media_type.cr │ ├── language_negotiator.cr │ └── negotiator.cr ├── routing/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── UPGRADING.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── fixtures/ │ │ │ └── route_provider/ │ │ │ ├── route_collection0.cr │ │ │ ├── route_collection1.cr │ │ │ ├── route_collection10.cr │ │ │ ├── route_collection11.cr │ │ │ ├── route_collection12.cr │ │ │ ├── route_collection2.cr │ │ │ ├── route_collection3.cr │ │ │ ├── route_collection4.cr │ │ │ ├── route_collection5.cr │ │ │ ├── route_collection6.cr │ │ │ ├── route_collection7.cr │ │ │ ├── route_collection8.cr │ │ │ └── route_collection9.cr │ │ ├── generator/ │ │ │ └── url_generator_spec.cr │ │ ├── matcher/ │ │ │ ├── abstract_url_matcher_test_case.cr │ │ │ ├── redirectable_url_matcher_spec.cr │ │ │ ├── traceable_url_matcher_spec.cr │ │ │ └── url_matcher_spec.cr │ │ ├── parameters_spec.cr │ │ ├── request_context_spec.cr │ │ ├── requirement/ │ │ │ ├── enum_spec.cr │ │ │ └── requirement_spec.cr │ │ ├── route_collection_spec.cr │ │ ├── route_compiler_spec.cr │ │ ├── route_provider_spec.cr │ │ ├── route_spec.cr │ │ ├── router_spec.cr │ │ ├── routing_handler_spec.cr │ │ ├── spec_helper.cr │ │ └── static_prefix_collection_spec.cr │ └── src/ │ ├── annotations.cr │ ├── athena-routing.cr │ ├── compiled_route.cr │ ├── exception/ │ │ ├── invalid_argument.cr │ │ ├── invalid_parameter.cr │ │ ├── method_not_allowed.cr │ │ ├── missing_required_parameters.cr │ │ ├── no_configuration.cr │ │ ├── resource_not_found.cr │ │ └── route_not_found.cr │ ├── ext/ │ │ └── regex.cr │ ├── generator/ │ │ ├── configurable_requirements_interface.cr │ │ ├── interface.cr │ │ ├── reference_type.cr │ │ └── url_generator.cr │ ├── matcher/ │ │ ├── redirectable_url_matcher_interface.cr │ │ ├── request_matcher_interface.cr │ │ ├── traceable_url_matcher.cr │ │ ├── url_matcher.cr │ │ └── url_matcher_interface.cr │ ├── parameters.cr │ ├── request_context.cr │ ├── request_context_aware_interface.cr │ ├── requirement/ │ │ ├── enum.cr │ │ └── requirement.cr │ ├── route.cr │ ├── route_collection.cr │ ├── route_compiler.cr │ ├── route_provider.cr │ ├── router.cr │ ├── router_interface.cr │ ├── routing_handler.cr │ └── static_prefix_collection.cr ├── serializer/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── UPGRADING.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── athena-serializer_spec.cr │ │ ├── compiler_spec.cr │ │ ├── exclusion_strategies/ │ │ │ ├── custom_strategy_spec.cr │ │ │ ├── group_spec.cr │ │ │ └── version_spec.cr │ │ ├── models/ │ │ │ ├── accessor.cr │ │ │ ├── accessor_order.cr │ │ │ ├── basic.cr │ │ │ ├── discriminator.cr │ │ │ ├── emit_null.cr │ │ │ ├── empty.cr │ │ │ ├── exclude.cr │ │ │ ├── expose.cr │ │ │ ├── groups.cr │ │ │ ├── ignore_on_deserialize.cr │ │ │ ├── ignore_on_serialize.cr │ │ │ ├── name.cr │ │ │ ├── nested.cr │ │ │ ├── post_deserialize.cr │ │ │ ├── post_serialize.cr │ │ │ ├── pre_serialize.cr │ │ │ ├── read_only.cr │ │ │ ├── skip.cr │ │ │ ├── skip_when_empty.cr │ │ │ └── virtual_property.cr │ │ ├── navigators/ │ │ │ ├── deserialization_navigator_spec.cr │ │ │ └── serialization_navigator_spec.cr │ │ ├── serialization_context_spec.cr │ │ ├── serializer_spec.cr │ │ ├── spec_helper.cr │ │ └── visitors/ │ │ ├── json_deserialization_visitor_spec.cr │ │ ├── json_serialization_visitor_spec.cr │ │ ├── yaml_deserialization_visitor_spec.cr │ │ └── yaml_serialization_visitor_spec.cr │ └── src/ │ ├── annotations.cr │ ├── any.cr │ ├── athena-serializer.cr │ ├── construction/ │ │ ├── instantiate_object_constructor.cr │ │ └── object_constructor_interface.cr │ ├── context.cr │ ├── deserialization_context.cr │ ├── exception/ │ │ ├── deserialization_exception.cr │ │ ├── logic.cr │ │ ├── missing_required_property.cr │ │ ├── nil_required_property.cr │ │ ├── property_exception.cr │ │ └── serialization_exception.cr │ ├── exclusion_strategies/ │ │ ├── disjunct.cr │ │ ├── exclusion_strategy_interface.cr │ │ ├── groups.cr │ │ └── version.cr │ ├── navigators/ │ │ ├── deserialization_navigator.cr │ │ ├── navigator_factory.cr │ │ └── serialization_navigator.cr │ ├── property_metadata.cr │ ├── serializable.cr │ ├── serialization_context.cr │ ├── serializer.cr │ ├── serializer_interface.cr │ └── visitors/ │ ├── deserialization_visitor.cr │ ├── deserialization_visitor_interface.cr │ ├── json_deserialization_visitor.cr │ ├── json_serialization_visitor.cr │ ├── serialization_visitor_interface.cr │ ├── yaml_deserialization_visitor.cr │ └── yaml_serialization_visitor.cr ├── spec/ │ ├── .editorconfig │ ├── .gitignore │ ├── CHANGELOG.md │ ├── CONTRIBUTING.md │ ├── LICENSE │ ├── README.md │ ├── UPGRADING.md │ ├── docs/ │ │ └── README.md │ ├── mkdocs.yml │ ├── shard.yml │ ├── spec/ │ │ ├── athena-spec_spec.cr │ │ ├── compiler_spec.cr │ │ ├── methods_spec.cr │ │ └── spec_helper.cr │ └── src/ │ ├── athena-spec.cr │ ├── methods.cr │ └── test_case.cr └── validator/ ├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── UPGRADING.md ├── docs/ │ └── README.md ├── mkdocs.yml ├── shard.yml ├── spec/ │ ├── athena-validator_spec.cr │ ├── constraint_spec.cr │ ├── constraints/ │ │ ├── all_validator_spec.cr │ │ ├── at_least_one_of_validator_spec.cr │ │ ├── blank_validator_spec.cr │ │ ├── callback_validator_spec.cr │ │ ├── choice_validator_spec.cr │ │ ├── collection_spec.cr │ │ ├── collection_validator_test_case.cr │ │ ├── composite_spec.cr │ │ ├── compound_validator_spec.cr │ │ ├── count_validator_spec.cr │ │ ├── email_validator_spec.cr │ │ ├── equal_to_validator_spec.cr │ │ ├── file_spec.cr │ │ ├── file_validator_ath_file_spec.cr │ │ ├── file_validator_path_spec.cr │ │ ├── file_validator_std_file_spec.cr │ │ ├── file_validator_test_case.cr │ │ ├── fixtures/ │ │ │ └── file-big.txt │ │ ├── greater_than_or_equal_validator_spec.cr │ │ ├── greater_than_validator_spec.cr │ │ ├── hash_collection_validator_spec.cr │ │ ├── hash_like_object_collection_validator_spec.cr │ │ ├── image_validator_spec.cr │ │ ├── ip_validator_spec.cr │ │ ├── is_false_validator_spec.cr │ │ ├── is_nil_validator_spec.cr │ │ ├── is_true_validator_spec.cr │ │ ├── isbn_validator_spec.cr │ │ ├── isin_validator_spec.cr │ │ ├── issn_validator_spec.cr │ │ ├── length_validator_spec.cr │ │ ├── less_than_or_equal_validator_spec.cr │ │ ├── less_than_validator_spec.cr │ │ ├── luhn_validator_spec.cr │ │ ├── negative_or_zero_validator_spec.cr │ │ ├── negative_validator_spec.cr │ │ ├── not_blank_validator_spec.cr │ │ ├── not_equal_to_validator_spec.cr │ │ ├── not_nil_validator_spec.cr │ │ ├── positive_or_zero_validator_spec.cr │ │ ├── positive_validator_spec.cr │ │ ├── range_validator_spec.cr │ │ ├── regex_validator_spec.cr │ │ ├── sequentially_validator_spec.cr │ │ ├── unique_validator_spec.cr │ │ ├── url_validator_spec.cr │ │ └── valid_validator_spec.cr │ ├── metadata/ │ │ └── class_metadata_spec.cr │ ├── property_path_spec.cr │ ├── spec/ │ │ └── compound_constraint_test_case_spec.cr │ ├── spec_helper.cr │ ├── validatable_spec.cr │ ├── validator/ │ │ └── recursive_validator_spec.cr │ └── violation/ │ ├── constraint_violation_list_spec.cr │ └── constraint_violation_spec.cr └── src/ ├── athena-validator.cr ├── constraint.cr ├── constraint_validator.cr ├── constraint_validator_factory.cr ├── constraint_validator_factory_interface.cr ├── constraint_validator_interface.cr ├── constraints/ │ ├── abstract_comparison.cr │ ├── abstract_comparison_validator.cr │ ├── all.cr │ ├── at_least_one_of.cr │ ├── blank.cr │ ├── callback.cr │ ├── choice.cr │ ├── collection.cr │ ├── composite.cr │ ├── compound.cr │ ├── count.cr │ ├── email.cr │ ├── equal_to.cr │ ├── existence.cr │ ├── file.cr │ ├── greater_than.cr │ ├── greater_than_or_equal.cr │ ├── group_sequence.cr │ ├── image.cr │ ├── ip.cr │ ├── is_false.cr │ ├── is_nil.cr │ ├── is_true.cr │ ├── isbn.cr │ ├── isin.cr │ ├── issn.cr │ ├── length.cr │ ├── less_than.cr │ ├── less_than_or_equal.cr │ ├── luhn.cr │ ├── negative.cr │ ├── negative_or_zero.cr │ ├── not_blank.cr │ ├── not_equal_to.cr │ ├── not_nil.cr │ ├── optional.cr │ ├── positive.cr │ ├── positive_or_zero.cr │ ├── range.cr │ ├── regex.cr │ ├── required.cr │ ├── sequentially.cr │ ├── unique.cr │ ├── url.cr │ └── valid.cr ├── exception/ │ ├── invalid_argument.cr │ ├── logic.cr │ └── unexpected_value_error.cr ├── execution_context.cr ├── execution_context_interface.cr ├── metadata/ │ ├── cascading_strategy.cr │ ├── class_metadata.cr │ ├── generic_metadata.cr │ ├── getter_metadata.cr │ ├── metadata.cr │ ├── metadata_factory.cr │ ├── metadata_factory_interface.cr │ ├── metadata_interface.cr │ ├── property_metadata.cr │ └── property_metadata_interface.cr ├── property_path.cr ├── spec/ │ ├── abstract_validator_test_case.cr │ ├── compound_constraint_test_case.cr │ ├── constraint_validator_test_case.cr │ └── validator_test_case.cr ├── spec.cr ├── validatable.cr ├── validator/ │ ├── contextual_validator_interface.cr │ ├── recursive_contextual_validator.cr │ ├── recursive_validator.cr │ └── validator_interface.cr └── violation/ ├── constraint_violation.cr ├── constraint_violation_builder.cr ├── constraint_violation_builder_interface.cr ├── constraint_violation_interface.cr ├── constraint_violation_list.cr └── constraint_violation_list_interface.cr ================================================ FILE CONTENTS ================================================ ================================================ FILE: .ameba.yml ================================================ Documentation/DocumentationAdmonition: Enabled: false Lint/ComparisonToBoolean: Enabled: false # TODO: Enable once https://github.com/crystal-ameba/ameba/issues/432 is resolved Lint/Formatting: Enabled: false # This has its own dedicated CI job Lint/NotNil: Enabled: false Lint/Typos: Enabled: false # This has its own dedicated CI job Lint/UselessAssign: ExcludeTypeDeclarations: true # TODO: Disable this once https://github.com/crystal-ameba/ameba/issues/447 is resolved Naming/AccessorMethodName: Enabled: false Naming/BlockParameterName: Enabled: false Naming/QueryBoolMethods: Enabled: false Style/LargeNumbers: Enabled: true ================================================ FILE: .changes/clock/v0.2.0.md ================================================ ## [0.2.0] - 2025-01-26 ### Changed - **Breaking:** Remove `Athena::Clock::Interface#sleep(Number)` overload ([#449]) (George Dietrich) ### Fixed - Fix type error when trying to use `ACLK::Aware#now` ([#498]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/clock/releases/tag/v0.2.0 [#449]: https://github.com/athena-framework/athena/pull/449 [#498]: https://github.com/athena-framework/athena/pull/498 ## [0.1.2] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) ### Fixed - Fix that `Athena::Clock::Aware` was not required by default ([#365]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/clock/releases/tag/v0.1.2 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.1.1] - 2023-10-09 _Administrative release, no functional changes_ [0.1.1]: https://github.com/athena-framework/clock/releases/tag/v0.1.1 ## [0.1.0] - 2023-09-16 _Initial release._ [0.1.0]: https://github.com/athena-framework/clock/releases/tag/v0.1.0 ================================================ FILE: .changes/clock/v0.3.0.md ================================================ ## [0.3.0] - 2026-04-19 ### Removed - Remove `ACLK::Monotonic` ([#667]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/clock/releases/tag/v0.3.0 [#667]: https://github.com/athena-framework/athena/pull/667 ================================================ FILE: .changes/console/v0.4.1.md ================================================ ## [0.4.1] - 2025-02-08 ### Fixed - Fix incorrectly aligned block ([#519]) (Zohir Tamda) [0.4.1]: https://github.com/athena-framework/console/releases/tag/v0.4.1 [#519]: https://github.com/athena-framework/athena/pull/519 ## [0.4.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) ### Added - **Breaking:** Add `ACON::Output::Verbosity::SILENT` verbosity level ([#489]) (George Dietrich) - **Breaking:** Rename `ACON::Completion::Input#must_suggest_values_for?` to `#must_suggest_option_values_for?` ([#498]) (George Dietrich) - Update minimum `crystal` version to `~> 1.13.0` ([#498]) (George Dietrich) - Add `#assert_command_is_not_successful` spec expectation method ([#498]) (George Dietrich) - Add support for [`FORCE_COLOR`](https://force-color.org/) and improve color support logic ([#488]) (George Dietrich) ### Fixed - Fix unexpected completion value when given an array of options ([#498]) (George Dietrich) - Fix error when trying to set `ACON::Helper::Table::Style#padding_char` ([#498]) (George Dietrich) [0.4.0]: https://github.com/athena-framework/console/releases/tag/v0.4.0 [#428]: https://github.com/athena-framework/athena/pull/428 [#488]: https://github.com/athena-framework/athena/pull/488 [#489]: https://github.com/athena-framework/athena/pull/489 [#498]: https://github.com/athena-framework/athena/pull/498 ## [0.3.6] - 2024-07-31 ### Changed - **Breaking:** `ACON::Application#getter` and constructor argument must now be a `String` instead of `SemanticVersion` ([#419]) (George Dietrich) - Changed the default `ACON::Application` version to `UNKNOWN` from `0.1.0` ([#419]) (George Dietrich) - List commands in a namespace when using it as the command name ([#427]) (George Dietrich) - Use single quotes in text descriptor to quote values in the output ([#427]) (George Dietrich) [0.3.6]: https://github.com/athena-framework/console/releases/tag/v0.3.6 [#419]: https://github.com/athena-framework/athena/pull/419 [#427]: https://github.com/athena-framework/athena/pull/427 ## [0.3.5] - 2024-04-09 ### Changed - Update minimum `crystal` version to `~> 1.11.0` ([#270]) (George Dietrich) - Integrate website into monorepo ([#365]) (George Dietrich) ### Added - Support for Windows OS ([#270]) (George Dietrich) ### Fixed - Fix incorrect column/width `ACON::Terminal` values on Windows ([#361]) (George Dietrich) [0.3.5]: https://github.com/athena-framework/console/releases/tag/v0.3.5 [#270]: https://github.com/athena-framework/athena/pull/270 [#365]: https://github.com/athena-framework/athena/pull/365 [#361]: https://github.com/athena-framework/athena/pull/361 ## [0.3.4] - 2023-10-10 ### Added - Add support for tab completion to the `bash` shell when binary is in the `bin/` directory and referenced with `./` ([#323]) (George Dietrich) [0.3.4]: https://github.com/athena-framework/console/releases/tag/v0.3.4 [#323]: https://github.com/athena-framework/athena/pull/323 ## [0.3.3] - 2023-10-09 ### Changed - Update minimum `crystal` version to `~> 1.8.0` ([#282]) (George Dietrich) ### Added - **Breaking:** Add `ACON::Helper::ProgressBar` to enable rendering progress bars ([#304]) (George Dietrich) - Add native shell tab completion support for `bash`, `zsh`, and `fish` for both built-in and custom commands ([#294], [#296], [#297], [#299]) (George Dietrich) - Add `ACON::Helper::ProgressIndicator` to enable rendering spinners ([#314]) (George Dietrich) - Add support for defining a max height for an `ACON::Output::Section` ([#303]) (George Dietrich) - Add `ACON::Helper.format_time` to format a duration as a human readable string ([#304]) (George Dietrich) - Add `#assert_command_is_successful` helper method to `ACON::Spec::CommandTester` and `ACON::Spec::ApplicationTester` ([#294]) (George Dietrich) ### Fixed - Ensure long lines with URLs are not cut when wrapped ([#314]) (George Dietrich) - Do not emit erroneous newline from `ACON::Style::Athena` when it's the first thing being written ([#314]) (George Dietrich) - Fix misalignment when word wrapping a hyperlink ([#305]) (George Dietrich) - Do not emit erroneous extra newlines from an `ACON::Output::Section` ([#303]) (George Dietrich) - Fix misalignment within a vertical table with multi-line cell ([#300]) (George Dietrich) [0.3.3]: https://github.com/athena-framework/console/releases/tag/v0.3.3 [#282]: https://github.com/athena-framework/athena/pull/282 [#294]: https://github.com/athena-framework/athena/pull/294 [#296]: https://github.com/athena-framework/athena/pull/296 [#297]: https://github.com/athena-framework/athena/pull/297 [#299]: https://github.com/athena-framework/athena/pull/299 [#300]: https://github.com/athena-framework/athena/pull/300 [#303]: https://github.com/athena-framework/athena/pull/303 [#304]: https://github.com/athena-framework/athena/pull/304 [#305]: https://github.com/athena-framework/athena/pull/305 [#314]: https://github.com/athena-framework/athena/pull/314 ## [0.3.2] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) ### Fixed - Fix formatting issue in Crystal `1.8-dev` ([#258]) (George Dietrich) [0.3.2]: https://github.com/athena-framework/console/releases/tag/v0.3.2 [#261]: https://github.com/athena-framework/athena/pull/261 [#258]: https://github.com/athena-framework/athena/pull/258 ## [0.3.1] - 2023-02-04 ### Added - Add better integration between `Athena::Console` and `Athena::DependencyInjection` ([#259]) (George Dietrich) [0.3.1]: https://github.com/athena-framework/console/releases/tag/v0.3.1 [#259]: https://github.com/athena-framework/athena/pull/259 ## [0.3.0] - 2023-01-07 ### Changed - **Breaking:** deprecate command default name/description class variables in favor of the new `ACONA::AsCommand` annotation ([#214]) (George Dietrich) - **Breaking:** refactor `ACON::Command#application=` to no longer have a `nil` default value ([#217]) (George Dietrich) - **Breaking:** refactor `ACON::Command#process_title=` no longer accept `nil` ([#217]) (George Dietrich) - **Breaking:** rename `ACON::Command#process_title=` to `ACON::Command#process_title` ([#217]) (George Dietrich) ### Added - **Breaking:** add `#table` method to `ACON::Style::Interface` ([#220]) (George Dietrich) - Add `ACONA::AsCommand` annotation to configure a command's name, description, aliases, and if it should be hidden ([#214]) (George Dietrich) - Add support for generating tables ([#220]) (George Dietrich) ### Fixed - Fix issue with using `ACON::Formatter::Output#format_and_wrap` with `nil` input and an edge case when wrapping a string with a space at the limit ([#220]) (George Dietrich) - Fix `ACON::Formatter::NullStyle#*_option` method using incorrect `ACON::Formatter::Mode` type restriction ([#220]) (George Dietrich) - Fix some flakiness when testing commands with input ([#224]) (George Dietrich) - Fix compiler error when trying to use `ACON::Style::Athena#error_style` ([#240]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/console/releases/tag/v0.3.0 [#214]: https://github.com/athena-framework/athena/pull/214 [#217]: https://github.com/athena-framework/athena/pull/217 [#220]: https://github.com/athena-framework/athena/pull/220 [#224]: https://github.com/athena-framework/athena/pull/224 [#240]: https://github.com/athena-framework/athena/pull/240 ## [0.2.1] - 2022-09-05 ### Changed - **Breaking:** ensure parameter names defined on interfaces match the implementation ([#188]) (George Dietrich) ### Added - Add an `ACON::Input::Interface` based on a command line string ([#186], [#187]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/console/releases/tag/v0.2.1 [#186]: https://github.com/athena-framework/athena/pull/186 [#187]: https://github.com/athena-framework/athena/pull/187 [#188]: https://github.com/athena-framework/athena/pull/188 ## [0.2.0] - 2022-05-14 _First release a part of the monorepo._ ### Changed - **Breaking:** remove `ACON::Formatter::Mode` in favor of `Colorize::Mode`. Breaking only if not using symbol autocasting. ([#170]) (George Dietrich) - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Added - Add `VERSION` constant to `Athena::Console` namespace ([#166]) (George Dietrich) - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Fixed - Disallow multi char option shortcuts made up of diff chars ([#164]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/console/releases/tag/v0.2.0 [#164]: https://github.com/athena-framework/athena/pull/164 [#166]: https://github.com/athena-framework/athena/pull/166 [#169]: https://github.com/athena-framework/athena/pull/169 [#170]: https://github.com/athena-framework/athena/pull/170 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.1.1] - 2021-12-01 ### Fixed - **Breaking:** fix typo in parameter name of `ACON::Command#option` method ([#3]) (George Dietrich) - Fix recursive struct error ([#4]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/console/releases/tag/v0.1.1 [#3]: https://github.com/athena-framework/console/pull/3 [#4]: https://github.com/athena-framework/console/pull/4 ## [0.1.0] - 2021-10-30 _Initial release._ [0.1.0]: https://github.com/athena-framework/console/releases/tag/v0.1.0 ================================================ FILE: .changes/console/v0.4.2.md ================================================ ## [0.4.2] - 2025-09-04 ### Added - Add ability to customize the finished state of an `ACON::Helper::ProgressIndicator` ([#535]) (George Dietrich) - Add `markdown` `ACON::Helper::Table` style ([#536]) (George Dietrich) - Add support for nested style tags ([#568]) (George Dietrich) ### Fixed - Fix `ACON::Helper::ProgressBar` messing up output in console section with EOL ([#537]) (George Dietrich) [0.4.2]: https://github.com/athena-framework/console/releases/tag/v0.4.2 [#535]: https://github.com/athena-framework/athena/pull/535 [#536]: https://github.com/athena-framework/athena/pull/536 [#568]: https://github.com/athena-framework/athena/pull/568 [#537]: https://github.com/athena-framework/athena/pull/537 ================================================ FILE: .changes/console/v0.4.3.md ================================================ ## [0.4.3] - 2026-04-19 ### Added - Add opt-in support for deriving the command name from `PROGRAM_NAME` when a CLI binary is invoked via a symlink ([#645]) (George Dietrich) [0.4.3]: https://github.com/athena-framework/console/releases/tag/v0.4.3 [#645]: https://github.com/athena-framework/athena/pull/645 ================================================ FILE: .changes/contracts/v0.1.0.md ================================================ ## [0.1.0] - 2025-08-02 _Initial release._ [0.1.0]: https://github.com/athena-framework/contracts/releases/tag/v0.1.0 ================================================ FILE: .changes/dependency-injection/v0.4.3.md ================================================ ## [0.4.3] - 2025-02-08 ### Changed - **Breaking:** prevent auto registering of already registered services ([#520]) (George Dietrich) ### Fixed - Ensure all array values have proper `#of` type ([#508]) (George Dietrich) [0.4.3]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.3 [#508]: https://github.com/athena-framework/athena/pull/508 [#520]: https://github.com/athena-framework/athena/pull/520 ## [0.4.2] - 2025-01-26 _Administrative release, no functional changes_ [0.4.2]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.2 ## [0.4.1] - 2024-07-31 ### Changed - **Breaking:** single implementation aliases are now explicit ([#408]) (George Dietrich) ### Fixed - Fix default/nil values related to `object_of` and `array_of` being unavailable in bundle extensions ([#432]) (George Dietrich) [0.4.1]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.1 [#408]: https://github.com/athena-framework/athena/pull/408 [#432]: https://github.com/athena-framework/athena/pull/432 ## [0.4.0] - 2024-04-09 ### Changed - **Breaking:** remove `Clock`, `Console`, and `EventDispatcher` built-in integrations ([#337]) (George Dietrich) - **Breaking:** major internal refactor ([#337], [#378]) (George Dietrich) - **Breaking:** replace `ADI.auto_configure` with [ADI::Autoconfigure](https://athenaframework.org/DependencyInjection/Autoconfigure/) ([#387]) (George Dietrich) - **Breaking:** replace `alias` `ADI::Register` field with [ADI::AsAlias](https://athenaframework.org/DependencyInjection/AsAlias/) ([#389]) (George Dietrich) - Integrate website into monorepo ([#365]) (George Dietrich) ### Added - Add ability to easily extend/customize the container ([#337], [#348], [#371], [#372], [#373], [#374], [#377], [#379], [#382], [#383]) (George Dietrich) - Add ability to define method calls that should be made during service instantiation ([#384]) (George Dietrich) - Add new [ADI::AutoconfigureTag](https://athenaframework.org/DependencyInjection/AutoconfigureTag/) and [ADI::TaggedIterator](https://athenaframework.org/DependencyInjection/TaggedIterator/) to make working with tagged services easier ([#387]) (George Dietrich) - Add `ADI.configuration_annotation` to `Athena::DependencyInjection` from `Athena::Config` ([#392]) (George Dietrich) [0.4.0]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.0 [#337]: https://github.com/athena-framework/athena/pull/337 [#348]: https://github.com/athena-framework/athena/pull/348 [#365]: https://github.com/athena-framework/athena/pull/365 [#371]: https://github.com/athena-framework/athena/pull/371 [#372]: https://github.com/athena-framework/athena/pull/372 [#373]: https://github.com/athena-framework/athena/pull/373 [#374]: https://github.com/athena-framework/athena/pull/374 [#377]: https://github.com/athena-framework/athena/pull/377 [#378]: https://github.com/athena-framework/athena/pull/378 [#379]: https://github.com/athena-framework/athena/pull/379 [#382]: https://github.com/athena-framework/athena/pull/382 [#383]: https://github.com/athena-framework/athena/pull/383 [#384]: https://github.com/athena-framework/athena/pull/384 [#387]: https://github.com/athena-framework/athena/pull/387 [#389]: https://github.com/athena-framework/athena/pull/389 [#392]: https://github.com/athena-framework/athena/pull/392 ## [0.3.8] - 2023-12-16 ### Fixed - Avoid depending directly on Crystal macro types ([#335]) (George Dietrich) [0.3.8]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.8 [#335]: https://github.com/athena-framework/athena/pull/335 ## [0.3.7] - 2023-10-09 ### Added - Add integration between `Athena::DependencyInjection` and the `Athena::Clock` component ([#318]) (George Dietrich) [0.3.7]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.7 [#318]: https://github.com/athena-framework/athena/pull/318 ## [0.3.6] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) [0.3.6]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.6 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.3.5] - 2023-02-04 ### Added - Add better integration between `Athena::DependencyInjection` and the `Athena::Console` and `Athena::EventDispatcher` components ([#259]) (George Dietrich) [0.3.5]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.5 [#259]: https://github.com/athena-framework/athena/pull/259 ## [0.3.4] - 2023-01-07 ### Changed - Refactor various internal logic (George Dietrich) [0.3.4]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.4 ## [0.3.3] - 2022-05-14 _First release a part of the monorepo._ ### Changed - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Added - Add getting started documentation to API docs ([#172]) (George Dietrich) [0.3.3]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.3 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.3.2] - 2021-10-30 ### Changed - Unused services are now excluded from the container ([#30]) (George Dietrich) [0.3.2]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.2 [#30]: https://github.com/athena-framework/dependency-injection/pull/30 ## [0.3.1] - 2021-03-28 ### Fixed - Fix error with untyped parameters with default values injecting ([#28]) (George Dietrich) [0.3.1]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.1 [#28]: https://github.com/athena-framework/dependency-injection/pull/28 ## [0.3.0] - 2021-03-20 ### Added - Allow injecting [configuration](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--configuration) into services ([#27]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.0 [#27]: https://github.com/athena-framework/dependency-injection/pull/27 ## [0.2.6] - 2021-03-15 ### Added - Allow using the `ADI::Inject` annotation on class methods to create [factories](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--factories) ([#25]) (George Dietrich) [0.2.6]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.6 [#25]: https://github.com/athena-framework/dependency-injection/pull/25 ## [0.2.5] - 2021-01-30 ### Changed - Migrate documentation to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#23], [#24]) (George Dietrich) [0.2.5]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.5 [#23]: https://github.com/athena-framework/dependency-injection/pull/23 [#24]: https://github.com/athena-framework/dependency-injection/pull/24 ## [0.2.4] - 2021-01-29 ### Added - Add dependency on `athena-framework/config` ([#20]) (George Dietrich) - Add support for injecting [parameters](https://athenaframework.org/architecture/config/#parameters) into a service ([#20]) (George Dietrich) - Add support for [service proxies](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--service-proxies) ([#21]) (George Dietrich) ### Removed - Remove the `lazy` `ADI::Register` field. All services are lazy by default now ([#21]) (George Dietrich) ### Fixed - Fix issue building documentation ([#22]) (George Dietrich) [0.2.4]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.4 [#20]: https://github.com/athena-framework/dependency-injection/pull/20 [#21]: https://github.com/athena-framework/dependency-injection/pull/21 [#22]: https://github.com/athena-framework/dependency-injection/pull/22 ## [0.2.3] - 2020-12-24 ### Fixed - Fix error when a parameter has a default value after an array parameter ([#19]) (George Dietrich) [0.2.3]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.3 [#19]: https://github.com/athena-framework/dependency-injection/pull/19 ## [0.2.2] - 2020-12-03 ### Changed - Update `crystal` version to allow version greater than `1.0.0` ([#18]) (George Dietrich) [0.2.2]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.2 [#18]: https://github.com/athena-framework/dependency-injection/pull/18 ## [0.2.1] - 2020-11-14 ### Added - Add a mock container instance to allow mocking services ([#15]) (George Dietrich) - Add ability to customize the type of a service within the container ([#15]) (George Dietrich) - Add support for [factory pattern](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--factories) constructors ([#16]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.1 [#15]: https://github.com/athena-framework/dependency-injection/pull/15 [#16]: https://github.com/athena-framework/dependency-injection/pull/16 ## [0.2.0] - 2020-06-09 _Major refactor of the component._ ### Added - Add concept of [aliasing services](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--aliasing-services) ([#10]) (George Dietrich) - Add concept of [binding values](https://athenaframework.org/DependencyInjection/#Athena::DependencyInjection:bind(key,value)) ([#10]) (George Dietrich) - Add concept of [auto configuration](https://athenaframework.org/DependencyInjection/#Athena::DependencyInjection:auto_configure(type,options)) ([#10]) (George Dietrich) - Add [ADI::Inject](https://athenaframework.org/DependencyInjection/Inject/) annotation ([#10]) (George Dietrich) - Add support for [generic services](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--generic-services) ([#10]) (George Dietrich) ### Changed - **Breaking:** manually provided arguments now need to be prefixed with a `_` ([#10]) (George Dietrich) - **Breaking:** service names are now based on the `FQN` of the type, downcase underscored by default ([#10]) (George Dietrich) - Updated [optional services](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--optional-services) to now be based on the type/default value of the parameter ([#10]) (George Dietrich) - Service dependencies are now resolved automatically, removes need to manually provide them ([#10]) (George Dietrich) ### Removed - **Breaking:** remove the `ADI::Service` module ([#10]) (George Dietrich) - **Breaking:** remove the `ADI::Injectable` module ([#10]) (George Dietrich) - **Breaking:** remove the `@?` syntax ([#10]) (George Dietrich) - **Breaking:** remove the `#get`, `#has`, `#resolve`, `#tagged`, and `#tags` methods from `ADI::ServiceContainer` ([#10]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.0 [#10]: https://github.com/athena-framework/dependency-injection/pull/10 ## [0.1.3] - 2020-04-06 ### Fixed - Fix an edge case by checking includers via `<=` ([#7]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.1.3 [#7]: https://github.com/athena-framework/dependency-injection/pull/7 ## [0.1.2] - 2020-02-22 ### Changed - Change type resolution logic to operate at compile time instead of runtime ([#6]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.1.2 [#6]: https://github.com/athena-framework/dependency-injection/pull/6 ## [0.1.1] - 2020-02-06 ### Added - Add the ability to redefine services ([#4]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.1.1 [#4]: https://github.com/athena-framework/dependency-injection/pull/4 ## [0.1.0] - 2020-01-31 _Initial release._ [0.1.0]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.1.0 ================================================ FILE: .changes/dependency-injection/v0.4.4.md ================================================ ## [0.4.4] - 2025-09-04 ### Changed - Relax DI argument validation for string parameters ([#548]) (George Dietrich) [0.4.4]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.4 [#548]: https://github.com/athena-framework/athena/pull/548 ================================================ FILE: .changes/dependency-injection/v0.4.5.md ================================================ ## [0.4.5] - 2026-04-19 ### Changed - Improve compile time error messages ([#646]) (George Dietrich) - Reduce the amount of ivars within `ADI::ServiceContainer` ([#649]) (George Dietrich) ### Added - Add ability to define schema configuration maps; with arbitrary keys, but structured values ([#641]) (George Dietrich) - Add ability to define re-usable schema object types ([#641]) (George Dietrich) - Add ability for aliases to take constructor parameter names into account ([#660]) (George Dietrich) - Add support for nested `>>` doc markup when using `object_schema` ([#684]) (George Dietrich) ### Fixed - Fix global extension schema `Enum` types not retaining their `::` prefix ([#639]) (George Dietrich) - Fix falsey binding values not resolving ([#647]) (George Dietrich) - Fix issue with using multiple extensions when one has a nested schema ([#658]) (George Dietrich) - Fix service argument validation errors overriding schema validation errors ([#659]) (George Dietrich) - Fix enum typed `object_schema` types not allowing symbol/number values ([#661]) (George Dietrich) - Fix compile time error when inadvertently using a type name that conflicts with an internal component type ([#678]) (George Dietrich) - Fix being unable to link to non top-level types within a nested properties' `>>` doc markup ([#684]) (George Dietrich) [0.4.5]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.5 [#646]: https://github.com/athena-framework/athena/pull/646 [#649]: https://github.com/athena-framework/athena/pull/649 [#641]: https://github.com/athena-framework/athena/pull/641 [#660]: https://github.com/athena-framework/athena/pull/660 [#684]: https://github.com/athena-framework/athena/pull/684 [#639]: https://github.com/athena-framework/athena/pull/639 [#647]: https://github.com/athena-framework/athena/pull/647 [#658]: https://github.com/athena-framework/athena/pull/658 [#659]: https://github.com/athena-framework/athena/pull/659 [#661]: https://github.com/athena-framework/athena/pull/661 [#678]: https://github.com/athena-framework/athena/pull/678 ================================================ FILE: .changes/dotenv/v0.2.0.md ================================================ ## [0.2.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/dotenv/releases/tag/v0.2.0 [#428]: https://github.com/athena-framework/athena/pull/428 ## [0.1.3] - 2024-07-31 ### Changed - Update minimum `crystal` version to `~> 1.13.0` ([#433]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/dotenv/releases/tag/v0.1.3 [#433]: https://github.com/athena-framework/athena/pull/433 ## [0.1.2] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) ### Added - Add helper `Athena::Dotenv.load` method to create and load `.env` files in one call ([#363]) (George Dietrich) ### Fixed - Fixed error parsing ENV vars starting with `_` ([#346]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/dotenv/releases/tag/v0.1.2 [#346]: https://github.com/athena-framework/athena/pull/346 [#363]: https://github.com/athena-framework/athena/pull/363 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.1.1] - 2023-10-09 _Administrative release, no functional changes_ [0.1.1]: https://github.com/athena-framework/dotenv/releases/tag/v0.1.1 ## [0.1.0] - 2023-04-23 _Initial release._ [0.1.0]: https://github.com/athena-framework/dotenv/releases/tag/v0.1.0 ================================================ FILE: .changes/dotenv/v0.2.1.md ================================================ ## [0.2.1] - 2025-11-09 ### Fixed - Fix being unable to call `Athena::Dotenv.load` with a single file ([#609]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/dotenv/releases/tag/v0.2.1 [#609]: https://github.com/athena-framework/athena/pull/609 ================================================ FILE: .changes/event-dispatcher/v0.3.1.md ================================================ ## [0.3.1] - 2025-01-26 _Administrative release, no functional changes_ [0.3.1]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.3.1 ## [0.3.0] - 2024-04-09 ### Changed - **Breaking:** remove `AED::EventListenerInterface` ([#391]) (George Dietrich) - Integrate website into monorepo ([#365]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.3.0 [#365]: https://github.com/athena-framework/athena/pull/365 [#391]: https://github.com/athena-framework/athena/pull/391 ## [0.2.3] - 2023-10-09 _Administrative release, no functional changes_ [0.2.3]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.2.3 ## [0.2.2] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) [0.2.2]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.2.2 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.2.1] - 2023-02-04 ### Added - Add better integration between `Athena::EventDispatcher` and `Athena::DependencyInjection` ([#259]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.2.1 [#259]: https://github.com/athena-framework/athena/pull/259 ## [0.2.0] - 2023-01-07 ### Changed - **Breaking:** refactor how listeners are registered to use the new `AEDA::AsEventListener` annotation on the method instead of the `self.subscribed_events` class method ([#236]) (George Dietrich) - **Breaking:** refactor and rename the majority of `AED::EventDispatcherInterface` API ([#236]) (George Dietrich) - **Breaking:** change the representation of a listener when returned from a dispatcher to be an `AED::Callable` instance ([#236]) (George Dietrich) - **Breaking:** refactor `AED::Event` to now be `abstract` ([#236]) (George Dietrich) ### Added - Add `AED::GenericEvent` that can be used for convenience within simple use cases ([#236]) (George Dietrich) - Add the ability to use a listener method without the `AED::EventDispatcherInterface` parameter ([#236]) (George Dietrich) ### Removed - **Breaking:** remove ability for listeners to automatically be registered with the dispatcher ([#236]) (George Dietrich) - **Breaking:** remove the `AED::EventDispatcher.new` constructor that accepts an `Array(AED::EventListenerInterface)` ([#236]) (George Dietrich) - **Breaking:** remove the `AED::EventListenerType` alias ([#236]) (George Dietrich) - **Breaking:** remove the `AED::SubscribedEvents` alias ([#236]) (George Dietrich) - **Breaking:** remove the `AED::EventListener` struct ([#236]) (George Dietrich) - **Breaking:** remove the `AED.create_listener` method ([#236]) (George Dietrich) - Remove the requirement that listeners methods need to be called `call` ([#236]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.2.0 [#236]: https://github.com/athena-framework/athena/pull/236 ## [0.1.4] - 2022-05-14 _First release a part of the monorepo._ ### Added - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Changed - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Fixed - Fix the `VERSION` constant's value ([#166]) (George Dietrich) [0.1.4]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.1.4 [#166]: https://github.com/athena-framework/athena/pull/166 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.1.3] - 2021-01-29 ### Changed - Migrate documentation to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#14]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.1.3 [#14]: https://github.com/athena-framework/event-dispatcher/pull/14 ## [0.1.2] - 2020-12-03 ### Changed - Update `crystal` version to allow version greater than `1.0.0` ([#13]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.1.2 [#13]: https://github.com/athena-framework/event-dispatcher/pull/13 ## [0.1.1] - 2020-11-12 ### Added - Add the [AED::Spec](https://athenaframework.org/EventDispatcher/Spec/) module to provide helpful testing utilities ([#11]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.1.1 [#11]: https://github.com/athena-framework/event-dispatcher/pull/11 ## [0.1.0] - 2020-01-11 _Initial release._ [0.1.0]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.1.0 ================================================ FILE: .changes/event-dispatcher/v0.4.0.md ================================================ ## [0.4.0] - 2025-09-04 ### Changed - **Breaking:** Changed interface of `AED::EventDispatcherInterface#dispatch` to accept an `ACTR::EventDispatcher::Event` vs `AED::Event` ([#544]) (George Dietrich) ### Removed - Removed `AED::StoppableEvent` in favor of `ACTR::EventDispatcher::StoppableEvent` ([#544]) (George Dietrich) [0.4.0]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.4.0 [#544]: https://github.com/athena-framework/athena/pull/544 ================================================ FILE: .changes/event-dispatcher/v0.4.1.md ================================================ ## [0.4.1] - 2026-04-19 ### Changed - Improve compile time error messages ([#646]) (George Dietrich) ### Fixed - Fix compatibility with `ACTR::EventDispatcher::Event` based event types ([#656]) (George Dietrich) [0.4.1]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.4.1 [#646]: https://github.com/athena-framework/athena/pull/646 [#656]: https://github.com/athena-framework/athena/pull/656 ================================================ FILE: .changes/framework/v0.20.1.md ================================================ ## [0.20.1] - 2025-02-08 ### Fixed - Fix `ATH::ViewHandler` bundle configuration values not being correctly set ([#520]) (George Dietrich) [0.20.1]: https://github.com/athena-framework/framework/releases/tag/v0.20.1 [#520]: https://github.com/athena-framework/athena/pull/520 ## [0.20.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) - **Breaking:** The `ATHR::Interface.configuration` macro is no longer scoped to the resolver namespace ([#425]) (George Dietrich) - **Breaking:** Rename `ATHR::RequestBody::Extract` to `ATHA::MapRequestBody` ([#425]) (George Dietrich) - **Breaking:** Rename `ATHR::Time::Format` to `ATHA::MapTime` ([#425]) (George Dietrich) - Update minimum `crystal` version to `~> 1.14.0` ([#433]) (George Dietrich) - Refactor auto redirection logic to be more robust ([#436], [#480]) (George Dietrich) - Refactor `ATHR::RequestBody` to raise more accurate deserialization errors ([#490]) (George Dietrich) ### Added - Add support for [Proxies & Load Balancers](https://athenaframework.org/guides/proxies/) ([#440], [#444]) (George Dietrich) - Add new `trusted_host` bundle scheme property to allow setting trusted hostnames ([#474]) (George Dietrich) - Add support for deserializing `application/x-www-form-urlencoded` bodies via `ATHA::MapRequestBody` ([#477]) (George Dietrich) - Add `ATHA::MapQueryString` to map a request's query string into a DTO type ([#477]) (George Dietrich) - Add `ATH::Exception.from_status` helper method ([#426]) (George Dietrich) - Add `ATHA::MapQueryParameter` for handling query parameters ([#426]) (George Dietrich) - Add `#validation_groups` and `#accept_formats` annotation properties to `ATHA::MapRequestBody` ([#486]) (George Dietrich) - Add `#validation_groups` annotation property to `ATHA::MapQueryString` ([#486]) (George Dietrich) - Add `ATH::Request#port` and `ATH::Response#redirect?` methods ([#436]) (George Dietrich) - Add `#host`, `#scheme`, `#secure?`, and `#from_trusted_proxy?` methods to `ATH::Request` ([#440]) (George Dietrich) - Add `ATH::Request#content_type_format` to return the request format's name from its `content-type` header ([#477]) (George Dietrich) - Add `ATH::IPUtils` module ([#440]) (George Dietrich) - Add `.unquote`, `.split`, and `.combine` methods `ATH::HeaderUtils` ([#440]) (George Dietrich) - Add request matchers for headers and query parameters ([#491]) (George Dietrich) ### Removed - **Breaking:** Remove `ATHA::QueryParam` ([#426]) (George Dietrich) - **Breaking:** Remove `ATHA::RequestParam` ([#426]) (George Dietrich) - **Breaking:** Remove `ATH::Exception::InvalidParameter` ([#426]) (George Dietrich) - **Breaking:** Remove everything within `ATH::Params` namespace ([#426]) (George Dietrich) - **Breaking:** Remove `ATH::Action#params` ([#426]) (George Dietrich) - **Breaking:** Remove `ATH::Listeners::ParamFetcher` ([#426]) (George Dietrich) ### Fixed - Fix query parameters being dropped when redirecting to a trailing/non-trailing slash endpoint ([#436]) (George Dietrich) - Fix auto redirection with non-standard ports ([#480]) (George Dietrich) - Fix `multipart/form-data` not being mapped to the `form` format ([#441]) (George Dietrich) - Fix being unable to provide the path of an `ARTA::Route` annotation on a class as a positional argument ([#482]) (George Dietrich) - Fix error when attempting to use `ATH::Controller#redirect_view` and `ATH::Controller#route_redirect_view` ([#498]) (George Dietrich) - Fix error when attempting to use `ATH::Spec::APITestCase#unlink` ([#498]) (George Dietrich) [0.20.0]: https://github.com/athena-framework/framework/releases/tag/v0.20.0 [#425]: https://github.com/athena-framework/athena/pull/425 [#426]: https://github.com/athena-framework/athena/pull/426 [#428]: https://github.com/athena-framework/athena/pull/428 [#433]: https://github.com/athena-framework/athena/pull/433 [#436]: https://github.com/athena-framework/athena/pull/436 [#440]: https://github.com/athena-framework/athena/pull/440 [#441]: https://github.com/athena-framework/athena/pull/441 [#444]: https://github.com/athena-framework/athena/pull/444 [#474]: https://github.com/athena-framework/athena/pull/474 [#477]: https://github.com/athena-framework/athena/pull/477 [#480]: https://github.com/athena-framework/athena/pull/480 [#482]: https://github.com/athena-framework/athena/pull/482 [#486]: https://github.com/athena-framework/athena/pull/486 [#490]: https://github.com/athena-framework/athena/pull/490 [#491]: https://github.com/athena-framework/athena/pull/491 [#498]: https://github.com/athena-framework/athena/pull/498 ## [0.19.2] - 2024-07-31 ### Added - Add `ATH.run_console` as an easier entrypoint into the console application ([#413]) (George Dietrich) - Add support for additional boolean conversion values from request attributes ([#422]) (George Dietrich) ### Changed - **Breaking:** `ATH::RequestMatcher::Method` now requires an `Array(String)` as opposed to any `Enumerable(String)` ([#431]) (George Dietrich) - Update minimum `crystal` version to `~> 1.13.0` ([#433]) (George Dietrich) - Updates usages of `UTF-8` in response headers to `utf-8` as preferred by the RFC ([#417]) (George Dietrich) ### Fixed - Fix the content negotiation implementation not working ([#431]) (George Dietrich) [0.19.2]: https://github.com/athena-framework/framework/releases/tag/v0.19.2 [#413]: https://github.com/athena-framework/athena/pull/413 [#417]: https://github.com/athena-framework/athena/pull/417 [#422]: https://github.com/athena-framework/athena/pull/422 [#431]: https://github.com/athena-framework/athena/pull/431 [#433]: https://github.com/athena-framework/athena/pull/433 ## [0.19.1] - 2024-04-27 ### Fixed - Fix `framework` component docs landing on an empty page ([#399]) (George Dietrich) - Fix `Athena::Clock` not being aliased to the interface correctly ([#400]) (George Dietrich) - Fix `ATHA::View` annotation being defined in incorrect namespace ([#403]) (George Dietrich) - Fix `ATH::ErrorRenderer` not being aliased to the interface correctly ([#404]) (George Dietrich) [0.19.1]: https://github.com/athena-framework/framework/releases/tag/v0.19.1 [#399]: https://github.com/athena-framework/athena/pull/399 [#400]: https://github.com/athena-framework/athena/pull/400 [#403]: https://github.com/athena-framework/athena/pull/403 [#404]: https://github.com/athena-framework/athena/pull/404 ## [0.19.0] - 2024-04-09 ### Changed - **Breaking:** change how framework features are configured ([#337], [#374], [#383]) (George Dietrich) - Update minimum `crystal` version to `~> 1.11.0` ([#270]) (George Dietrich) - Integrate website into monorepo ([#365]) (George Dietrich) ### Added - Support for Windows OS ([#270]) (George Dietrich) - Add `ATH::RequestMatcher` as a generic way of matching an `ATH::Request` given a set of rules ([#338]) (George Dietrich) - Raise an exception if a controller's return value fails to serialize instead of just returning `nil` ([#357]) (George Dietrich) - Add support for new Crystal 1.12 `Process.on_terminate` method ([#394]) (George Dietrich) ### Fixed - Fix macro splat deprecation ([#330]) (George Dietrich) - Normalize `ATH::Request#method` to always be uppercase ([#338]) (George Dietrich) - Fixed not being able to use top level configuration annotations on controller action parameters ([#356]) (George Dietrich) [0.19.0]: https://github.com/athena-framework/framework/releases/tag/v0.19.0 [#270]: https://github.com/athena-framework/athena/pull/270 [#330]: https://github.com/athena-framework/athena/pull/330 [#337]: https://github.com/athena-framework/athena/pull/337 [#338]: https://github.com/athena-framework/athena/pull/338 [#356]: https://github.com/athena-framework/athena/pull/356 [#357]: https://github.com/athena-framework/athena/pull/357 [#365]: https://github.com/athena-framework/athena/pull/365 [#374]: https://github.com/athena-framework/athena/pull/374 [#383]: https://github.com/athena-framework/athena/pull/383 [#394]: https://github.com/athena-framework/athena/pull/394 ## [0.18.2] - 2023-10-09 ### Changed - Change routing logic to redirect `GET` and `HEAD` requests with a trailing slash to the route without one if it exists, and vice versa ([#307]) (George Dietrich) ### Added - Add native tab completion support to the built-in `ATH::Commands` ([#296]) (George Dietrich) - Add support for defining multiple route annotations on a single controller action method ([#315]) (George Dietrich) - Require the new `Athena::Clock` component ([#318]) (George Dietrich) - Add additional `ATH::Spec::APITestCase` request helper methods ([#312], [#313]) (George Dietrich) ### Fixed - Fix incorrectly generated route paths with a controller level prefix and no action level `/` prefix ([#308]) (George Dietrich) [0.18.2]: https://github.com/athena-framework/framework/releases/tag/v0.18.2 [#296]: https://github.com/athena-framework/athena/pull/296 [#307]: https://github.com/athena-framework/athena/pull/307 [#308]: https://github.com/athena-framework/athena/pull/308 [#312]: https://github.com/athena-framework/athena/pull/312 [#313]: https://github.com/athena-framework/athena/pull/313 [#315]: https://github.com/athena-framework/athena/pull/315 [#318]: https://github.com/athena-framework/athena/pull/318 ## [0.18.1] - 2023-05-29 ### Added - Add support for serializing arbitrarily nested controller action return types ([#273]) (George Dietrich) - Allow using constants for controller action's `path` ([#279]) (George Dietrich) ### Fixed - Fix incorrect `content-length` header value when returning multi-byte strings ([#288]) (George Dietrich) [0.18.1]: https://github.com/athena-framework/framework/releases/tag/v0.18.1 [#273]: https://github.com/athena-framework/athena/pull/273 [#279]: https://github.com/athena-framework/athena/pull/279 [#288]: https://github.com/athena-framework/athena/pull/288 ## [0.18.0] - 2023-02-20 ### Changed - **Breaking:** upgrade [Athena::EventDispatcher](https://athenaframework.org/EventDispatcher/) to [0.2.x](https://github.com/athena-framework/event-dispatcher/blob/master/CHANGELOG.md#020---2023-01-07) ([#205]) (George Dietrich) - **Breaking:** deprecate the `ATH::ParamConverter` concept in favor of [Value Resolvers](https://athenaframework.org/Framework/Controller/ValueResolvers/Interface) ([#243]) (George Dietrich) - **Breaking:** rename various types/methods to better adhere to https://github.com/crystal-lang/crystal/issues/10374 ([#243]) (George Dietrich) - **Breaking:** Change `ATH::Spec::AbstractBrowser` to be a `class` ([#249]) (George Dietrich) - **Breaking:** upgrade [Athena::Validator](https://athenaframework.org/Validator/) to [0.3.x](https://github.com/athena-framework/validator/blob/master/CHANGELOG.md#030---2023-01-07) ([#250]) (George Dietrich) - Improve service `ATH::Controller`s to not need the `public: true` `ADI::Register` field ([#213]) (George Dietrich) - Update minimum `crystal` version to `~> 1.6.0` ([#205]) (George Dietrich) ### Added - Add trace logging to `ATH::Listeners::CORS` to aid in debugging ([#265]) (George Dietrich) - Introduce new `framework.debug` parameter that is `true` if the binary was _not_ built with the `--release` flag ([#249]) (George Dietrich) - Add built-in [HTTP Expectation](https://athenaframework.org/Framework/Spec/Expectations/HTTP) methods to `ATH::Spec::WebTestCase` ([#249]) (George Dietrich) - Add `#response` and `#request` methods to `ATH::Spec::AbstractBrowser` types ([#249]) (George Dietrich) - Add [ATHR](https://athenaframework.org/Framework/aliases/#ATHR) alias to make using value resolver annotations easier ([#243]) (George Dietrich) - Add [ATH::Commands::Commands::DebugEventDispatcher](https://athenaframework.org/Framework/Commands/DebugEventDispatcher) framework CLI command to aid in debugging the event dispatcher ([#241]) (George Dietrich) - Add [ATH::Commands::Commands::DebugRouter](https://athenaframework.org/Framework/Commands/DebugRouter) and [ATH::Commands::Commands::DebugRouterMatch](https://athenaframework.org/Framework/Commands/DebugRouterMatch) framework CLI commands to aid in debugging the router ([#224]) (George Dietrich) - Add integration for the [Athena::Console](https://athenaframework.org/Console/) component ([#218]) (George Dietrich) ### Fixed - Correctly populate `content-length` based on the response content's size ([#267]) (George Dietrich) - Prevent wildcard CORS `expose_headers` value when `allow_credentials` is `true` ([#264]) (George Dietrich) - Correctly handle `JSON::Serializable` values within `Hash`/`NamedTuple` controller action return types ([#253]) (George Dietrich) - Fix [ATH::ParameterBag#get?](https://athenaframework.org/Framework/ParameterBag/#Athena::Framework::ParameterBag#get?(name,_type)) not returning `nil` if it could not convert the value to the desired type ([#243]) (George Dietrich) [0.18.0]: https://github.com/athena-framework/framework/releases/tag/v0.18.0 [#205]: https://github.com/athena-framework/athena/pull/205 [#213]: https://github.com/athena-framework/athena/pull/213 [#218]: https://github.com/athena-framework/athena/pull/218 [#224]: https://github.com/athena-framework/athena/pull/224 [#241]: https://github.com/athena-framework/athena/pull/241 [#243]: https://github.com/athena-framework/athena/pull/243 [#249]: https://github.com/athena-framework/athena/pull/249 [#250]: https://github.com/athena-framework/athena/pull/250 [#253]: https://github.com/athena-framework/athena/pull/253 [#264]: https://github.com/athena-framework/athena/pull/264 [#265]: https://github.com/athena-framework/athena/pull/265 [#267]: https://github.com/athena-framework/athena/pull/267 ## [0.17.1] - 2022-09-05 ### Changed - **Breaking:** ensure parameter names defined on interfaces match the implementation ([#188]) (George Dietrich) [0.17.1]: https://github.com/athena-framework/framework/releases/tag/v0.17.1 [#188]: https://github.com/athena-framework/athena/pull/188 ## [0.17.0] - 2022-05-14 _Checkout [this](https://forum.crystal-lang.org/t/athena-0-17-0/4624) forum thread for an overview of changes within the ecosystem._ ### Added - Add `pcre2` library dependency to `shard.yml` ([#159]) (George Dietrich) - Add [ATH::Arguments::Resolvers::Enum](https://athenaframework.org/Framework/Arguments/Resolvers/Enum/) to allow resolving `Enum` members directly to controller actions ([#173]) (George Dietrich) - Add [ATH::Arguments::Resolvers::UUID](https://athenaframework.org/Framework/Arguments/Resolvers/UUID/) to allow resolving `UUID`s directly to controller actions by ([#176]) (George Dietrich) - Add [ATH::ParameterBag#has(name, type)](https://athenaframework.org/Framework/ParameterBag/#Athena::Framework::ParameterBag#has?(name,type)) that checks if a parameter with the provided name exists, and that is of the provided type ([#176]) (George Dietrich) - Add [ATH::Arguments::Resolvers::DefaultValue](https://athenaframework.org/Framework/Arguments/Resolvers/DefaultValue/) to allow resolving an action parameter's default value if no other value was provided ([#177]) (George Dietrich) ### Changed - **Breaking:** rename `ATH::Arguments::Resolvers::ArgumentValueResolverInterface` to `ATH::Arguments::Resolvers::Interface` ([#176]) (George Dietrich) - **Breaking:** bump `athena-framework/serializer` to `~> 0.3.0` ([#181]) (George Dietrich) - **Breaking:** bump `athena-framework/validator` to `~> 0.2.0` ([#181]) (George Dietrich) - Expose the default value of an [ATH::Arguments::ArgumentMetadata](https://athenaframework.org/Framework/Arguments/ArgumentMetadata/) ([#176]) (George Dietrich) - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Fixed - Fix error when two controller share a common action name ([#146]) (George Dietrich) - Fix release badge to use correct repo ([#161]) (George Dietrich) - Fix query/request param docs to use new error responses ([#167]) (George Dietrich) - Fix incorrect `Athena::Framework` `Log` name ([#175]) (George Dietrich) [0.17.0]: https://github.com/athena-framework/framework/releases/tag/v0.17.0 [#146]: https://github.com/athena-framework/athena/pull/146 [#159]: https://github.com/athena-framework/athena/pull/159 [#161]: https://github.com/athena-framework/athena/pull/161 [#167]: https://github.com/athena-framework/athena/pull/167 [#169]: https://github.com/athena-framework/athena/pull/169 [#173]: https://github.com/athena-framework/athena/pull/173 [#175]: https://github.com/athena-framework/athena/pull/175 [#176]: https://github.com/athena-framework/athena/pull/176 [#177]: https://github.com/athena-framework/athena/pull/177 [#181]: https://github.com/athena-framework/athena/pull/181 ## [0.16.0] - 2022-01-22 _First release in the [athena-framework/framework](https://github.com/athena-framework/framework) repo, post monorepo._ ### Added - Add dependency on `athena-framework/routing` ([#141]) (George Dietrich) - Allow prepending [HTTP::Handlers](https://crystal-lang.org/api/HTTP/Handler.html) to the Athena server ([#133]) (George Dietrich) - Add common HTTP methods (get, post, put, delete) to [ATH::Spec::APITestCase](https://athenaframework.org//Framework/Spec/APITestCase/#Athena::Framework::Spec::APITestCase-methods) ([#134]) (George Dietrich) - Add overload of [ATH::Spec::APITestCase#request](https://athenaframework.org/Framework/Spec/APITestCase/#Athena::Framework::Spec::APITestCase#request(method,path,body,headers)) that accepts an [ATH::Request](https://athenaframework.org/Framework/Request/) or [HTTP::Request](https://crystal-lang.org/api/HTTP/Request.html) ([#134]) (George Dietrich) - Allow running an HTTPS server via passing an [OpenSSL::SSL::Context::Server](https://crystal-lang.org/api/OpenSSL/SSL/Context/Server.html) to `ATH.run` ([#135], [#136]) (George Dietrich) - Add [ATH::ParameterBag#set(hash)](https://athenaframework.org/Framework/ParameterBag/#Athena::Framework::ParameterBag#set(name,value,type)) that allows setting a hash of key/value pairs ([#141]) (George Dietrich) ### Changed - **Breaking:** integrate the [Athena::Routing](https://athenaframework.org/Routing/) component ([#141]) (George Dietrich) ### Removed - **Breaking:** remove dependency on [amberframework/amber-router](https://github.com/amberframework/amber-router) ([#141]) (George Dietrich) [0.16.0]: https://github.com/athena-framework/framework/releases/tag/v0.16.0 [#133]: https://github.com/athena-framework/athena/pull/133 [#134]: https://github.com/athena-framework/athena/pull/134 [#135]: https://github.com/athena-framework/athena/pull/135 [#136]: https://github.com/athena-framework/athena/pull/136 [#141]: https://github.com/athena-framework/athena/pull/141 ## [0.15.1] - 2021-12-13 ### Changed - Include error list in `ATH::Exception::InvalidParameter` ([#124]) (George Dietrich) - Set the base path of parameter errors to the name of the parameter ([#124]) (George Dietrich) [0.15.1]: https://github.com/athena-framework/athena/releases/tag/v0.15.1 [#124]: https://github.com/athena-framework/athena/pull/124 ## [0.15.0] - 2021-10-30 _Last release in the [athena-framework/athena](https://github.com/athena-framework/athena) repo, pre monorepo._ ### Added - Expose the raw [HTTP::Request](https://crystal-lang.org/api/HTTP/Request.html) method from an `ATH::Request` ([#115]) (George Dietrich) - Add built in [ATH::RequestBodyConverter](https://athenaframework.org/Framework/RequestBodyConverter) param converter ([#116]) (George Dietrich) - Add `VERSION` constant to `Athena::Framework` namespace ([#120]) (George Dietrich) ### Changed - **Breaking:** rename base param converter type to `ATH::ParamConverter` and make it a class ([#116]) (George Dietrich) - **Breaking:** rename the component from `Athena::Routing` to `Athena::Framework` ([#120]) (George Dietrich) ### Fixed - Fix incorrect parameter type restriction on `ATH::ParameterBag#set` ([#116]) (George Dietrich) - Fix incorrect ivar type on `AVD::Exception::Exceptions::ValidationFailed#violations` ([#116]) (George Dietrich) - Correctly reject requests with whitespace when converting numeric inputs ([#117]) (George Dietrich) [0.15.0]: https://github.com/athena-framework/athena/releases/tag/v0.15.0 [#115]: https://github.com/athena-framework/athena/pull/115 [#116]: https://github.com/athena-framework/athena/pull/116 [#117]: https://github.com/athena-framework/athena/pull/117 [#120]: https://github.com/athena-framework/athena/pull/120 ================================================ FILE: .changes/framework/v0.21.0.md ================================================ ## [0.21.0] - 2025-09-04 ### Changed - **Breaking:** Leverage `ATH::AbstractFile` within `ATH::BinaryFileResponse` ([#563]) (George Dietrich) - Leverage `mime` component within `ATH::BinaryFileResponse` ([#545]) (George Dietrich) - Setter methods on `ATH::Response` and subclasses now return `self` to better support method chaining ([#563]) (George Dietrich) ### Added - Add support for Athena Contract component types ([#544]) (George Dietrich) - Add native file upload support ([#559]) (George Dietrich) ### Fixed - Correctly apply `emit_nil` value from `ATHA::View` ([#526]) (George Dietrich) [0.21.0]: https://github.com/athena-framework/framework/releases/tag/v0.21.0 [#545]: https://github.com/athena-framework/athena/pull/545 [#563]: https://github.com/athena-framework/athena/pull/563 [#544]: https://github.com/athena-framework/athena/pull/544 [#559]: https://github.com/athena-framework/athena/pull/559 [#526]: https://github.com/athena-framework/athena/pull/526 ================================================ FILE: .changes/framework/v0.21.1.md ================================================ ## [0.21.1] - 2025-10-04 ### Fixed - Fix improper handling of optional file uploads ([#595]) (George Dietrich) [0.21.1]: https://github.com/athena-framework/framework/releases/tag/v0.21.1 [#595]: https://github.com/athena-framework/athena/pull/595 ================================================ FILE: .changes/framework/v0.22.0.md ================================================ ## [0.22.0] - 2026-04-19 ### Changed - **Breaking:** Store `ATH::Action` within `ATH::Request#attributes` instead of within an ivar ([#636]) (George Dietrich) - **Breaking:** Extract out HTTP related `framework` types into the new `http` component ([#640]) (George Dietrich) - **Breaking:** Extract out Request/Response handling related `framework` types into the new `http_kernel` component ([#657]) (George Dietrich) - **Breaking:** Refactor how annotations are fetched off an action/parameter ([#655]) (George Dietrich) ### Fixed - Fix CORS error when using HTTP/2 but providing uppercase header names ([#670]) (George Dietrich) - Fix compile time error when inadvertently using a type name that conflicts with an internal component type ([#678]) (George Dietrich) [0.22.0]: https://github.com/athena-framework/framework/releases/tag/v0.22.0 [#636]: https://github.com/athena-framework/athena/pull/636 [#640]: https://github.com/athena-framework/athena/pull/640 [#657]: https://github.com/athena-framework/athena/pull/657 [#655]: https://github.com/athena-framework/athena/pull/655 [#670]: https://github.com/athena-framework/athena/pull/670 [#678]: https://github.com/athena-framework/athena/pull/678 ================================================ FILE: .changes/header.tpl.md ================================================ # Changelog ================================================ FILE: .changes/http/v0.1.0.md ================================================ ## [0.1.0] - 2026-04-19 _Initial release._ [0.1.0]: https://github.com/athena-framework/http/releases/tag/v0.1.0 ================================================ FILE: .changes/http-kernel/v0.1.0.md ================================================ ## [0.1.0] - 2026-04-19 _Initial release._ [0.1.0]: https://github.com/athena-framework/http-kernel/releases/tag/v0.1.0 ================================================ FILE: .changes/image-size/v0.1.4.md ================================================ ## [0.1.4] - 2025-01-26 _Administrative release, no functional changes_ [0.1.4]: https://github.com/athena-framework/image-size/releases/tag/v0.1.4 ## [0.1.3] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/image-size/releases/tag/v0.1.3 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.1.2] - 2023-10-09 _Administrative release, no functional changes_ [0.1.2]: https://github.com/athena-framework/image-size/releases/tag/v0.1.2 ## [0.1.1] - 2022-05-14 _First release a part of the monorepo._ ### Added - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Changed - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Fixed - Fix incorrect `description` key in `shard.yml` ([#171]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/image-size/releases/tag/v0.1.1 [#169]: https://github.com/athena-framework/athena/pull/169 [#171]: https://github.com/athena-framework/athena/pull/171 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.1.0] - 2022-02-21 _Initial release._ [0.1.0]: https://github.com/athena-framework/image-size/releases/tag/v0.1.0 ================================================ FILE: .changes/mercure/v0.1.0.md ================================================ ## [0.1.0] - 2026-04-19 _Initial release._ [0.1.0]: https://github.com/athena-framework/mercure/releases/tag/v0.1.0 ================================================ FILE: .changes/mercure-bundle/v0.1.0.md ================================================ ## [0.1.0] - 2026-04-19 _Initial release._ [0.1.0]: https://github.com/athena-framework/mercure-bundle/releases/tag/v0.1.0 ================================================ FILE: .changes/mime/v0.2.0.md ================================================ ## [0.2.0] - 2025-05-14 ### Added - **Breaking:** Add `AMIME::Types` to more robustly handles MIME type/file extension/guessing ([#534]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/mime/releases/tag/v0.2.0 [#534]: https://github.com/athena-framework/athena/pull/534 ## [0.1.0] - 2025-01-26 _Initial release._ [0.1.0]: https://github.com/athena-framework/mime/releases/tag/v0.1.0 ================================================ FILE: .changes/mime/v0.2.1.md ================================================ ## [0.2.1] - 2025-09-04 ### Added - Add fallback MIME types guesser based on stdlib `MIME` module ([#546]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/mime/releases/tag/v0.2.1 [#546]: https://github.com/athena-framework/athena/pull/546 ================================================ FILE: .changes/negotiation/v0.2.0.md ================================================ ## [0.2.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) - Use lowercase `utf-8` within header values ([#417]) (George Dietrich) - Update minimum `crystal` version to `~> 1.13.0` ([#428]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/negotiation/releases/tag/v0.2.0 [#417]: https://github.com/athena-framework/athena/pull/417 [#428]: https://github.com/athena-framework/athena/pull/428 ## [0.1.5] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) [0.1.5]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.5 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.1.4] - 2023-10-09 _Administrative release, no functional changes_ [0.1.4]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.4 ## [0.1.3] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.3 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.1.2] - 2022-05-14 _First release a part of the monorepo._ ### Added - Add `VERSION` constant to `Athena::Negotiation` namespace ([#166]) (George Dietrich) - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Changed - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Fixed - Correct the shard version in `README.md` ([#6]) (syeopite) [0.1.2]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.2 [#6]: https://github.com/athena-framework/negotiation/pull/6 [#166]: https://github.com/athena-framework/athena/pull/166 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.1.1] - 2021-02-04 ### Changed - Migrate documentation to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#4]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.1 [#4]: https://github.com/athena-framework/negotiation/pull/4 ## [0.1.0] - 2020-12-24 _Initial release._ [0.1.0]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.0 ================================================ FILE: .changes/routing/v0.1.10.md ================================================ ## [0.1.10] - 2025-01-26 ### Changed - Allow having multiple independent compiled route collections ([#468]) (George Dietrich) - Log unhandled `ART::RoutingHandler` exceptions ([#470]) (George Dietrich) ### Fixed - Make `ART::RequestContext.from_uri` more robust ([#498]) (George Dietrich) [0.1.10]: https://github.com/athena-framework/routing/releases/tag/v0.1.10 [#468]: https://github.com/athena-framework/athena/pull/468 [#470]: https://github.com/athena-framework/athena/pull/470 [#498]: https://github.com/athena-framework/athena/pull/498 ## [0.1.9] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) ### Added - **Breaking:** add kwargs overload to `ART::Generator::Interface#generate` ([#375]) (George Dietrich) ### Fixed - Fix compatibility with PCRE2 10.43 ([#362]) (George Dietrich) - Fix error when PCRE2 JIT mode is unavailable ([#381]) (George Dietrich) [0.1.9]: https://github.com/athena-framework/routing/releases/tag/v0.1.9 [#362]: https://github.com/athena-framework/athena/pull/362 [#365]: https://github.com/athena-framework/athena/pull/365 [#375]: https://github.com/athena-framework/athena/pull/375 [#381]: https://github.com/athena-framework/athena/pull/381 ## [0.1.8] - 2023-10-09 ### Added - Internal support for redirecting within an `ART::Matcher::*` ([#307]) (George Dietrich) [0.1.8]: https://github.com/athena-framework/routing/releases/tag/v0.1.8 [#307]: https://github.com/athena-framework/athena/pull/307 ## [0.1.7] - 2023-05-29 ### Changed - **Breaking:** Update minimum `crystal` version to `~> 1.8.0`. Drop support for `PCRE1`. ([#281]) (George Dietrich) [0.1.7]: https://github.com/athena-framework/routing/releases/tag/v0.1.7 [#281]: https://github.com/athena-framework/athena/pull/281 ## [0.1.6] - 2023-03-26 ### Fixed - Fix compatibility with Crystal `1.8.0-dev` ([#272]) (George Dietrich) [0.1.6]: https://github.com/athena-framework/routing/releases/tag/v0.1.6 [#272]: https://github.com/athena-framework/athena/pull/272 ## [0.1.5] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) ### Added - Add additional `ART::Requirement` constants ([#257]) (George Dietrich) ### Fixed - Fix formatting issue in Crystal `1.8-dev` ([#258]) (George Dietrich) [0.1.5]: https://github.com/athena-framework/routing/releases/tag/v0.1.5 [#257]: https://github.com/athena-framework/athena/pull/257 [#258]: https://github.com/athena-framework/athena/pull/258 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.1.4] - 2023-01-07 ### Changed - Change route compilation to be eager ([#207]) (George Dietrich) ### Added - Add ability to bubble up exceptions from `ART::RoutingHandler` ([#206]) (George Dietrich) - Add `ART::Matcher::TraceableURLMatcher` to help with debugging route matches ([#224]) (George Dietrich) - Add `ART::Route#has_scheme?` ([#224]) (George Dietrich) [0.1.4]: https://github.com/athena-framework/routing/releases/tag/v0.1.4 [#207]: https://github.com/athena-framework/athena/pull/207 [#206]: https://github.com/athena-framework/athena/pull/206 [#224]: https://github.com/athena-framework/athena/pull/224 ## [0.1.3] - 2022-09-05 ### Changed - **Breaking:** ensure parameter names defined on interfaces match the implementation ([#188]) (George Dietrich) ### Added - Add an `HTTP::Handler` to add basic routing support to a `HTTP::Server` ([#189]) (George Dietrich) ### Fixed - Fixed slash characters being double escaped in generated URL query params ([#180]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/routing/releases/tag/v0.1.3 [#180]: https://github.com/athena-framework/athena/pull/180 [#188]: https://github.com/athena-framework/athena/pull/188 [#189]: https://github.com/athena-framework/athena/pull/189 ## [0.1.2] - 2022-05-14 ### Changed - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Added - Add getting started documentation to API docs ([#172]) (George Dietrich) - Add common route requirement constants to the [ART::Requirement](https://athenaframework.org/Routing/Requirement/) namespace ([#173]) (George Dietrich) - Add [ART::Requirement::Enum](https://athenaframework.org/Routing/Requirement/Enum/) to make creating [Enum](https://crystal-lang.org/api/Enum.html) based route requirements easier ([#173]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/routing/releases/tag/v0.1.2 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 [#173]: https://github.com/athena-framework/athena/pull/173 ## [0.1.1] - 2022-02-05 _First release a part of the monorepo._ ### Fixed - Fix erroneous mutating of matched route data ([#144]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/routing/releases/tag/v0.1.1 [#144]: https://github.com/athena-framework/athena/pull/144 ## [0.1.0] - 2022-01-10 _Initial release._ [0.1.0]: https://github.com/athena-framework/routing/releases/tag/v0.1.0 ================================================ FILE: .changes/routing/v0.1.11.md ================================================ ## [0.1.11] - 2025-09-04 ### Fixed - Fix linker warning due to duplicate `pcre2-8` linkage ([#560]) (George Dietrich) [0.1.11]: https://github.com/athena-framework/routing/releases/tag/v0.1.11 [#560]: https://github.com/athena-framework/athena/pull/560 ================================================ FILE: .changes/routing/v0.1.12.md ================================================ ## [0.1.12] - 2025-11-01 ### Fixed - Fix Crystal `1.19` incompatibility ([#600]) (George Dietrich) [0.1.12]: https://github.com/athena-framework/routing/releases/tag/v0.1.12 [#600]: https://github.com/athena-framework/athena/pull/600 ================================================ FILE: .changes/routing/v0.2.0.md ================================================ ## [0.2.0] - 2026-04-19 ### Changed - **Breaking:** Change `ART::Route#defaults` and matched route parameters return type to `ART::Parameters` ([#652]) (George Dietrich) - **Breaking:** Loosen the type restriction for the `params` parameter of `ART::Generator::Interface#generate` ([#669]) (George Dietrich) ### Added - Allow query-specific parameters within `ART::Generator::URLGenerator` via special `_query` parameter ([#669]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/routing/releases/tag/v0.2.0 [#652]: https://github.com/athena-framework/athena/pull/652 [#669]: https://github.com/athena-framework/athena/pull/669 ================================================ FILE: .changes/serializer/v0.4.1.md ================================================ ## [0.4.1] - 2025-02-08 ### Fixed - Fix serialization of value when its type is different type than the ivar ([#514]) (George Dietrich) [0.4.1]: https://github.com/athena-framework/serializer/releases/tag/v0.4.1 [#514]: https://github.com/athena-framework/athena/pull/514 ## [0.4.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) - Update minimum `crystal` version to `~> 1.13.0` ([#428]) (George Dietrich) [0.4.0]: https://github.com/athena-framework/serializer/releases/tag/v0.4.0 [#428]: https://github.com/athena-framework/athena/pull/428 ## [0.3.6] - 2024-04-27 ### Fixed - Fix misnamed modules being defined in incorrect namespace ([#402]) (George Dietrich) [0.3.6]: https://github.com/athena-framework/serializer/releases/tag/v0.3.6 [#402]: https://github.com/athena-framework/athena/pull/402 ## [0.3.5] - 2024-04-09 ### Changed - Change `Config` dependency to `DependencyInjection` for the custom annotation feature ([#392]) (George Dietrich) - Integrate website into monorepo ([#365]) (George Dietrich) [0.3.5]: https://github.com/athena-framework/serializer/releases/tag/v0.3.5 [#392]: https://github.com/athena-framework/athena/pull/392 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.3.4] - 2023-10-09 _Administrative release, no functional changes_ [0.3.4]: https://github.com/athena-framework/serializer/releases/tag/v0.3.4 ## [0.3.3] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) [0.3.3]: https://github.com/athena-framework/serializer/releases/tag/v0.3.3 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.3.2] - 2023-01-07 ### Fixed - Fix deserializing `JSON::Any` and `YAML::Any` ([#215]) (George Dietrich) [0.3.2]: https://github.com/athena-framework/serializer/releases/tag/v0.3.2 [#215]: https://github.com/athena-framework/athena/pull/215 ## [0.3.1] - 2022-09-05 ### Changed - **Breaking:** ensure parameter names defined on interfaces match the implementation ([#188]) (George Dietrich) [0.3.1]: https://github.com/athena-framework/serializer/releases/tag/v0.3.1 [#188]: https://github.com/athena-framework/athena/pull/188 ## [0.3.0] - 2022-05-14 _First release a part of the monorepo._ ### Added - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Changed - **Breaking:** change serialization of [Enums](https://crystal-lang.org/api/Enum.html) to underscored strings by default ([#173]) (George Dietrich) - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Fixed - Fix compiler error when trying to deserialize a `Hash` ([#165]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/serializer/releases/tag/v0.3.0 [#165]: https://github.com/athena-framework/athena/pull/165 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 [#173]: https://github.com/athena-framework/athena/pull/173 ## [0.2.10] - 2021-11-12 ### Fixed - Fix issue with empty YAML input ([#22]) (George Dietrich) [0.2.10]: https://github.com/athena-framework/serializer/releases/tag/v0.2.10 [#22]: https://github.com/athena-framework/serializer/pull/22 ## [0.2.9] - 2021-10-30 ### Added - Add `VERSION` constant to `Athena::Serializer` namespace ([#20]) (George Dietrich) ### Fixed - Fix broken type link ([#19]) (George Dietrich) [0.2.9]: https://github.com/athena-framework/serializer/releases/tag/v0.2.9 [#19]: https://github.com/athena-framework/serializer/pull/19 [#20]: https://github.com/athena-framework/serializer/pull/20 ## [0.2.8] - 2021-05-17 ### Fixed - Fixes incorrect `nil` check in macro logic ([#17]) (George Dietrich) [0.2.8]: https://github.com/athena-framework/serializer/releases/tag/v0.2.8 [#17]: https://github.com/athena-framework/serializer/pull/17 ## [0.2.7] - 2021-04-09 ### Added - Add some more specialized exception types ([#16]) (George Dietrich) [0.2.7]: https://github.com/athena-framework/serializer/releases/tag/v0.2.7 [#16]: https://github.com/athena-framework/serializer/pull/16 ## [0.2.6] - 2021-03-16 ### Added - Expose a setter for `ASR::Context#version=` ([#15]) (George Dietrich) ### Changed - Change `athena-framework/config` version constraint to `>= 2.0.0` ([#15]) (George Dietrich) [0.2.6]: https://github.com/athena-framework/serializer/releases/tag/v0.2.6 [#15]: https://github.com/athena-framework/serializer/pull/15 ## [0.2.5] - 2021-01-29 ### Changed - Migrate documentation to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#14]) (George Dietrich) [0.2.5]: https://github.com/athena-framework/serializer/releases/tag/v0.2.5 [#14]: https://github.com/athena-framework/serializer/pull/14 ## [0.2.4] - 2021-01-29 ### Changed - Bump min `athena-framework/config` version to `~> 2.0.0` ([#13]) (George Dietrich) [0.2.4]: https://github.com/athena-framework/serializer/releases/tag/v0.2.4 [#13]: https://github.com/athena-framework/serializer/pull/13 ## [0.2.3] - 2021-01-20 ### Fixed - Fix since/until and group annotations not working for virtual properties ([#12]) (George Dietrich) [0.2.3]: https://github.com/athena-framework/serializer/releases/tag/v0.2.3 [#12]: https://github.com/athena-framework/serializer/pull/12 ## [0.2.2] - 2020-12-03 ### Changed - Update `crystal` version to allow version greater than `1.0.0` ([#11]) (George Dietrich) [0.2.2]: https://github.com/athena-framework/serializer/releases/tag/v0.2.2 [#11]: https://github.com/athena-framework/serializer/pull/11 ## [0.2.1] - 2020-11-08 ### Added - Add deserialization support to `ASRA::Name` ([#9]) (Joakim Repomaa) [0.2.1]: https://github.com/athena-framework/serializer/releases/tag/v0.2.1 [#9]: https://github.com/athena-framework/serializer/pull/9 ## [0.2.0] - 2020-07-08 ### Added - Add dependency on `athena-framework/config` ([#8]) (George Dietrich) - Add ability to use custom annotations within [exclusion strategies](https://athenaframework.org/Serializer/ExclusionStrategies/ExclusionStrategyInterface/#Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface--annotation-configurations) ([#8]) (George Dietrich) - Add [ASR::Context#direction](https://athenaframework.org/Serializer/Context/#Athena::Serializer::Context#direction) to represent which direction the context object represents ([#8]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/serializer/releases/tag/v0.2.0 [#8]: https://github.com/athena-framework/serializer/pull/8 ## [0.1.3] - 2020-07-08 ### Fixed - Fix overflow error when deserializing `Int64` values ([#7]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/serializer/releases/tag/v0.1.3 [#7]: https://github.com/athena-framework/serializer/pull/7 ## [0.1.2] - 2020-07-05 ### Added - Add improved documentation to various types ([#6]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/serializer/releases/tag/v0.1.2 [#6]: https://github.com/athena-framework/serializer/pull/6 ## [0.1.1] - 2020-06-27 ### Added - Add [naming strategies](https://athenaframework.org/Serializer/Annotations/Name/#Athena::Serializer::Annotations::Name--naming-strategies) to `ASRA::Name` ([#5]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/serializer/releases/tag/v0.1.1 [#5]: https://github.com/athena-framework/serializer/pull/5 ## [0.1.0] - 2020-06-23 _Initial release._ [0.1.0]: https://github.com/athena-framework/serializer/releases/tag/v0.1.0 ================================================ FILE: .changes/serializer/v0.4.2.md ================================================ ## [0.4.2] - 2025-08-12 ### Fixed - Fix nightly type incompatibility with `ASR::Any` ([#562]) (George Dietrich) [0.4.2]: https://github.com/athena-framework/serializer/releases/tag/v0.4.2 [#562]: https://github.com/athena-framework/athena/pull/562 ================================================ FILE: .changes/serializer/v0.4.3.md ================================================ ## [0.4.3] - 2026-04-19 ### Changed - Improve compile time error messages ([#646]) (George Dietrich) ### Removed - Remove `ASR::PropertyMetadata#class` method and generic variable ([#672]) (George Dietrich) [0.4.3]: https://github.com/athena-framework/serializer/releases/tag/v0.4.3 [#646]: https://github.com/athena-framework/athena/pull/646 [#672]: https://github.com/athena-framework/athena/pull/672 ================================================ FILE: .changes/spec/v0.3.11.md ================================================ ## [0.3.11] - 2025-05-19 ### Fixed - Fix duplicate test case runs with abstract generic parent test case ([#538]) (George Dietrich) [0.3.11]: https://github.com/athena-framework/spec/releases/tag/v0.3.11 [#538]: https://github.com/athena-framework/athena/pull/538 ## [0.3.10] - 2025-02-08 ### Changed - **Breaking:** prevent defining `ASPEC::TestCase#initialize` methods that accepts arguments/blocks ([#516]) (George Dietrich) [0.3.10]: https://github.com/athena-framework/spec/releases/tag/v0.3.10 [#516]: https://github.com/athena-framework/athena/pull/516 ## [0.3.9] - 2025-01-26 _Administrative release, no functional changes_ [0.3.9]: https://github.com/athena-framework/spec/releases/tag/v0.3.9 ## [0.3.8] - 2024-07-31 ### Added - Add support for using the `CRYSTAL` ENV var to customize binary used for `ASPEC::Methods.assert_error` and `ASPEC::Methods.assert_success` ([#424]) (George Dietrich) [0.3.8]: https://github.com/athena-framework/spec/releases/tag/v0.3.8 [#424]: https://github.com/athena-framework/athena/pull/424 ## [0.3.7] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) [0.3.7]: https://github.com/athena-framework/spec/releases/tag/v0.3.7 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.3.6] - 2023-10-09 _Administrative release, no functional changes_ [0.3.6]: https://github.com/athena-framework/spec/releases/tag/v0.3.6 ## [0.3.5] - 2023-04-26 ### Fixed - Ensure `#before_all` runs exactly once, and before `#initialize` ([#285]) (George Dietrich) [0.3.5]: https://github.com/athena-framework/spec/releases/tag/v0.3.5 [#285]: https://github.com/athena-framework/athena/pull/285 ## [0.3.4] - 2023-03-19 ### Fixed - Fix exceptions not being counted as errors when raised within the `initialize` method of a test case ([#276]) (George Dietrich) - Fix a documentation typo in the `TestWith` example ([#269]) (George Dietrich) [0.3.4]: https://github.com/athena-framework/spec/releases/tag/v0.3.4 [#269]: https://github.com/athena-framework/athena/pull/269 [#276]: https://github.com/athena-framework/athena/pull/276 ## [0.3.3] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) [0.3.3]: https://github.com/athena-framework/spec/releases/tag/v0.3.3 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.3.2] - 2023-01-16 ### Added - Add `ASPEC::TestCase::TestWith` that works similar to the `ASPEC::TestCase::DataProvider` but without needing to create a dedicated method ([#254]) (George Dietrich) [0.3.2]: https://github.com/athena-framework/spec/releases/tag/v0.3.2 [#254]: https://github.com/athena-framework/athena/pull/254 ## [0.3.1] - 2023-01-07 ### Changed - Update the docs to clarify the component needs to be manually installed ([#247]) (George Dietrich) ### Added - Add support for *codegen* for the `ASPEC.assert_error` and `ASPEC.assert_success` methods ([#219]) (George Dietrich) - Add ability to skip running all examples within a test case via the `ASPEC::TestCase::Skip` annotation ([#248]) (George Dietrich) [0.3.1]: https://github.com/athena-framework/spec/releases/tag/v0.3.1 [#219]: https://github.com/athena-framework/athena/pull/219 [#247]: https://github.com/athena-framework/athena/pull/247 [#248]: https://github.com/athena-framework/athena/pull/248 ## [0.3.0] - 2022-05-14 _First release a part of the monorepo._ ### Changed - **Breaking:** change the `assert_error` to no longer be file based. Code should now be provided as a HEREDOC argument to the method ([#173]) (George Dietrich) - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Added - Add `VERSION` constant to `Athena::Spec` namespace ([#166]) (George Dietrich) - Add getting started documentation to API docs ([#172]) (George Dietrich) - Add [ASPEC::Methods.assert_success](https://athenaframework.org/Spec/Methods/#Athena::Spec::Methods#assert_success(code,*,line,file)) ([#173]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/spec/releases/tag/v0.3.0 [#166]: https://github.com/athena-framework/athena/pull/166 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 [#173]: https://github.com/athena-framework/athena/pull/173 ## [0.2.6] - 2021-11-03 ### Fixed - Fix `test` helper macro generating invalid method names by replacing all non alphanumeric chars with `_` ([#12]) (George Dietrich) [0.2.6]: https://github.com/athena-framework/spec/releases/tag/v0.2.6 [#12]: https://github.com/athena-framework/spec/pull/12 ## [0.2.5] - 2021-11-03 ### Fixed - Fix `test` helper macro not actually calling `yield` ([#11]) (George Dietrich) [0.2.5]: https://github.com/athena-framework/spec/releases/tag/v0.2.5 [#11]: https://github.com/athena-framework/spec/pull/11 ## [0.2.4] - 2021-01-29 ### Changed - Finish migration to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#9]) (George Dietrich) [0.2.4]: https://github.com/athena-framework/spec/releases/tag/v0.2.4 [#9]: https://github.com/athena-framework/spec/pull/9 ## [0.2.3] - 2020-12-03 ### Changed - Update `crystal` version to allow version greater than `1.0.0` ([#7]) (George Dietrich) [0.2.3]: https://github.com/athena-framework/spec/releases/tag/v0.2.3 [#7]: https://github.com/athena-framework/spec/pull/7 ## [0.2.2] - 2020-10-02 ### Added - Add support for data providers defined in parent types ([#6]) (George Dietrich) [0.2.2]: https://github.com/athena-framework/spec/releases/tag/v0.2.2 [#6]: https://github.com/athena-framework/spec/pull/6 ## [0.2.1] - 2020-09-25 ### Changed - Changed data provider generated `it` blocks have proper file names and line numbers ([#4]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/spec/releases/tag/v0.2.1 [#4]: https://github.com/athena-framework/spec/pull/4 ## [0.2.0] - 2020-08-08 ### Changed - **Breaking:** require [data providers](https://athenaframework.org/Spec/TestCase/DataProvider/) methods to declare a return type of `Hash`, `NamedTuple`, `Tuple`, or `Array` ([#3]) (George Dietrich) - Changed data provider generated `it` blocks to include the key/index ([#2]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/spec/releases/tag/v0.2.0 [#2]: https://github.com/athena-framework/spec/pull/2 [#3]: https://github.com/athena-framework/spec/pull/3 ## [0.1.0] - 2020-08-06 _Initial release._ [0.1.0]: https://github.com/athena-framework/spec/releases/tag/v0.1.0 ================================================ FILE: .changes/spec/v0.4.0.md ================================================ ## [0.4.0] - 2025-09-04 ### Added - Add support for generating macro code coverage reports for `.assert_error` and `.assert_compiles` methods ([#551]) (George Dietrich) ### Removed - Remove `codegen` parameter from `ASPEC::Methods.assert_error` and `ASPEC::Methods.assert_success` ([#551]) (George Dietrich) - Remove `ASPEC::Methods.assert_error` in favor of `ASPEC::Methods.assert_compile_time_error` and `ASPEC::Methods.assert_runtime_error` ([#551]) (George Dietrich) - Remove `ASPEC::Methods.assert_success` in favor of `ASPEC::Methods.assert_compiles` and `ASPEC::Methods.assert_executes` ([#551]) (George Dietrich) [0.4.0]: https://github.com/athena-framework/spec/releases/tag/v0.4.0 [#551]: https://github.com/athena-framework/athena/pull/551 ================================================ FILE: .changes/spec/v0.4.1.md ================================================ ## [0.4.1] - 2025-11-12 ### Fixed - Fix segfault when interacting with a test case ivar object's ivar that was left uninitialized due to an exception in its initializer, within the `tear_down` method ([#613]) (George Dietrich) [0.4.1]: https://github.com/athena-framework/spec/releases/tag/v0.4.1 [#613]: https://github.com/athena-framework/athena/pull/613 ================================================ FILE: .changes/spec/v0.4.2.md ================================================ ## [0.4.2] - 2026-04-19 ### Added - Generate macro code coverage report for `ASPEC::Methods.assert_compiles` ([#642]) (George Dietrich) - Add `ASPEC.compile_time_assert` helper function for use with `assert_compiles` ([#686]) (George Dietrich) - Add ability to add code before/after the actual code of `ASPEC::Methods.assert_compiles` and `ASPEC::Methods.assert_compile_time_error` ([#687]) (George Dietrich) ### Fixed - Fix compile time error when inadvertently using a type name that conflicts with an internal component type ([#678]) (George Dietrich) - Fix incorrect macro code coverage line numbers ([#686]) (George Dietrich) - Fix macro code coverage output file writing on windows ([#696]) (George Dietrich) [0.4.2]: https://github.com/athena-framework/spec/releases/tag/v0.4.2 [#642]: https://github.com/athena-framework/athena/pull/642 [#686]: https://github.com/athena-framework/athena/pull/686 [#687]: https://github.com/athena-framework/athena/pull/687 [#678]: https://github.com/athena-framework/athena/pull/678 [#696]: https://github.com/athena-framework/athena/pull/696 ================================================ FILE: .changes/unreleased/.gitkeep ================================================ ================================================ FILE: .changes/unreleased/event-dispatcher-Changed-20260502-225424.yaml ================================================ project: event-dispatcher kind: Changed body: Event listeners with a generic type parameter now match all subclasses and module includers of the parameter's type time: 2026-05-02T22:54:24.690293195-04:00 custom: Author: George Dietrich Breaking: "No" PR: "703" Username: blacksmoke16 ================================================ FILE: .changes/validator/v0.4.0.md ================================================ ## [0.4.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) ### Added - **Breaking:** Add and make `require_tld: true` the default for `AVD::Constraints::URL` ([#492]) (George Dietrich) - Add example usages to `AVD::Constraints::*` docs ([#483], [#493]) (Zohir Tamda, George Dietrich) [0.4.0]: https://github.com/athena-framework/validator/releases/tag/v0.4.0 [#428]: https://github.com/athena-framework/athena/pull/428 [#483]: https://github.com/athena-framework/athena/pull/483 [#492]: https://github.com/athena-framework/athena/pull/492 [#493]: https://github.com/athena-framework/athena/pull/493 ## [0.3.4] - 2024-07-31 ### Changed - Update minimum `crystal` version to `~> 1.13.0` ([#433]) (George Dietrich) [0.3.4]: https://github.com/athena-framework/validator/releases/tag/v0.3.4 [#433]: https://github.com/athena-framework/athena/pull/433 ## [0.3.3] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) [0.3.3]: https://github.com/athena-framework/validator/releases/tag/v0.3.3 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.3.2] - 2023-10-09 ### Fixed - Fix compiler error when using a composite constraint with a single member and no `of AVD::Constraint` ([#292]) (George Dietrich) [0.3.2]: https://github.com/athena-framework/validator/releases/tag/v0.3.2 [#292]: https://github.com/athena-framework/athena/pull/292 ## [0.3.1] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) ### Fixed - Fix issue when using `AVD::Metadata::GetterMetadata` with methods that have parameters ([#252]) (George Dietrich) [0.3.1]: https://github.com/athena-framework/validator/releases/tag/v0.3.1 [#252]: https://github.com/athena-framework/athena/pull/252 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.3.0] - 2023-01-07 ### Changed - **Breaking:** update default `AVD::Constraints::Email::Mode` to be `:html5` ([#230]) (George Dietrich) - Refactor `AVD::Constraints::IP` to use new dedicated `Socket::IPAddress` methods ([#205]) (George Dietrich) - Update minimum `crystal` version to `~> 1.6` ([#205]) (George Dietrich) ### Added - Add `AVD::Constraints::Collection` ([#229]) (George Dietrich) - Add `AVD::Constraints::Existence`, `AVD::Constraints::Required`, and `AVD::Constraints::Optional` for use with the collection constraint ([#229]) (George Dietrich) - Add `AVD::Spec::ConstraintValidatorTestCase#expect_validate_value_at` to more easily handle validation of nested constraints ([#229]) (George Dietrich) - Add `AVD::Constraints::Email::Mode::HTML5_ALLOW_NO_TLD` that allows matching `HTML` input field validation exactly ([#231]) (George Dietrich) ### Removed - **Breaking:** remove `AVD::Constraints::Email::Mode::Loose` ([#230]) (George Dietrich) ### Fixed - **Breaking:** fix spelling of `AVD::Constraints::ISSN#require_hyphen` parameter ([#222]) (George Dietrich) - Fix property path display issue with `Enumerable` objects ([#229]) (George Dietrich) - Fix `AVD::Constraints::Valid` constraints incorrectly being allowed within `AVD::Constraints::Composite` ([#229]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/validator/releases/tag/v0.3.0 [#205]: https://github.com/athena-framework/athena/pull/205 [#222]: https://github.com/athena-framework/athena/pull/222 [#229]: https://github.com/athena-framework/athena/pull/229 [#230]: https://github.com/athena-framework/athena/pull/230 [#231]: https://github.com/athena-framework/athena/pull/231 ## [0.2.1] - 2022-09-05 ### Added - Add support for exclusive end support to `AVD::Constraints::Range` ([#184]) (George Dietrich) ### Changed - Include allowed MIME types within `AVD::Constraints::Image` if they were customized ([#183]) (George Dietrich) - **Breaking:** ensure parameter names defined on interfaces match the implementation ([#188]) (George Dietrich) ### Fixed - Fix some file size factorization edge cases in `AVD::Constraints::File` ([#182]) (George Dietrich) - Fix duplicating constraints due to Crystal generics bug ([#192]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/validator/releases/tag/v0.2.1 [#182]: https://github.com/athena-framework/athena/pull/182 [#183]: https://github.com/athena-framework/athena/pull/183 [#184]: https://github.com/athena-framework/athena/pull/184 [#188]: https://github.com/athena-framework/athena/pull/188 [#192]: https://github.com/athena-framework/athena/pull/192 ## [0.2.0] - 2022-05-14 ### Added - Add the [AVD::Constraints::File](https://athenaframework.org/Validator/Constraints/File/) constraint ([#153]) (George Dietrich) - Allow `AVD::Spec::MockValidator` to dynamically configure returned violations ([#155], [#157]) (George Dietrich) - Add the [AVD::Constraints::Image](https://athenaframework.org/Validator/Constraints/Image/) constraint ([#153]) (George Dietrich) - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Changed - **Breaking:** make `AVD::ConstraintValidator` classes ([#154]) (George Dietrich) - **Breaking:** `AVD::ExecutionContext` is no longer a generic type ([#156]) (George Dietrich) - Update `assert_violation` to use a clearer failure message if no violations were found ([#153]) (George Dietrich) - Update `AVD::Constraints::ISIN` to use the validator off the context versus an ivar ([#155]) (George Dietrich) - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Removed - **Breaking:** removed `AVD::Spec::MockValidator#violations=` ([#155]) (George Dietrich) ### Fixed - Fix `AVD::Violation::ConstraintViolation` not comparing correctly ([#153]) (George Dietrich) - Ensure only `Indexable` types can be used with `AVD::Constraints::Unique` ([#168]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/validator/releases/tag/v0.2.0 [#153]: https://github.com/athena-framework/athena/pull/153 [#154]: https://github.com/athena-framework/athena/pull/154 [#155]: https://github.com/athena-framework/athena/pull/155 [#156]: https://github.com/athena-framework/athena/pull/156 [#157]: https://github.com/athena-framework/athena/pull/157 [#168]: https://github.com/athena-framework/athena/pull/168 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.1.7] - 2021-12-27 _First release a part of the monorepo._ ### Fixed - Fix callback constraint methods being incorrectly added as getters ([#132]) (George Dietrich) [0.1.7]: https://github.com/athena-framework/validator/releases/tag/v0.1.7 [#132]: https://github.com/athena-framework/athena/pull/132 ## [0.1.6] - 2021-12-13 ### Fixed - Fix `AVD::Validatable` not working when included into parent types ([#16]) (George Dietrich) [0.1.6]: https://github.com/athena-framework/validator/releases/tag/v0.1.6 [#16]: https://github.com/athena-framework/validator/pull/16 ## [0.1.5] - 2021-10-30 ### Added - Add `VERSION` constant to `Athena::Validator` namespace ([#12]) (George Dietrich) ### Fixed - Fix incorrect type restriction on validator factory ([#12]) (George Dietrich) - Fix incorrect link within the docs ([#14]) (George Dietrich) [0.1.5]: https://github.com/athena-framework/validator/releases/tag/v0.1.5 [#12]: https://github.com/athena-framework/validator/pull/12 [#14]: https://github.com/athena-framework/validator/pull/14 ## [0.1.4] - 2021-01-30 ### Changed - Finish migration to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#10], [#11]) (George Dietrich) [0.1.4]: https://github.com/athena-framework/validator/releases/tag/v0.1.4 [#10]: https://github.com/athena-framework/validator/pull/10 [#11]: https://github.com/athena-framework/validator/pull/11 ## [0.1.3] - 2020-12-07 ### Changed - Update `crystal` version to allow version greater than `1.0.0` ([#9]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/validator/releases/tag/v0.1.3 [#9]: https://github.com/athena-framework/validator/pull/9 ## [0.1.2] - 2020-11-25 ### Added - Add the [AVD::Constraints::Choice](https://athenaframework.org/Validator/Constraints/Choice/) constraint ([#7]) (George Dietrich) ### Changed - Allow setting violations directly on mock validators ([#7]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/validator/releases/tag/v0.1.2 [#7]: https://github.com/athena-framework/validator/pull/7 ## [0.1.1] - 2020-11-08 ### Fixed - Fix compiler error due to less strict `abstract def` implementations ([#6]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/validator/releases/tag/v0.1.1 [#6]: https://github.com/athena-framework/validator/pull/6 ## [0.1.0] - 2020-10-17 _Initial release._ [0.1.0]: https://github.com/athena-framework/validator/releases/tag/v0.1.0 ================================================ FILE: .changes/validator/v0.4.1.md ================================================ ## [0.4.1] - 2025-09-04 ### Changed - Leverage `mime` component for more robust `AVD::Constraints::File` MIME type validation ([#545]) (George Dietrich) ### Added - Add `AVD::Spec::CompoundConstraintTestCase` to make testing `AVD::Constraints::Compound` easier ([#540]) (George Dietrich) - Add support for `ATH::UploadedFile` to `AVD::Constraints::File` and `AVD::Constraints::Image` ([#559]) (George Dietrich) ### Fixed - Fix equality between `AVD::Constraint` instances ([#540]) (George Dietrich) [0.4.1]: https://github.com/athena-framework/validator/releases/tag/v0.4.1 [#545]: https://github.com/athena-framework/athena/pull/545 [#540]: https://github.com/athena-framework/athena/pull/540 [#559]: https://github.com/athena-framework/athena/pull/559 ================================================ FILE: .changes/validator/v0.5.0.md ================================================ ## [0.5.0] - 2026-04-19 ### Changed - **Breaking:** Split `AVD::Constraints::Size` into `Count` and `Length` constraints ([#611]) (George Dietrich) - Make identifying constraint violation inequality easier within spec failures ([#610]) (George Dietrich) ### Added - Allow picking the unit used for `AVD::Constraints::Length` validations ([#612]) (George Dietrich) [0.5.0]: https://github.com/athena-framework/validator/releases/tag/v0.5.0 [#610]: https://github.com/athena-framework/athena/pull/610 [#611]: https://github.com/athena-framework/athena/pull/611 [#612]: https://github.com/athena-framework/athena/pull/612 ================================================ FILE: .changie.yaml ================================================ changesDir: .changes unreleasedDir: unreleased headerPath: header.tpl.md changelogPath: CHANGELOG.md versionExt: md versionFormat: '## [{{.VersionNoPrefix}}] - {{.Time.Format "2006-01-02"}}' kindFormat: '### {{.Kind}}' changeFormat: '- {{.Body}} ([#{{.Custom.PR}}]) ({{.Custom.Author}}) ' footerFormat: | [{{.VersionNoPrefix}}]: https://github.com/athena-framework/{{ kebabcase (index .Changes 0).Project }}/releases/tag/{{.Version}} {{- range (customs .Changes "PR" | uniq) }} [#{{.}}]: https://github.com/athena-framework/athena/pull/{{.}} {{- end}} projectsVersionSeparator: '/' projects: # Bundles - label: Mercure Bundle key: mercure-bundle changelog: src/bundles/mercure/CHANGELOG.md replacements: - { path: src/bundles/mercure/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/bundles/mercure/src/athena-mercure_bundle.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/mercure-bundle/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } # Components - label: Clock key: clock changelog: src/components/clock/CHANGELOG.md replacements: - { path: src/components/clock/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/clock/src/athena-clock.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/clock/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Console key: console changelog: src/components/console/CHANGELOG.md replacements: - { path: src/components/console/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/console/src/athena-console.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/console/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Contracts key: contracts changelog: src/components/contracts/CHANGELOG.md replacements: - { path: src/components/contracts/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/contracts/src/athena-contracts.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/contracts/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Dependency Injection key: dependency-injection changelog: src/components/dependency_injection/CHANGELOG.md replacements: - { path: src/components/dependency_injection/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/dependency_injection/src/athena-dependency_injection.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/dependency_injection/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Dotenv key: dotenv changelog: src/components/dotenv/CHANGELOG.md replacements: - { path: src/components/dotenv/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/dotenv/src/athena-dotenv.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/dotenv/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Event Dispatcher key: event-dispatcher changelog: src/components/event_dispatcher/CHANGELOG.md replacements: - { path: src/components/event_dispatcher/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/event_dispatcher/src/athena-event_dispatcher.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/event_dispatcher/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Framework key: framework changelog: src/components/framework/CHANGELOG.md replacements: - { path: src/components/framework/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/framework/src/athena.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: docs/getting_started/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: HTTP key: http changelog: src/components/http/CHANGELOG.md replacements: - { path: src/components/http/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/http/src/athena-http.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/http/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: HTTP Kernel key: http-kernel changelog: src/components/http_kernel/CHANGELOG.md replacements: - { path: src/components/http_kernel/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/http_kernel/src/athena-http_kernel.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/http_kernel/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Image Size key: image-size changelog: src/components/image_size/CHANGELOG.md replacements: - { path: src/components/image_size/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/image_size/src/athena-image_size.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/image_size/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Mercue key: mercure changelog: src/components/mercure/CHANGELOG.md replacements: - { path: src/components/mercure/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/mercure/src/athena-mercure.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/mercure/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: MIME key: mime changelog: src/components/mime/CHANGELOG.md replacements: - { path: src/components/mime/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/mime/src/athena-mime.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/mime/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Negotiation key: negotiation changelog: src/components/negotiation/CHANGELOG.md replacements: - { path: src/components/negotiation/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/negotiation/src/athena-negotiation.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/negotiation/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Routing key: routing changelog: src/components/routing/CHANGELOG.md replacements: - { path: src/components/routing/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/routing/src/athena-routing.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/routing/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Serializer key: serializer changelog: src/components/serializer/CHANGELOG.md replacements: - { path: src/components/serializer/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/serializer/src/athena-serializer.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/serializer/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Spec key: spec changelog: src/components/spec/CHANGELOG.md replacements: - { path: src/components/spec/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/spec/src/athena-spec.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/spec/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } - label: Validator key: validator changelog: src/components/validator/CHANGELOG.md replacements: - { path: src/components/validator/shard.yml, find: '^version: .*', replace: 'version: {{.VersionNoPrefix}}' } - { path: src/components/validator/src/athena-validator.cr, find: ' VERSION = ".*"', replace: ' VERSION = "{{.VersionNoPrefix}}"' } - { path: src/components/validator/docs/README.md, find: ' version: ~> .*', replace: ' version: ~> {{.Major}}.{{.Minor}}.0' } kinds: - label: Changed changeFormat: '- {{if eq .Custom.Breaking "Yes"}}**Breaking:** {{end}}{{.Body}} ([#{{.Custom.PR}}]) ({{.Custom.Author}}) ' additionalChoices: - type: enum key: Breaking label: Is it a breaking change? enumOptions: - Yes - No - label: Added - label: Removed - label: Fixed custom: - key: PR type: int minInt: 0 optional: false - key: Author type: string optional: false - key: Username label: Github Username type: string optional: false newlines: beforeChangelogVersion: 1 afterKind: 1 beforeKind: 1 beforeFooterTemplate: 1 envPrefix: CHANGIE_ ================================================ FILE: .editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: .gitattributes ================================================ *.cr text eol=lf ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: 'github-actions' directory: '/' # Updates tend to be released at the beginning of the month, but not exactly on the first. # Add some delay to be able to catpure them schedule: interval: 'cron' cronjob: '0 0 7 * *' labels: - 'kind:infrastructure' - 'kind:maintenance' - package-ecosystem: 'uv' directory: '/' schedule: interval: 'monthly' labels: - 'kind:documentation' - 'kind:maintenance' groups: documentation: patterns: - '*' ================================================ FILE: .github/pull_request_template.md ================================================ ## Context ## Changelog --- _Before merging, remember to add the `athena-framework/athena` prefix to the PR number in the PR title_ ================================================ FILE: .github/workflows/build_and_publish_docs.yml ================================================ on: workflow_dispatch: inputs: branch: description: 'Which Cloudflare Pages branch (master | dev) to deploy the docs to' type: string required: true workflow_call: inputs: branch: description: 'Which Cloudflare Pages branch (master | dev) to deploy the docs to' type: string required: true jobs: build-and-publish-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 - name: Install Crystal uses: crystal-lang/install-crystal@d8ef131ecec0352ce0e39b81b0a6d95def58fe2f # v1.9.2 - name: Install Crystal Shard Dependencies run: shards install --without-development env: SHARDS_OVERRIDE: ${{ inputs.branch == 'dev' && 'shard.dev.yml' || 'shard.prod.yml' }} - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.0.1 - name: Build Docs run: just build-docs - name: Publish to Cloudflare Pages uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: pages deploy ./site --project-name=athenaframework --branch=${{ inputs.branch }} ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: merge_group: push: branches: - master # Allows codecov to receive current HEAD information for each commit merged into master pull_request: branches: - master schedule: - cron: '15 1 * * *' # Nightly at 01:15 concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: check_spelling: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check Spelling uses: crate-ci/typos@bbaefadf97b0ec5fdc942684b647f1a6ab250274 # v1.46.0 check_format: strategy: fail-fast: false matrix: crystal: - latest - nightly runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 - name: Install Crystal uses: crystal-lang/install-crystal@d8ef131ecec0352ce0e39b81b0a6d95def58fe2f # v1.9.2 with: crystal: ${{ matrix.crystal }} - name: Check Format run: just format coding_standards: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 - name: Install Crystal uses: crystal-lang/install-crystal@d8ef131ecec0352ce0e39b81b0a6d95def58fe2f # v1.9.2 - name: Install Dependencies run: shards install env: SHARDS_OVERRIDE: shard.dev.yml - name: Ameba run: just ameba test_compiled: strategy: fail-fast: false matrix: crystal: - latest - nightly runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 - name: Install kcov if: ${{ matrix.crystal == vars.COVERAGE_CHANNEL }} run: | sudo apt-get update && sudo apt-get install binutils-dev libssl-dev libcurl4-openssl-dev libelf-dev libstdc++-12-dev zlib1g-dev libdw-dev libiberty-dev curl -L -o ./kcov.tar.gz https://github.com/SimonKagstrom/kcov/archive/refs/tags/v43.tar.gz && mkdir kcov-source && tar xzf kcov.tar.gz -C kcov-source --strip-components=1 && cd kcov-source && mkdir build && cd build && cmake .. && make -j$(nproc) && sudo make install - name: Install Crystal uses: crystal-lang/install-crystal@d8ef131ecec0352ce0e39b81b0a6d95def58fe2f # v1.9.2 with: crystal: ${{ matrix.crystal }} - name: Install System Dependencies run: sudo apt-get update && sudo apt install -y libmagic-dev - name: Install Dependencies run: shards install --skip-postinstall --skip-executables env: SHARDS_OVERRIDE: shard.dev.yml - name: Compiled Specs run: just test-compiled shell: bash - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 if: ${{ matrix.crystal == vars.COVERAGE_CHANNEL && github.event_name != 'schedule' }} # Only want to upload coverage report once in the matrix with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true directory: coverage files: '**/cov.xml,**/macro_coverage.*.codecov.json' # There is no `unreachable.codecov.json` file when running _only_ compiled specs verbose: true - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 if: ${{ matrix.crystal == vars.COVERAGE_CHANNEL && github.event_name != 'schedule' }} # Only want to upload coverage report once in the matrix with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true directory: coverage files: '**/junit.xml' verbose: true report_type: test_results test_unit: strategy: fail-fast: false matrix: os: - ubuntu-latest - ubuntu-24.04-arm - macos-latest - windows-latest crystal: - latest - nightly runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: github.event_name != 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: github.event_name == 'pull_request' with: fetch-depth: 0 - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 - name: Install kcov if: ${{ matrix.os == 'ubuntu-latest' && matrix.crystal == vars.COVERAGE_CHANNEL }} run: | sudo apt-get update && sudo apt-get install binutils-dev libssl-dev libcurl4-openssl-dev libelf-dev libstdc++-12-dev zlib1g-dev libdw-dev libiberty-dev curl -L -o ./kcov.tar.gz https://github.com/SimonKagstrom/kcov/archive/refs/tags/v43.tar.gz && mkdir kcov-source && tar xzf kcov.tar.gz -C kcov-source --strip-components=1 && cd kcov-source && mkdir build && cd build && cmake .. && make -j$(nproc) && sudo make install - name: Install Crystal uses: crystal-lang/install-crystal@d8ef131ecec0352ce0e39b81b0a6d95def58fe2f # v1.9.2 with: crystal: ${{ matrix.crystal }} - name: Install System Dependencies if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' run: sudo apt-get update && sudo apt install -y libmagic-dev - name: Install System Dependencies if: matrix.os == 'macos-latest' run: brew install libmagic - name: Install Dependencies run: shards install --skip-postinstall --skip-executables env: SHARDS_OVERRIDE: shard.dev.yml - name: Specs run: just test-unit shell: bash - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 if: ${{ matrix.os == 'ubuntu-latest' && matrix.crystal == vars.COVERAGE_CHANNEL && github.event_name != 'schedule' }} # Only want to upload coverage report once in the matrix with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true directory: coverage files: '**/cov.xml,**/unreachable.codecov.json' verbose: true - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 if: ${{ matrix.os == 'ubuntu-latest' && matrix.crystal == vars.COVERAGE_CHANNEL && github.event_name != 'schedule' }} # Only want to upload coverage report once in the matrix with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true directory: coverage files: '**/junit.xml' verbose: true report_type: test_results ================================================ FILE: .github/workflows/sync.yml ================================================ name: Sync on: push: branches: - master concurrency: group: ${{ github.workflow }} cancel-in-progress: false # ensure back-to-back merges don't cancel an in-progress sync jobs: # Sync changes in the merged PR to the read-only mirror repos. sync: runs-on: ubuntu-latest outputs: updated_shards: ${{ steps.subtree-sync.outputs.updated_shards }} kind_docs: ${{ steps.subtree-sync.outputs.kind_docs }} kind_release: ${{ steps.subtree-sync.outputs.kind_release }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # Required so `git subtree` can find its split commit ssh-key: ${{ secrets.ATHENA_BOT_SSH_PRIV_KEY }} # Workaround for git 2.52.0 regression breaking subtree split with --squash # See: https://lore.kernel.org/git/176677910605.6.2281395015810449820.1087545551@dietrich.pub/T/ - name: Cache Git installation id: cache-git uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/git-custom key: git-2.51.1-subtree-${{ runner.os }}-${{ runner.arch }} - name: Build Git with subtree support if: steps.cache-git.outputs.cache-hit != 'true' run: | set -euo pipefail GIT_VERSION="2.51.1" INSTALL_DIR="$HOME/git-custom" # Install build dependencies sudo apt-get update sudo apt-get install -y libcurl4-gnutls-dev libexpat1-dev gettext libz-dev libssl-dev # Download and extract wget -q "https://mirrors.edge.kernel.org/pub/software/scm/git/git-${GIT_VERSION}.tar.gz" tar -xzf "git-${GIT_VERSION}.tar.gz" cd "git-${GIT_VERSION}" # Build and install git make -j$(nproc) prefix="$INSTALL_DIR" all make prefix="$INSTALL_DIR" install # Build and install contrib/subtree cd contrib/subtree make prefix="$INSTALL_DIR" install - name: Add custom Git to PATH run: echo "$HOME/git-custom/bin" >> "$GITHUB_PATH" - name: Sync Shards id: subtree-sync env: GH_TOKEN: ${{ github.token }} BEFORE_SHA: ${{ github.event.before }} AFTER_SHA: ${{ github.event.after }} run: | set -euo pipefail UPDATED_SHARDS=() for shardDir in $(find src/ -maxdepth 3 -type f -name shard.yml | xargs -I{} dirname {} | sort); do # The git repos uses hyphens instead of underscores. REPO_NAME=$(basename "$shardDir" | tr '_' '-') if [[ "$shardDir" == src/bundles/* ]]; then REPO_NAME="${REPO_NAME}-bundle" fi REPO_URL="git@github.com:athena-framework/$REPO_NAME.git" if ! $(git diff --quiet --exit-code $BEFORE_SHA $AFTER_SHA -- "$shardDir"); then echo "Syncing: $REPO_NAME" git remote add "$REPO_NAME" "$REPO_URL" &> /dev/null || true git fetch --quiet "$REPO_NAME" git subtree push --prefix="$shardDir" "$REPO_NAME" "master" UPDATED_SHARDS+=("\"$REPO_NAME\"") fi done JSON_ARR=$(IFS=,; echo "[${UPDATED_SHARDS[*]}]") echo "Changed shards: $JSON_ARR" echo "updated_shards=$JSON_ARR" >> "$GITHUB_OUTPUT" PR_LABELS=$(gh api --jq '.[0].labels | map(.name)' /repos/athena-framework/athena/commits/${{ github.event.after }}/pulls) IS_KIND_DOCUMENTATION=$([ "null" != "$(jq 'index("kind:documentation")' <<< $PR_LABELS)" ] && echo "true" || echo "false") IS_KIND_RELEASE=$([ "null" != "$(jq 'index("kind:release")' <<< $PR_LABELS)" ] && echo "true" || echo "false") echo "kind_docs=$IS_KIND_DOCUMENTATION" >> "$GITHUB_OUTPUT" echo "kind_release=$IS_KIND_RELEASE" >> "$GITHUB_OUTPUT" release: needs: - sync if: needs.sync.outputs.updated_shards != '[]' && needs.sync.outputs.kind_release == 'true' strategy: fail-fast: false matrix: shard: ${{ fromJson(needs.sync.outputs.updated_shards) }} uses: ./.github/workflows/tag_and_create_release.yml secrets: inherit with: shard: ${{ matrix.shard }} # Trigger a re-build of the docs after all shards were released. build-docs: needs: - release uses: ./.github/workflows/build_and_publish_docs.yml secrets: inherit with: branch: master # Cherry picks changes into `docs` branch of each changed shard(s) for PRs with the `kind:documentation` label pick-docs: runs-on: ubuntu-latest needs: - sync if: needs.sync.outputs.updated_shards != '[]' && needs.sync.outputs.kind_docs == 'true' strategy: fail-fast: false matrix: shard: ${{ fromJson(needs.sync.outputs.updated_shards) }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: athena-framework/${{ matrix.shard }} ref: docs ssh-key: ${{ secrets.ATHENA_BOT_SSH_PRIV_KEY }} - name: Cherry pick commit run: | set -euo pipefail git config user.name "${{ vars.BOT_USER_NAME }}" git config user.email "${{ vars.BOT_USER_EMAIL }}" NEW_COMMIT=$(git ls-remote "git@github.com:athena-framework/${{ matrix.shard }}.git" HEAD | awk '{ print $1}') git fetch origin $NEW_COMMIT git cherry-pick $NEW_COMMIT git push origin docs # If there were shard related doc updates, trigger a re-build after all shards were synced. build-shard-docs: needs: - sync # This needs to also depend on `sync` so we cn use its outputs even if it's redundant because of the dependency on `pick-docs`. - pick-docs if: needs.sync.outputs.kind_docs == 'true' && needs.sync.outputs.updated_shards != '[]' uses: ./.github/workflows/build_and_publish_docs.yml secrets: inherit with: branch: master # If there were no shard related doc updates, simply trigger a re-build after `sync` job to handle updates to the root docs. build-root-docs: needs: - sync if: needs.sync.outputs.kind_docs == 'true' && needs.sync.outputs.updated_shards == '[]' uses: ./.github/workflows/build_and_publish_docs.yml secrets: inherit with: branch: master # Build and deploy a development version of the docs to allow viewing docs for all un-released changes in `master` build-dev-docs: uses: ./.github/workflows/build_and_publish_docs.yml secrets: inherit with: branch: dev ================================================ FILE: .github/workflows/tag_and_create_release.yml ================================================ on: workflow_dispatch: inputs: shard: description: 'Name of the shard to release' type: string required: true workflow_call: inputs: shard: description: 'Name of the shard to release' type: string required: true jobs: tag-and-create-release: runs-on: ubuntu-latest steps: - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 with: ssh-private-key: ${{ secrets.ATHENA_BOT_SSH_PRIV_KEY }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: athena-framework/${{ inputs.shard }} ssh-key: ${{ secrets.ATHENA_BOT_SSH_PRIV_KEY }} path: shard - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: root - name: Setup Changie uses: miniscruff/changie-action@11bcad388e7973948cbcecb10863baf024d5f607 # v3.0.0 with: version: v1.22.1 - name: Get the latest version working-directory: root id: current-release run: | TAG=$(changie latest --project "${{ inputs.shard }}" | cut -d'/' -f2) echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Tag release working-directory: shard run: | set -euo pipefail git config gpg.format ssh git config user.signingkey "${{ vars.BOT_USER_SIGNING_KEY }}" git config user.name "${{ vars.BOT_USER_NAME }}" git config user.email "${{ vars.BOT_USER_EMAIL }}" # Convert from GH repo name format into human readable format SHARD_NAME=$(echo "${{ inputs.shard }}" | tr '-' ' ' | sed -e 's/\b./\u\0/g') MESSAGE="Athena $SHARD_NAME ${{ steps.current-release.outputs.tag }}" git tag -asm "$MESSAGE" ${{ steps.current-release.outputs.tag }} git push --quiet origin ${{ steps.current-release.outputs.tag }} # Be sure to reset `docs` branch back to current state of `master` as a release assumes the previously cherry-picked commits are now inherently included git branch --quiet --force docs master git push --quiet origin docs --force - name: Create Release working-directory: root env: GH_TOKEN: ${{ secrets.SYNC_TOKEN }} run: | # Cuts off first 2 lines since version number and date are redundant in this context. # Then replace Author names with GH usernames for use in the GH release notes. # See https://github.com/miniscruff/changie/discussions/610#discussioncomment-13917611 for reference. tail -n+3 ".changes/${{ inputs.shard }}/${{ steps.current-release.outputs.tag }}.md" | \ perl -0777 -pe 's{\([^)]+\)\s*}{ "(" . join(", ", map { s/^\s+|\s+$//g; s/^@?/@/; $_ } grep { length } split(/\s*,\s*/, $1)) . ")" }ge' | \ gh release create ${{ steps.current-release.outputs.tag }} \ --repo athena-framework/${{ inputs.shard }} \ --verify-tag \ --title ${{ steps.current-release.outputs.tag }} \ --notes-file - ================================================ FILE: .gitignore ================================================ .DS_Store *.dwarf /.shards/ /bin/ /lib/ /logs/ # Libraries don't need dependency lock # Dependencies will be locked in application that uses them /shard.lock __pycache__ .cache .venv site/ coverage/ # Ignore `lib` symlink related to building docs # This includes bundle docs, due to mkdocs-material project plugin limitations /src/components/**/lib /src/bundles/**/lib ================================================ FILE: .python-version ================================================ 3.13 ================================================ FILE: .typos.toml ================================================ [default] extend-ignore-re = [ "(?Rm)^.*#\\s*spellchecker:disable-line$", # Allow disabling specific lines "=[0-9A-F \n\r]{2}", # Disable checking Quoted-Printable encoded strings ] [default.extend-words] referer = "referrer" ASPEC = "ASPEC" [files] extend-exclude = [ "src/components/routing/spec/fixtures/route_provider/*", "src/components/mime/src/types/data.cr", "src/components/mime/spec/fixtures/*", ] ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing First off, thank you for taking the time to contribute! Athena, and many other open source projects, would not be the same without you! The following is intended to be a living document describing the guidelines for contributing to Athena, and its shards. Athena makes use of the monorepo pattern, with each shard having its own read only repository. As such, all contributions should directed towards this repository. ## Start Here Find something that isn't working as expected? Have an idea for a new feature/enhancement? Want to improve the documentation? If you answer "Yes" to any of these, you've come to the right place! The first step is to search through the current [issues](https://github.com/athena-framework/athena/issues) and [pull requests](https://github.com/athena-framework/athena/pulls) to see if it has already been reported and/or resolved. If your search comes up empty then feel free to create an issue, or if you're still not sure if you should make one, stop by the [Discord](https://discord.gg/TmDVPb3dmr) server to ask just to be sure; even if the answer is most likely always going to be yes. ## Issue Tracker The [issue tracker](https://github.com/athena-framework/athena/issues) is the heart of the Athena. Use it for bugs, questions, proposals, and feature requests. Please always **open a new issue before sending a pull request** if you want to add a new feature to Athena, unless it is a minor obvious fix, or is in relation to an already open & approved issue. This reduces the likelihood of wasted effort, and ensures the end result is robust by being able to work out implementation details _before_ the work is started. ## Local Development Before staring any local development, be sure to [fork](https://github.com/athena-framework/athena/fork) the repo, then create a branch to use for the related approved issue you're working on. Due to Athena's usage of a monorepo, the same single repo can be used to contribute code to all shards. In addition to Crystal itself, Athena makes use of [just](https://just.systems/man/en/introduction.html) as its command runner. `just` provides a simple way of executing common commands needed for development. Once you have it installed, and have cloned the monorepo, first install all the shard dependencies by running: ```sh just install ``` And that's it, you are now ready to start coding! From here there are some additional optional tools that will come in handy: 1. [typos](https://github.com/crate-ci/typos) - Source code spell checker, used as part of the `spellcheck` recipe. 1. [watchexec](https://github.com/watchexec/watchexec) - Executes commands in response to file modifications, used as part of the `watch` and `watch-spec` recipes. 1. [kcov](https://github.com/SimonKagstrom/kcov) - Code coverage tool, used to generate coverage reports/files as part of the `test` recipes. 1. [changie](https://changie.dev/) - Changelog management tool, used as part of the `change` recipe. 1. [uv](https://docs.astral.sh/uv/) - Python package manager, used for the `docs` related recipes. **TIP:** Running `just` will provide a summary of available recipes. ### Development Because of Athena's usage of a monorepo some interactions may be different than a normal shard. Most things can be done from the root of the repo; no need to `cd` to whatever shard you're working on; can just go through `just`. For exploratory work, the suggested workflow is to have your code in the related shard's entry point file. E.g. `src/components/clock/src/athena-clock.cr` for the `clock` shard. From here you can run `just watch clock` and that will re-run the file when changes are made. This makes it simple to play around with early implementations before there is proper test coverage. #### Testing Similar to development itself, running the specs are also done through `just`: `just test clock` would run the spec suite for that shard, and generate coverage information if you have `kcov` installed. The `watch-test` recipe can come in handy to provide quicker feedback while the tests are under development. ##### Athena Spec Many Athena shards make use of [Athena Spec](https://athenaframework.org/Spec/) for their unit/integration tests. This library provides an alternate DSL that is 100% compatible with the standard library's `Spec` module. I.e. they can be used together seamlessly, using whatever DSL is more appropriate for what is being tested. Being familiar with the base [ASPEC::TestCase](https://athenaframework.org/Spec/TestCase/) type will not only make reading the specs easier, but writing them as well. It comes with various features to make the tests simpler, reusable, and extensible. You may even want to use it in your own projects :wink:. ### Linting Beyond testing, Athena makes use of various forms of linting, including: * [ameba](https://github.com/crystal-ameba/ameba) for static code analysis * [typos](https://github.com/crate-ci/typos) for spell checking * The Crystal [formatter](https://crystal-lang.org/reference/guides/writing_shards.html#coding-style) for code formatting All of these can be executed at once via the `just lint` recipe, but may also be ran individual as needed via their related `just` recipe. ### Documentation Athena's [documentation](https://athenaframework.org/) site may be built locally via the `just build-docs` recipe. Alternatively, a live-updating server may be started via the `just serve-docs` recipe. ## Opening a PR At this point the code on your branch should have a passing test suite, including linting/spellchecking, and updated documentation if applicable. From here the only step left is to open a PR. > **NOTE:** Once the PR is opened, please avoid force-pushing to that branch. Athena comes with a PR template that should be filled out; being sure to reference the issue number in the context section. E.g. `Resolves #xxx`. The changelog section should include all changes, both internal and external being sure to highlight breaking changes by prefixing the line with `**Breaking:**`. Additionally, changes that affect end users should also have a `changie` change file. These can most easily be created by following along the prompts of `just change`. Project maintainers can add the file(s) themselves if needed to move things along; just being sure to give proper attribution in the change file. > **NOTE:** As of now you'll need to open the PR _before_ creating the change file in order to know what the PR number is. ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2021 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Athena [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/framework.svg)](https://github.com/athena-framework/framework/releases) A monorepo representing the ecosystem of reusable, independent shards provided by Athena. See [Athena Framework](https://github.com/athena-framework/framework) for the web framework created using these shards. ## Documentation Checkout the [External Docs](https://athenaframework.org). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ## Contributors - [George Dietrich](https://github.com/blacksmoke16) - creator and maintainer ================================================ FILE: UPGRADING.md ================================================ # Upgrading The Athena ecosystem generally follows the [semver](https://semver.org/) pattern, but with some nuances due to its pre-1.0 status. Minor releases are reserved for major breaking changes, usually along side new large features/refactors. Patch releases contain backwards compatible features, bug fixes, and small breaking changes that are unlikely to affect the average user. In either case, the changes you need to make in order to upgrade will be documented in files like this one; located within each component's [repository](https://github.com/orgs/athena-framework/repositories?q=topic%3Acomponent&type=source&language=crystal&sort=name) as applicable. ================================================ FILE: codecov.yml ================================================ comment: layout: 'condensed_header, components, condensed_files, condensed_footer' coverage: status: project: default: target: auto # Prevent total coverage from decreasing due to a PR patch: default: target: 100% # Enforce all _new_ code is fully tested component_management: individual_components: # Bundles - component_id: mercure_bundle name: Mercure Bundle paths: - src/bundles/mercure/** # Components - component_id: clock_component name: Clock Component paths: - src/components/clock/** - component_id: console_component name: Console Component paths: - src/components/console/** - component_id: dependency_injection_component name: Dependency Injection Component paths: - src/components/dependency_injection/** - component_id: dotenv_component name: Dotenv Component paths: - src/components/dotenv/** - component_id: event_dispatcher_component name: Event Dispatcher Component paths: - src/components/event_dispatcher/** - component_id: framework_component name: Framework Component paths: - src/components/framework/** - component_id: http_component name: HTTP Component paths: - src/components/http/** - component_id: http_kernel_component name: HTTP Kernel Component paths: - src/components/http_kernel/** - component_id: image_size_component name: Image Size Component paths: - src/components/image_size/** - component_id: mercure_component name: Mercure Component paths: - src/components/mercure/** - component_id: mime_component name: MIME Component paths: - src/components/mime/** - component_id: negotiation_component name: Negotiation Component paths: - src/components/negotiation/** - component_id: routing_component name: Routing Component paths: - src/components/routing/** - component_id: serializer_component name: Serializer Component paths: - src/components/serializer/** - component_id: spec_component name: Spec Component paths: - src/components/spec/** - component_id: validator_component name: Validator Component paths: - src/components/validator/** ================================================ FILE: docs/README.md ================================================ ## Athena Athena is a collection of general-purpose, robust, independent, and reusable components with the goal of powering a software ecosystem. These include: * [Clock](/Clock/) (`ACLK`) - Decouples applications from the system clock * [Console](/Console/) (`ACON`) - Allows the creation of CLI based commands * [Contracts](/Contracts/) (`ACTR`) - A set of abstractions extracted out of the Athena components * [DependencyInjection](/DependencyInjection/) (`ADI`) - Robust dependency injection service container framework * [Dotenv](/Dotenv/) - Registers environment variables from a `.env` file * [EventDispatcher](/EventDispatcher/) (`AED`) - A Mediator and Observer pattern event library * [Framework](/Framework/) (`ATH`) - Integrates the components into a single cohesive, flexible, and modular framework * [HTTP](/HTTP/) (`AHTTP`) - Shared common HTTP abstractions/utilities * [HTTPKernel](/HTTPKernel/) (`AHK`) - Provides a structured process for converting a Request into a Response * [ImageSize](/ImageSize/) (`AIS`) - Measures the size of various image formats * [Mercure](/Mercure/) (`AMC`) - Allows easily pushing updates to web browsers and other HTTP clients using the Mercure protocol * [MIME](/MIME/) (`AMIME`) - Allows manipulating `MIME` messages * [Negotiation](/Negotiation/) (`ANG`) - Framework agnostic content negotiation library * [Routing](/Routing/) (`ART`) - A performant and robust HTTP based routing library/framework * [Serializer](/Serializer/) (`ASR`) - Object (de)serialization library * [Spec](/Spec/) (`ASPEC`) - Common/helpful [Spec](https://crystal-lang.org/api/Spec.html) compliant testing utilities * [Validator](/Validator/) (`AVD`) - Object/value validation library These components may be used on their own to aid in existing projects or integrated into existing (or new) frameworks. Additionally, some bundles are also provided that provide opt-in integrations of select components: * [MercureBundle](/MercureBundle/) (`ABM`) - Integrations the Mercure component into the framework. TIP: Each component may also define additional shortcut aliases. Check the `Aliases` page of each component in the [API Reference](./api_reference.md) for more information. ## Athena Framework Athena also provides the [Framework](./getting_started/README.md) component that integrates select components into a single cohesive, flexible, and modular framework. It is designed in such a way to be non-intrusive and not require a strict organizational convention in regards to how a project is setup; this allows it to use a minimal amount of setup boilerplate while not preventing it for more complex projects. Not every component needs to be used or understood to start using the framework, only those which are required for the task at hand. ### Feature Highlights Athena Framework has quite a few unique features that set it a part from other Crystal frameworks: * Follows the SOLID principles to encourage good software design * Architected in such a way to allow maximum flexibility without needing to fight against the framework * Uses annotations as a means of extension/customization * Built-in testing utilities TIP: The [demo](https://github.com/athena-framework/demo) application serves as a good example of what an application using the framework could look like. ## Resources * [Discord Server](https://discord.gg/TmDVPb3dmr) * [GitHub Repository](https://github.com/athena-framework/athena) ================================================ FILE: docs/api_reference.md ================================================ Links to the API docs of each component may be found in this section. These can be a good reference for more in-dept, component specific information, or how when using the component outside of the framework. ================================================ FILE: docs/bundle_reference.md ================================================ Bundles integrate components into the framework by registering services, configuring defaults, and wiring up dependencies via the [dependency injection](/DependencyInjection) component. Each bundle defines a schema that describes its available configuration options, allowing behavior to be customized without modifying code. See [Getting Started > Configuration](./getting_started/configuration.md#bundles) for more information on the role bundles play in Athena. The built-in [Framework Bundle](/Framework/Bundle/) schema is documented in the framework API docs. Third-party and standalone bundles, such as the [Mercure Bundle](/MercureBundle), are documented in this section. ================================================ FILE: docs/css/index.css ================================================ /* https://mkdocstrings.github.io/crystal/styling.html#recommended-styles */ /* Indentation of sub-items */ div.doc-contents:not(.first) { padding-left: 15px; border-left: 4px solid rgba(230, 230, 230); } /* Additional Customizations */ body { font-size: 1rem; } /* Increased spacing between macro & instance methods. */ .doc-macro + .doc-macro, .doc-instance_method + .doc-instance_method { margin-top: 3em; } .md-typeset pre>code::-webkit-scrollbar { height: 0.4em; } /* Slightly more compact headings */ h1.doc-heading { font-size: 1.3rem; } h3.doc-heading { font-size: 0.85rem; background: var(--md-code-bg-color); border-radius: 2px; } h3.schema-heading { margin: 0; } .schema-default, .schema-type { display: inline; } .schema-type p { display: inline; } .schema-default p { display: inline; } /* Make content grid a bit wider (but never wider than the screen) */ .md-grid { max-width: min(80rem, 100vw); } /* Make sure page content doesn't get too wide on big 4k monitors */ .md-content { max-width: 85ch; } ================================================ FILE: docs/css/monorepo.css ================================================ /* Hide latest release since the monorepo is the old framework repo and still has tags */ .md-source__fact--version { display: none; } ================================================ FILE: docs/getting_started/README.md ================================================ Athena Framework does not have any other dependencies outside of Crystal and Shards. It is designed in such a way to be non-intrusive and not require a strict organizational convention in regards to how a project is setup; this allows it to use a minimal amount of setup boilerplate while not preventing it for more complex projects. ## Install Athena Framework Add the framework component to your `shard.yml`: ```yaml dependencies: athena: github: athena-framework/framework version: ~> 0.22.0 ``` Then run `shards install`. This will install the framework component and its required component dependencies. Finally require it via `require "athena"`, then are all set to starting using the framework, starting with [Routing & HTTP](./routing.md). TIP: Check out the [skeleton](https://github.com/athena-framework/skeleton) template repository to get up and running quickly. ================================================ FILE: docs/getting_started/commands.md ================================================ The Athena Framework comes with a built-in integration with the [Athena::Console](/Console) component. This integration can be a way to define alternate entry points into your business logic, such as for use with scheduled jobs (Cron, Airflow, etc), or one-off internal/administrative things (running migrations, creating users, etc) all the while sharing the same dependencies due to it also leveraging [dependency injection](../why_athena.md#dependency-injection). ## Basic Usage Similar to [event listeners](./middleware.md#event-listeners), console commands can simply be registered as a service to be automatically registered. If using the preferred [ACONA::AsCommand](/Console/Annotations/AsCommand) annotation, they are registered in a lazy fashion, meaning only the command(s) you execute will actually be instantiated. ```crystal @[ADI::Register] @[ACONA::AsCommand("admin:create-user", description: "Creates a new internal user")] class AdminCreateUser < ACON::Command # A constructor can be defined to leverage existing services if applicable #def initialize( # @some_service : MyService #) # # Just be sure to call `super()`! # super() #end # Configure the command by adding arguments, options, aliases, etc. protected def configure : Nil self .argument("id", :required, "The employee's ID") .argument("name", :required, "The user's name") .argument("email", :optional, "The user's email. Assumed to be first.last if not provided") .option("admin", nil, :none, "If the user should be created as an internal admin") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # Provides a standardized format for how to display text in the terminal style = ACON::Style::Athena.new input, output input.argument "id", Int32 # => 12 name = input.argument "name" # => "George Dietrich" input.argument "email" # => nil input.option "admin", Bool # => true # Implement your business logic style.success "Successfully created a user for #{name}!" # Note the command executed successfully Status::SUCCESS end end ``` From here, if the application was created using the [skeleton](https://github.com/athena-framework/skeleton), commands can be executed via `shards run console -- admin:create-user 12 "George Dietrich" --admin`. Otherwise [ATH.run_console](/Framework/#Athena::Framework.run_console) can be used for your [entrypoint](/Console/#entrypoint) file. NOTE: During *development* the console binary needs to re-build the application in order to have access to the changes made since last execution. When deploying a *production* console binary, or if not doing any new console command dev, build it with the `--release` flag for increased performance. ## Built-in Commands The framework also comes with some helpful built-in commands to either help with debugging, or provide framework specific features. See each command within the [ATH::Commands](/Framework/Commands) namespace for more information. ================================================ FILE: docs/getting_started/configuration.md ================================================ 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: ```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 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 %} ``` 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 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 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 Athena integrates the [Athena::DependencyInjection](/DependencyInjection) component's ability to define custom annotation configurations. This feature allows developers to define custom annotations, and the data that should be read off of them, then apply/access the annotations on [ATH::Controller](/Framework/Controller) and/or [AHK::Action](/HTTPKernel/Action)s. This is a powerful feature that allows for almost limitless flexibility/customization. Some ideas include: storing some value in the request attributes and raise an exception or invoke some external service; all based on the presence/absence of it, a value read off of it, or either/both of those in-conjunction with an external service. For example: ```crystal require "athena" # Define our configuration annotation with an optional `name` argument. # A default value can also be provided, or made not nilable to be considered required. ADI.configuration_annotation MyAnnotation, name : String? = nil # Define and register our listener that will do something based on our annotation. @[ADI::Register] class MyAnnotationListener def initialize( @annotation_resolver : ATH::AnnotationResolver, ); end @[AEDA::AsEventListener] def on_view(event : AHK::Events::View) : Nil # Represents all custom annotations applied to the current AHK::Action + controller class. ann_configs = @annotation_resolver.action_annotations(event.request) # Check if this action has the annotation unless ann_configs.has? MyAnnotation # Do something based on presence/absence of it. # Would be executed for `ExampleController#one` since it does not have the annotation applied. end my_ann = ann_configs[MyAnnotation] # Access data off the annotation. if my_ann.name == "Fred" # Do something if the provided name is/is not some value. # Would be executed for `ExampleController#two` since it has the annotation applied, and name value equal to "Fred". end end end class ExampleController < ATH::Controller @[ARTA::Get("one")] def one : Int32 1 end @[ARTA::Get("two")] @[MyAnnotation(name: "Fred")] def two : Int32 2 end end ATH.run ``` ### Pagination A good example use case for custom annotations is the creation of a `Paginated` annotation that can be applied to controller actions to have them be paginated via the listener. Generic pagination can be implemented via listening on the [view](./middleware.md#4-view-event) event which exposes the value returned via the related controller action. ```crystal # Define our configuration annotation with the default pagination values. # These values can be overridden on a per endpoint basis. ADI.configuration_annotation Paginated, page : Int32 = 1, per_page : Int32 = 100, max_per_page : Int32 = 1000 # Define and register our listener that will handle paginating the response. @[ADI::Register] struct PaginationListener private PAGE_QUERY_PARAM = "page" private PER_PAGE_QUERY_PARAM = "per_page" def initialize( @annotation_resolver : ATH::AnnotationResolver, ); end # Use a high priority to ensure future listeners are working with the paginated data @[AEDA::AsEventListener(priority: 255)] def on_view(event : AHK::Events::View) : Nil # Return if the endpoint is not paginated. return unless (pagination = @annotation_resolver.action_annotations(event.request)[Paginated]?) # Return if the action result is not able to be paginated. return unless (action_result = event.action_result).is_a? Indexable request = event.request # Determine pagination values; first checking the request's query parameters, # using the default values in the `Paginated` object if not provided. page = request.query_params[PAGE_QUERY_PARAM]?.try &.to_i || pagination.page per_page = request.query_params[PER_PAGE_QUERY_PARAM]?.try &.to_i || pagination.per_page # Raise an exception if `per_page` is higher than the max. raise AHK::Exception::BadRequest.new "Query param 'per_page' should be '#{pagination.max_per_page}' or less." if per_page > pagination.max_per_page # Paginate the resulting data. # In the future a more robust pagination service could be injected # that could handle types other than `Indexable`, such as # ORM `Collection` objects. end_index = page * per_page start_index = end_index - per_page # Paginate and set the action's result. event.action_result = action_result[start_index...end_index] end end class ExampleController < ATH::Controller @[ARTA::Get("values")] @[Paginated(per_page: 2)] def get_values : Array(Int32) (1..10).to_a end end ATH.run # GET /values # => [1, 2] # GET /values?page=2 # => [3, 4] # GET /values?per_page=3 # => [1, 2, 3] # GET /values?per_page=3&page=2 # => [4, 5, 6] ``` ================================================ FILE: docs/getting_started/error_handling.md ================================================ ## HTTP Exceptions Exception handling in the Athena Framework is similar to exception handling in any Crystal program, with the addition of a new unique exception type, [AHK::Exception::HTTPException](/HTTPKernel/Exception/HTTPException). Custom `HTTP` errors can also be defined by inheriting from `AHK::Exception::HTTPException` or a child type. A use case for this could be allowing additional data/context to be included within the exception. Non `AHK::Exception::HTTPException` exceptions are represented as a `500 Internal Server Error`. When an exception is raised, the framework emits the [Exception](./middleware.md#8-exception-handling) event to allow an opportunity for it to be handled. By default these exceptions will return a `JSON` serialized version of the exception, via [AHK::ErrorRenderer](/HTTPKernel/ErrorRenderer), that includes the message and code; with the proper response status set. If the exception goes unhandled, i.e. no listener sets an [AHTTP::Response](/HTTP/Response) on the event, then the request is finished and the exception is re-raised. ```crystal require "athena" class ExampleController < ATH::Controller @[ARTA::Get("/divide/{num1}/{num2}")] def divide(num1 : Int32, num2 : Int32) : Int32 num1 // num2 end @[ARTA::Get("/divide_rescued/{num1}/{num2}")] def divide_rescued(num1 : Int32, num2 : Int32) : Int32 num1 // num2 # Rescue a non `AHK::Exception::HTTPException` rescue ex : DivisionByZeroError # in order to raise an `AHK::Exception::HTTPException` to provide a better error message to the client. raise AHK::Exception::BadRequest.new "Invalid num2: Cannot divide by zero" end end ATH.run # GET /divide/10/0 # => {"code":500,"message":"Internal Server Error"} # GET /divide_rescued/10/0 # => {"code":400,"message":"Invalid num2: Cannot divide by zero"} # GET /divide_rescued/10/10 # => 1 ``` ## Logging Logging is handled via Crystal's [Log](https://crystal-lang.org/api/Log.html) module. Athena Framework logs when a request matches a controller action, as well as any exception. This of course can be augmented with additional application specific messages. ```bash 2022-01-08T20:44:18.134423Z INFO - athena.routing: Server has started and is listening at http://0.0.0.0:3000 2022-01-08T20:44:19.773376Z INFO - athena.routing: Matched route 'example_controller_divide' -- route: "example_controller_divide", route_parameters: {"_route" => "example_controller_divide", "_controller" => "ExampleController#divide", "num1" => "10", "num2" => "0"}, request_uri: "/divide/10/0", method: "GET" 2022-01-08T20:44:19.892748Z ERROR - athena.routing: Uncaught exception # at /usr/lib/crystal/int.cr:141:7 in 'check_div_argument' Division by 0 (DivisionByZeroError) from /usr/lib/crystal/int.cr:141:7 in 'check_div_argument' from /usr/lib/crystal/int.cr:105:5 in '//' from src/components/framework/src/athena.cr:206:5 in 'divide' from src/components/framework/src/ext/routing/annotation_route_loader.cr:8:5 in '->' from /usr/lib/crystal/primitives.cr:266:3 in 'execute' from src/components/framework/src/route_handler.cr:76:16 in 'handle_raw' from src/components/framework/src/route_handler.cr:19:5 in 'handle' from src/components/framework/src/athena.cr:161:27 in '->' from /usr/lib/crystal/primitives.cr:266:3 in 'process' from /usr/lib/crystal/http/server.cr:515:5 in 'handle_client' from /usr/lib/crystal/http/server.cr:468:13 in '->' from /usr/lib/crystal/primitives.cr:266:3 in 'run' from /usr/lib/crystal/fiber.cr:98:34 in '->' from ??? 2022-01-08T20:45:10.803001Z INFO - athena.routing: Matched route 'example_controller_divide_rescued' -- route: "example_controller_divide_rescued", route_parameters: {"_route" => "example_controller_divide_rescued", "_controller" => "ExampleController#divide_rescued", "num1" => "10", "num2" => "0"}, request_uri: "/divide_rescued/10/0", method: "GET" 2022-01-08T20:45:10.923945Z WARN - athena.routing: Uncaught exception # at src/components/framework/src/athena.cr:215:5 in 'divide_rescued' Invalid num2: Cannot divide by zero (Athena::Framework::Exception::BadRequest) from src/components/framework/src/athena.cr:215:5 in 'divide_rescued' from src/components/framework/src/ext/routing/annotation_route_loader.cr:8:5 in '->' from /usr/lib/crystal/primitives.cr:266:3 in 'execute' from src/components/framework/src/route_handler.cr:76:16 in 'handle_raw' from src/components/framework/src/route_handler.cr:19:5 in 'handle' from src/components/framework/src/athena.cr:161:27 in '->' from /usr/lib/crystal/primitives.cr:266:3 in 'process' from /usr/lib/crystal/http/server.cr:515:5 in 'handle_client' from /usr/lib/crystal/http/server.cr:468:13 in '->' from /usr/lib/crystal/primitives.cr:266:3 in 'run' from /usr/lib/crystal/fiber.cr:98:34 in '->' from ??? 2022-01-08T20:45:14.132652Z INFO - athena.routing: Matched route 'example_controller_divide_rescued' -- route: "example_controller_divide_rescued", route_parameters: {"_route" => "example_controller_divide_rescued", "_controller" => "ExampleController#divide_rescued", "num1" => "10", "num2" => "10"}, request_uri: "/divide_rescued/10/10", method: "GET" ``` #### Customization By default the Athena Framework utilizes the default [Log::Formatter](https://crystal-lang.org/api/Log/Formatter.html) and [Log::Backend](https://crystal-lang.org/api/Log/Backend.html)s Crystal defines. This of course can be customized via interacting with Crystal's [Log](https://crystal-lang.org/api/Log.html) module. It is also possible to control what exceptions, and with what severity, will be logged by redefining the `log_exception` method within [AHK::Listeners::Error](/HTTPKernel/Listeners/Error). TIP: Since `AHK::Listeners::Error` logs already include the error message and first line of the trace, consider defining a custom [Log Formatter](https://crystal-lang.org/api/Log/Formatter.html) that excludes the `exception` to have shorter, single line error logs in console: ```crystal Log.define_formatter SingleLineFormatter, "#{timestamp} #{severity} - #{source(after: ": ")}#{message}" \ "#{data(before: " -- ")}#{context(before: " -- ")}" # 2024-03-04T05:30:29.329041Z INFO - athena.framework: Server has started and is listening at http://0.0.0.0:3000 # 2024-03-04T05:30:37.568264Z INFO - athena.framework: Matched route 'view_controller_bar' -- route: "view_controller_bar", route_parameters: {"_route" => "view_controller_bar", # "_controller" => "ViewController#bar"}, request_uri: "/bar", method: "GET" # 2024-03-04T05:30:40.280070Z INFO - athena.framework: Matched route 'view_controller_foo' -- route: "view_controller_foo", route_parameters: {"_route" => "view_controller_foo", # "_controller" => "ViewController#foo"}, request_uri: "/foo", method: "GET" # 2024-03-04T05:30:40.351541Z ERROR - athena.framework: Uncaught exception # at src/components/framework/src/view/view_handler.cr:166:21 in 'init_response' # 2024-03-04T05:30:41.281275Z INFO - athena.framework: Matched route 'view_controller_foo' -- route: "view_controller_foo", route_parameters: {"_route" => "view_controller_foo", # "_controller" => "ViewController#foo"}, request_uri: "/foo", method: "GET" # 2024-03-04T05:30:41.282632Z ERROR - athena.framework: Uncaught exception # at src/components/framework/src/view/view_handler.cr:166:21 in 'init_response' # 2024-03-04T05:30:43.886367Z INFO - athena.framework: Matched route 'view_controller_bar' -- route: "view_controller_bar", route_parameters: {"_route" => "view_controller_bar", # "_controller" => "ViewController#bar"}, request_uri: "/bar", method: "GET" ``` ================================================ FILE: docs/getting_started/middleware.md ================================================ At a high level the Athena Framework's job is *to interpret a request and create the appropriate response based on your application logic*. Conceptually this could be broken down into three steps: 1. Consume the request 2. Apply application logic to determine what the response should be 3. Return the response Steps 1 and 3 are handled via Crystal's [HTTP::Server](https://crystal-lang.org/api/HTTP/Server.html), while step 2 is where the framework fits in. ## Events Athena Framework is an event based framework, meaning it emits various events via the [Event Dispatcher](/EventDispatcher) component during the life-cycle of a request. These events are listened on internally in order to handle each request; custom listeners on these events can also be registered. The flow of a request, and the related events that are dispatched, is depicted below in a visual format: ![High Level Request Life-cycle Flow](../img/Athena.png) ### 1. Request Event The very first event that is dispatched is the [AHK::Events::Request](/HTTPKernel/Events/Request) event and can have a variety of listeners. The primary purpose of this event is to create an [AHTTP::Response](/HTTP/Response/) directly, or to add information to the requests' attributes; a simple key/value store tied to request instance accessible via [AHTTP::Request#attributes](/HTTP/Request/#Athena::HTTP::Request#attributes). In some cases the listener may have enough information to return an [AHTTP::Response](/HTTP/Response/) immediately. An example of this would be the [ATH::Listeners::CORS](/Framework/Listeners/CORS/) listener. If enabled it is able to return a `CORS` preflight response even before routing is invoked. WARNING: If an [AHTTP::Response](/HTTP/Response/) is returned at this stage, the flow of the request skips directly to the [response](#5-response-event) event. Future `Request` event listeners will not be invoked either. Another use case for this event is populating additional data into the request's attributes; such as the locale or format of the request. !!! example "Request event in the Athena Framework" This is the event that [AHK::Listeners::Routing](/HTTPKernel/Listeners/Routing/) listens on to determine which [ATH::Controller](/Framework/Controller/)/[AHK::Action](/HTTPKernel/Action/) pair should handle the request. See [ATH::Controller](/Framework/Controller/) for more details on routing. ### 2. Action Event The next event to be dispatched is the [AHK::Events::Action](/HTTPKernel/Events/Action/) event, assuming a response was not already returned within the [request](#1-request-event) event. This event is dispatched after the related controller/action pair is determined, but before it is executed. This event is intended to be used when a listener requires information from the related [AHK::Action](/HTTPKernel/Action/); such as reading [custom annotations](./configuration.md#custom-annotations). ### 3. Invoke the Controller Action This next step is not an event, but a important concept within the Athena Framework nonetheless; executing the controller action related to the current request. #### Argument Resolution Before the controller action can be invoked, the arguments, if any, to pass to it need to be determined. This is achieved via an [AHK::Controller::ArgumentResolverInterface](/HTTPKernel/Controller/ArgumentResolverInterface/) that facilitates gathering all the arguments. One or more [ATHR::Interface](/Framework/Controller/ValueResolvers/Interface/) will then be used to resolve each specific argument's value. Checkout [ATH::Controller::ValueResolvers](/Framework/Controller/ValueResolvers/) for a summary of the built-in resolvers, and the order in which they are invoked. Custom value resolves may be created & registered to extend this functionality. TODO: An additional event could possibly be added after the arguments have been resolved, but before invoking the controller action. #### Execute the Controller Action The job of a controller action is to apply business/application logic to build a response for the related request; such as an HTML page, a JSON string, or anything else. How/what exactly this should be is up to the developer creating the application. #### Handle the Response The type of the value returned from the controller action determines what happens next. If the value is an [AHTTP::Response](/HTTP/Response/), then it is used as is, skipping directly to the [response](#5-response-event) event. However, if the value is _NOT_ an `AHTTP::Response`, then the [view](#4-view-event) is dispatched (since the framework _needs_ an `AHTTP::Response` in order to have something to send back to the client). ### 4. View Event The [AHK::Events::View](/HTTPKernel/Events/View/) event is only dispatched when the controller action does _NOT_ return an [AHTTP::Response](/HTTP/Response/). The purpose of this event is to turn the controller action's return value into an `AHTTP::Response`. An [ATH::View](/Framework/View/) may be used to customize the response, e.g. setting a custom response status and/or adding additional headers; while keeping the controller action response data intact. This event is intended to be used as a "View" layer; allowing scalar values/objects to be returned while listeners convert that value to the expected format (e.g. JSON, HTML, etc.). See the [negotiation](./routing.md#content-negotiation) component for more information on this feature. !!! example "View event in the Athena Framework" By default the framework will JSON serialize any non [AHTTP::Response](/HTTP/Response/) values. ### 5. Response Event The end goal of the Athena Framework is to return an [AHTTP::Response](/HTTP/Response/) back to the client; which might be created within the [request](#1-request-event) event, returned from the related controller action, or set within the [view](#4-view-event) event. Regardless of how the response was created, the [AHK::Events::Response](/HTTPKernel/Events/Response/) event is dispatched directly after. The intended use case for this event is to allow for modifying the response object in some manner. Common examples include: add/edit headers, add cookies, change/compress the response body. ### 6. Return the Response The raw [HTTP::Server::Response](https://crystal-lang.org/api/HTTP/Server/Response.html) object is never directly exposed. The reasoning for this is to allow listeners to mutate the response before it is returned as mentioned in the [response](#5-response-event) event section. If the raw response object was exposed, whenever any data is written to it it'll immediately be sent to the client and the status/headers will be locked; as mentioned in the Crystal API docs: > The response `#status` and `#headers` must be configured before writing the response body. Once response output is written, changing the `#status` and `#headers` properties has no effect. Each [AHTTP::Response](/HTTP/Response/) has a [AHTTP::Response::Writer](/HTTP/Response/Writer/) instance that determines _how_ the response should be written to the raw response's IO. By default it is written directly, but can be customized via the [response](#5-response-event) event, such as for compression. ### 7. Terminate Event The final event to be dispatched is the [AHK::Events::Terminate](/HTTPKernel/Events/Terminate/) event. This is event is dispatched _after_ the response has been sent to the user. The intended use case for this event is to perform some "heavy" action after the user has received the response; as to not affect the response time of the request. E.x. queuing up emails or logs to be sent/written after a successful request. ### 8. Exception Handling If an exception is raised at anytime while a request is being handled, the [AHK::Events::Exception](/HTTPKernel/Events/Exception/) is dispatched. The purpose of this event is to convert the exception into an [AHTTP::Response](/HTTP/Response/). This is globally handled via an [AHK::ErrorRendererInterface](/HTTPKernel/ErrorRendererInterface/), with the default being to JSON serialize the exception. It is also possible to handle specific error states differently by registering multiple exception listeners to handle each case. An example of this could be to invoke some special logic only if the exception is of a specific type. ## Event Listeners Unlike other frameworks, Athena Framework leverages event based middleware instead of a pipeline based approach. The primary use case for event listeners is to tap into the life-cycle of the request, such as adding common headers, setting state extracted from the request, or whatever else the application requires. These can be created by creating a type annotated with [ADI::Register](/DependencyInjection/Register), then annotating one or more methods with [AEDA::AsEventListener](/EventDispatcher/Annotations/AsEventListener). ```crystal require "athena" @[ADI::Register] class CustomListener @[AEDA::AsEventListener] def on_response(event : AHK::Events::Response) : Nil event.response.headers["FOO"] = "BAR" end end class ExampleController < ATH::Controller get "/" do "Hello World" end end ATH.run # GET / # => Hello World (with `FOO => BAR` header) ``` Similarly, the framework itself is implemented using the same features available to the users. Thus it is very easy to run specific listeners before/after the built-in ones if so desired. TIP: Check out the `debug:event-dispatcher` [command](./commands.md) for an easy way to see all the listeners and the order in which they are executed. TIP: A single event listener may listen on multiple events. Instance variables can be used to share state between the events. WARNING: The "type" of the listener has an effect on its behavior! When a `struct` service is retrieved or injected into a type, it will be a copy of the one in the SC (passed by value). This means that changes made to it in one type, will *NOT* be reflected in other types. A `class` service on the other hand will be a reference to the one in the SC. This allows it to share state between services. ## Custom Events Using events can be a helpful design pattern to allow for code that is easily extensible. An event represents something _has happened_ where nobody may be interested in it, or in other words there may be zero or more listeners listening on a given event. A more concrete example is an event could be dispatched after some core piece of application logic. From here it would be easy to tap into when this logic is executed to perform some other follow up action, without increasing the complexity of the type that performs the core action. This also adheres to the [single responsibility](../why_athena.md#single-responsibility) principle. ```crystal require "athena" # Define a custom event class MyEvent < AED::Event property value : Int32 def initialize(@value : Int32); end end # Define a listener that listens our the custom event. @[ADI::Register] class CustomEventListener @[AEDA::AsEventListener] def call(event : MyEvent) : Nil event.value *= 10 end end # Register a controller as a service, # injecting the event dispatcher to handle processing our value. @[ADI::Register] class ExampleController < ATH::Controller def initialize(@event_dispatcher : AED::EventDispatcherInterface); end @[ARTA::Get("/{value}")] def get_value(value : Int32) : Int32 event = MyEvent.new value @event_dispatcher.dispatch event event.value end end ATH.run # GET /10 # => 100 ``` ================================================ FILE: docs/getting_started/routing.md ================================================ ## Controllers The Athena Framework is a MVC based framework, as such, the logic to handle a given route is defined within an [ATH::Controller](/Framework/Controller). Athena Framework takes an annotation based approach to routing. An annotation, such as `ARTA::Get` is applied to an instance method of a controller class, which will be executed when that endpoint receives a request. ### Creating a Route In Athena Framework, controllers are simply classes and route actions are simply methods. This means they can be documented/tested as you would any Crystal class/method. However see the [testing](./testing.md#testing-controllers) section for how to best test a controller. ```crystal require "athena" # Define a controller class ExampleController < ATH::Controller # Define an action to handle the related route @[ARTA::Get("/")] def index : String "Hello World" end # The macro DSL can also be used get "/" do "Hello World" end end # Run the server ATH.run # GET / # => Hello World ``` Routing is handled via the [Athena::Routing](/Routing) component. It provides a flexible and robust foundation for handling determining which route should match a given request. TIP: Check out the `debug:router` [command](./commands.md) to view all of the routes the framework is aware of within your application. ### Raw Response An [AHTTP::Response](/HTTP/Response) can be used to fully customize the response; such as returning a specific status code, or adding some one-off headers. ```crystal require "athena" require "mime" class ExampleController < ATH::Controller # A GET endpoint returning an `AHTTP::Response`. # Can be used to return raw data, such as HTML or CSS etc, in a one-off manner. @[ARTA::Get("/index")] def index : AHTTP::Response AHTTP::Response.new( "

Welcome to my website!

", headers: HTTP::Headers{"content-type" => MIME.from_extension(".html")} ) end end ATH.run # GET /index # => "

Welcome to my website!

" ``` A [View](./middleware.md#4-view-event) event is emitted if the returned value is _NOT_ an [AHTTP::Response](/HTTP/Response). By default, non `AHTTP::Response`s are JSON serialized. However, this event can be listened on to customize how the value is serialized. More on this in the [Content Negotiation](#content-negotiation) section. ### Route Parameters Arguments are converted to their expected types if possible, otherwise an error response is automatically returned. The values are provided directly as method arguments, thus preventing the need for `env.params.url["name"]` and any boilerplate related to it. Just like normal method arguments, default values can be defined. The method's return type adds some type safety to ensure the expected value is being returned. ```crystal require "athena" class ExampleController < ATH::Controller @[ARTA::Get("/add/{value1}/{value2}")] def add(value1 : Int32, value2 : Int32) : Int32 value1 + value2 end end ATH.run # GET /add/2/3 # => 5 # GET /add/foo/12 # => {"code":400,"message":"Required parameter 'value1' with value 'foo' could not be converted into a valid 'Int32'"} ``` TIP: For more complex conversions, consider creating a [Value Resolver](/Framework/Controller/ValueResolvers/Interface) to encapsulate the logic. #### Query Parameters [ATHA::MapQueryParameter](/Framework/Annotations/MapQueryParameter) can be used to map a query parameter directly to a controller action parameter. ```crystal require "athena" class ExampleController < ATH::Controller @[ARTA::Get("/")] def index(@[ATHA::MapQueryParameter] page : Int32) : Int32 page end end ATH.run # GET / # => {"code":404,"message":"Missing query parameter: 'page'."} # GET /?page=10 # => 10 # GET /?page=bar # => {"code":404,"message":"Invalid query parameter: 'page'."} ``` This works well enough for one-off parameters. However [ATHA::MapQueryString](/Framework/Annotations/MapQueryString) can be used to the request's query string into a DTO type, much like how `JSON::Serializable` works for example. In addition to making it easier to reuse, it also allows for enhanced validation of the query parameters via the [`Athena::Validator`](/Validator/#validating-objects) component. ### Raw Request Restricting an action argument to [AHTTP::Request](/HTTP/Request) will provide the raw request object. This can be useful to access data directly off the request object, such as consuming the request's body. This approach is fine for simple or one-off endpoints. TIP: Check out [ATHR::RequestBody](/Framework/Controller/ValueResolvers/RequestBody) for a better way to handle this. ```crystal require "athena" class ExampleController < ATH::Controller @[ARTA::Post("/data")] def data(request : AHTTP::Request) : String raise AHK::Exception::BadRequest.new "Request body is empty." unless body = request.body JSON.parse(body).as_h["name"].as_s end end ATH.run # POST /data body: {"id":1,"name":"Jim"} # => Jim ``` ### Streaming Response By default `AHTTP::Response` content is written all at once to the response's `IO`. However in some cases the content may be too large to fit into memory. In this case an [AHTTP::StreamedResponse](/HTTP/StreamedResponse) may be used to stream the content back to the client. ```crystal require "athena" require "mime" class ExampleController < ATH::Controller @[ARTA::Get(path: "/users")] def users : AHTTP::Response AHTTP::StreamedResponse.new headers: HTTP::Headers{"content-type" => "application/json; charset=UTF-8"} do |io| User.all.to_json io end end end ATH.run # GET /athena/users" # => [{"id":1,...},...] ``` ## File Uploads Athena supports the [opt-in](/Framework/Bundle/Schema/FileUploads/) feature of populating [AHTTP::Request#files](/HTTP/Request/#Athena::HTTP::Request#files) based on the files included in a `multipart/form-data` file upload request. A [HTTP::FormData::Part](https://crystal-lang.org/api/HTTP/FormData/Part.html) without a *filename* is considered to be just a normal textual field and will be added to [AHTTP::Request#attributes](/HTTP/Request/#Athena::HTTP::Request#attributes). These values can be provided to the controller action in the same way route parameters can. ```crystal require "athena" class ExampleController < ATH::Controller @[ARTA::Post(path: "/avatar")] def avatar(request : AHTTP::Request) : String request.files["profile_picture"][0].client_original_name end end ATH.configure({ framework: { file_uploads: { enabled: true, }, }, }) ATH.run # POST /avatar" (multipart/form-data request with `profile_picture` key pointing to the `pic.png` file) # => pic.png ``` TIP: Check out [ATHA::MapUploadedFile](/Framework/Annotations/MapUploadedFile/) for a better way to handle this. ## File Response An [AHTTP::BinaryFileResponse](/HTTP/BinaryFileResponse) may be used to return static files/content. This response type handles caching, partial requests, and setting the relevant headers. The Athena Framework also supports downloading of dynamically generated content by using an [AHTTP::Response](/HTTP/Response) with the [content-disposition](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition) header. [AHTTP::HeaderUtils.make_disposition](/HTTP/HeaderUtils/#Athena::HTTP::HeaderUtils.make_disposition(disposition,filename,fallback_filename)) can be used to easily build the header. ```crystal require "athena" require "mime" class ExampleController < ATH::Controller @[ARTA::Get(path: "/data/export")] def data_export : AHTTP::Response content = # ... AHTTP::Response.new( content, headers: HTTP::Headers{ "content-disposition" => ATH::HeaderUtils.make_disposition(:attachment, "data.csv"), "content-type" => MIME.from_extension(".csv") } ) end end ATH.run ``` ### Static Files Static files can also be served from an Athena application. This can be achieved by combining an [AHTTP::BinaryFileResponse](/HTTP/BinaryFileResponse) with the [request](./middleware.md#1-request-event) event; checking if the request's path represents a file/directory within the application's public directory and returning the file if so. ```crystal # Register a request event listener to handle returning static files. @[ADI::Register] struct StaticFileListener # This could be parameter if the directory changes between environments. private PUBLIC_DIR = Path.new("public").expand # Run this listener with a very high priority so it is invoked before any application logic. @[AEDA::AsEventListener(priority: 256)] def on_request(event : AHK::Events::Request) : Nil # Fallback if the request method isn't intended for files. # Alternatively, a 405 could be thrown if the server is dedicated to serving files. return unless event.request.method.in? "GET", "HEAD" original_path = event.request.path request_path = URI.decode original_path # File path cannot contains '\0' (NUL). if request_path.includes? '\0' raise AHK::Exception::BadRequest.new "File path cannot contain NUL bytes." end request_path = Path.posix request_path expanded_path = request_path.expand "/" file_path = PUBLIC_DIR.join expanded_path.to_kind Path::Kind.native is_dir = Dir.exists? file_path is_dir_path = original_path.ends_with? '/' event.response = if request_path != expanded_path || is_dir && !is_dir_path redirect_path = expanded_path if is_dir && !is_dir_path redirect_path = expanded_path.join "" end # Request is a directory but acting as a file, # redirect to the actual directory URL. AHTTP::RedirectResponse.new redirect_path elsif File.file? file_path AHTTP::BinaryFileResponse.new file_path else # Nothing to do. return end end 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" class ExampleController < ATH::Controller # Define a route to redirect to, explicitly naming this route `add`. # The default route name is controller + method down snake-cased; e.x. `example_controller_add`. @[ARTA::Get("/add/{value1}/{value2}", name: "add")] def add(value1 : Int32, value2 : Int32, negative : Bool = false) : Int32 sum = value1 + value2 negative ? -sum : sum end # Define a route that redirects to the `add` route with fixed parameters. @[ARTA::Get("/")] def redirect : AHTTP::RedirectResponse # Generate a link to the other route. url = self.generate_url "add", value1: 8, value2: 2 url # => /add/8/2 # Redirect to the user to the generated url. self.redirect url # Or could have used a method that does both self.redirect_to_route "add", value1: 8, value2: 2 end end ATH.run # GET / # => 10 ``` 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 Currently due to Athena Framework's [architecture](./middleware.md#events), WebSockets are not directly supported. However the framework does allow prepending [HTTP::Handler](https://crystal-lang.org/api/HTTP/Handler.html) to the internal server. This could be used to leverage the standard library's [HTTP::WebSocketHandler](https://crystal-lang.org/api/HTTP/WebSocketHandler.html) handler or a third party library such as https://github.com/cable-cr/cable. ```crystal require "athena" # ... ws_handler = HTTP::WebSocketHandler.new do |ws, ctx| ws.on_ping { ws.pong ctx.request.path } end ATH.run prepend_handlers: [ws_handler] ``` Alternatively, the [Athena::Mercure](/Mercure) component may be used as a replacement of the more common websocket use cases. ## Content Negotiation As mentioned earlier, controller action responses are JSON serialized if the controller action does _NOT_ return an [AHTTP::Response](/HTTP/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) header and the format priority configuration. ### Format Priority 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 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 [AHTTP::Response](/HTTP/Response) in the format determined by the [ATH::Listeners::Format](/Framework/Listeners/Format), otherwise falling back on the request's [format](/HTTP/Request/#Athena::HTTP::Request#format(mime_type)), defaulting to `json`. 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 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 An [ATH::View](/Framework/View) is intended to act as an in between returning raw data and an [AHTTP::Response](/HTTP/Response). In other words, it still invokes the [view](./middleware.md#4-view-event) event, but allows customizing the response's status and headers. Convenience methods are defined in the base controller type to make creating views easier. E.g. [ATH::Controller#view](/Framework/Controller/#Athena::Framework::Controller#view(data,status,headers)). ### View Format Handlers By default the Athena Framework uses `json` as the default response format. However it is possible to extend the [ATH::View::ViewHandler](/Framework/View/ViewHandler) to support additional, and even custom, formats. This is achieved by creating an [ATH::View::FormatHandlerInterface](/Framework/View/FormatHandlerInterface) instance that defines the logic needed to turn an [ATH::View](/Framework/View) into an [AHTTP::Response](/HTTP/Response). The implementation can be as simple/complex as needed for the given format. Official handlers could be provided in the future for common formats such as `html`, probably via an integration with some form of tempting engine utilizing [custom annotations](./configuration.md#custom-annotations) to specify the format. ### Adding/Customizing Formats [AHTTP::Request::FORMATS](/HTTP/Request/#Athena::HTTP::Request::FORMATS) represents the formats supported by default. However this list is not exhaustive and may need altered application to application; such as [registering](/HTTP/Request/#Athena::HTTP::Request.register_format(format,mime_types)) new formats. #### Example The following is a demonstration of how the various negotiation features can be used in conjunction. The example includes: 1. Defining a custom [ATH::View::ViewHandler](/Framework/View/ViewHandler) for the `csv` format. 1. Enabling content negotiation, supporting `json` and `csv` formats, falling back to `json`. 1. An endpoint returning an [ATH::View](/Framework/View) that sets a custom HTTP status. ```crystal require "athena" require "csv" # An interface to denote a type can provide its data in CSV format. # # An easier/more robust implementation can probably be thought of, # however this is mainly for demonstration purposes. module CSVRenderable abstract def to_csv(builder : CSV::Builder) : Nil end # Define an example entity type. record User, id : Int64, name : String, email : String do include CSVRenderable include JSON::Serializable # Define the headers this type has. def self.headers : Enumerable(String) { "id", "name", "email", } end def to_csv(builder : CSV::Builder) : Nil # Add the related values based on `self.` builder.row @id, @name, @email end end # Register our handler as a service. @[ADI::Register] class CSVFormatHandler # Implement the interface. include ATH::View::FormatHandlerInterface # :inherit: def call(view_handler : ATH::View::ViewHandlerInterface, view : ATH::ViewBase, request : AHTTP::Request, format : String) : AHTTP::Response view_data = view.data headers = if view_data.is_a? Enumerable typeof(view_data.first).headers else view_data.class.headers end data = if view_data.is_a? Enumerable view_data else {view_data} end # Assume each item has the same headers. content = CSV.build do |csv| csv.row headers data.each do |r| r.to_csv csv end end # Return an AHTTP::Response with the rendered CSV content. # Athena handles setting the proper content-type header based on the format. # But could be overridden here if so desired. AHTTP::Response.new content end # :inherit: def format : String "csv" end end ATH.configure({ framework: { format_listener: { enabled: true, rules: [ # Allow json and csv formats, falling back on json if an unsupported format is requested. {priorities: ["json", "csv"], fallback_format: "json"} ] }, } }) class ExampleController < ATH::Controller @[ARTA::Get("/users")] def get_users : ATH::View(Array(User)) self.view([ User.new(1, "Jim", "jim@example.com"), User.new(2, "Bob", "bob@example.com"), User.new(3, "Sally", "sally@example.com"), ], status: :im_a_teapot) end end ATH.run ``` ================================================ FILE: docs/getting_started/testing.md ================================================ One of the benefits of using the Athena Framework is testing is considered a first class citizen. Both the framework and the components themselves provides testing utilities to help ensure your code is working as expected. A small amount of setup is required to make use of the testing features provided by the framework. If you created your project via the [skeleton](https://github.com/athena-framework/skeleton) template repository, then everything is ready for use out of the box. Otherwise, ensure that your `spec/spec_helper.cr` file includes the following requires/method calls, in this order: ```crystal require "spec" require "../src/main" # Or whatever the name of your entrypoint file is called require "athena/spec" # ... ASPEC.run_all ``` WARNING: It's important that your main entrypoint file is required _before_ `athena/spec`. ## TestCase At the core is the [Athena::Spec](/Spec) component, with [ASPEC::TestCase](/Spec/TestCase) being the primary type. `ASPEC::TestCase` provides an alternative DSL for creating tests compliant with the stdlib's [Spec](https://crystal-lang.org/api/Spec.html) module. NOTE: `ASPEC::TestCase` is _NOT_ a standalone testing framework, but is fully intended to be mixed with standard `describe`, `it`, and/or `pending` blocks depending on which approach makes the most sense for what is being tested. The primary benefit of this approach is that logic is more easily shared/reused as compared to the normal block based approach. I.e. a component can provide a base test case type that can be inherited from, a few methods implemented, and tada. For example, [AVD::Spec::ConstraintValidatorTestCase](/Validator/Spec/ConstraintValidatorTestCase). ```crystal struct ExampleSpec < ASPEC::TestCase def test_add : Nil (1 + 2).should eq 3 end end ``` TIP: The [ASPEC::TestCase::DataProvider](/Spec/TestCase/DataProvider) and [ASPEC::TestCase::TestWith](/Spec/TestCase/TestWith) annotations can make testing similar code with different inputs super easy! ## Testing Services Testing a type/service is best done in isolation, using mocked versions of its dependencies to ensure that specific type is working as expected. In most cases this can be as simple as defining a private class that includes/implements an interface along with additional inputs for asserting it was called as expected. In other cases, the related component may provide these out of the box, such as: * [AED::Spec::TracableEventDispatcher](/EventDispatcher/Spec/TracableEventDispatcher) For testing types that depend upon a [AED::EventDispatcherInterface](/EventDispatcher/EventDispatcherInterface) * [ACLK::Spec::MockClock](/Clock/Spec/MockClock) For testing time sensitive types * [AVD::Spec::FailingConstraint](/Validator/Spec/FailingConstraint) For testing invalid constraint related logic Checkout the `Spec` namespace of each component in the [API Reference](../api_reference.md) for more examples. ## Testing Controllers While testing a service in isolation is a good starting point; it does not make the most sense for all types of services. A perfect example of this are [ATH::Controller](/Framework/Controller)s. Controllers are best tested in conjunction with the various moving parts that make them function. To make this as easy as possible, the framework provides [ATH::Spec::APITestCase](/Framework/Spec/APITestCase) and provides many helpful `HTTP` related [expectations](/Framework/Spec/Expectations/HTTP). ```crystal require "athena" require "athena/spec" class ExampleController < ATH::Controller @[ARTA::Get("/add/{value1}/{value2}")] def add(value1 : Int32, value2 : Int32, @[ATHA::MapQueryParameter] negative : Bool = false) : Int32 sum = value1 + value2 negative ? -sum : sum end end struct ExampleControllerTest < ATH::Spec::APITestCase def test_add_positive : Nil self.get("/add/5/3").body.should eq "8" end def test_add_negative : Nil self.get("/add/5/3?negative=true").body.should eq "-8" end end # Run all test case tests. ASPEC.run_all ``` ## Testing Commands Similar to controllers, [commands](./commands.md) also have additional moving parts that need to accounted for when testing. The [ACON::Spec::CommandTester](/Console/Spec/CommandTester) type can be used to simplify this: ```crystal describe AddCommand do describe "#execute" do it "without negative option" do tester = ACON::Spec::CommandTester.new AddCommand.new tester.execute value1: 10, value2: 7 tester.display.should eq "The sum of the values is: 17\n" end it "with negative option" do tester = ACON::Spec::CommandTester.new AddCommand.new tester.execute value1: -10, value2: 5, "--negative": nil tester.display.should eq "The sum of the values is: 5\n" end end end ``` ================================================ FILE: docs/getting_started/validation.md ================================================ The [Athena::Validator](/Validator) component adds a robust/flexible validation framework. This component is also mostly optional, but is leveraged for the super useful [ATHR::RequestBody](/Framework/Controller/ValueResolvers/RequestBody) resolver type to ensure only valid data make it into the system. This component can also be used to define validation requirements for [ATH::Params::ParamInterface](/Framework/Params/ParamInterface)s. ## Custom Constraints In addition to the general information for defining [Custom Constraints](/Validator/#Athena::Validator--custom-constraints), the validator component defines a specific type for defining service based constraint validators: `AVD::ServiceConstraintValidator`. This type should be inherited from instead of `AVD::ConstraintValidator` _IF_ the validator for your custom constraint needs to be a service, E.x. ```crystal class Athena::Validator::Constraints::CustomConstraint < AVD::Constraint # ... @[ADI::Register] struct Validator < AVD::ServiceConstraintValidator def initialize(...); end # :inherit: def validate(value : _, constraint : AVD::Constraints::CustomConstraint) : Nil # ... end end end ``` ================================================ FILE: docs/guides/README.md ================================================ This section of the documentation includes various guides for high level features that don't fit anywhere else. ================================================ FILE: docs/guides/proxies.md ================================================ It's usually considered a best practice to run an application behind a reverse proxy or load balancer. For the most part, this doesn't cause any problems with Athena. But, when a request passes through a proxy, certain request information is sent using either the standard `forwarded` header or `x-forwarded-*` headers. For example, instead of reading the request's [`#remote_address`](https://crystal-lang.org/api/HTTP/Request.html#remote_address%3ASocket%3A%3AAddress%7CNil-instance-method) (which will now be the IP address of your reverse proxy), the scheme of the original request will be stored in a standard `Forwarded: proto="..."` header or a `x-forwarded-proto` header. If you don't configure Athena to look for these headers, you'll get incorrect information about the request, such as if the client is connecting via HTTPS, the client's port and the hostname being requested. ## Trusted Proxies To solve this problem, you need to tell Athena which IP addresses belong to proxies you trust, and which headers the proxy uses to send information. This can be accomplished via the [`framework.trusted_proxies`](/Framework/Bundle/Schema/#Athena::Framework::Bundle::Schema#trusted_proxies) and [`framework.trusted_headers`](http://localhost:8000/Framework/Bundle/Schema/#Athena::Framework::Bundle::Schema#trusted_headers) configuration properties respectively. ```crystal ATH.configure({ framework: { # The IP address (or range) of your proxy. trusted_proxies: ["192.0.0.1", "10.0.0.0/8"], # Trust only `x-forwarded-port` and `x-forwarded-proto` headers. trusted_headers: AHTTP::Request::ProxyHeader[:forwarded_port, :forwarded_proto] }, }) ``` DANGER: Enabling the [AHTTP::Request::ProxyHeader::FORWARDED_HOST](/HTTP/Request/ProxyHeader/#Athena::HTTP::Request::ProxyHeader::FORWARDED_HOST) option exposes the application to [HTTP Host header attacks](https://www.skeletonscribe.net/2013/05/practical-http-host-header-attacks.html). Make sure the proxy really sends an `x-forwarded-host` header to avoid client supplied ones being passed through. WARNING: The "trusted proxies" feature does not work as expected when using the [nginx realip module](https://nginx.org/en/docs/http/ngx_http_realip_module.html). Disable that module when serving Athena applications. ## Dynamic IPs Some proxies do not have static IP addresses or even a range that you can target with CIDR notation. In this case, you need to - _very carefully_ - trust _all_ proxies. 1. Ensure your web server(s) do _NOT_ respond to traffic from _ANY_ clients other than your load balancer. 1. Once you're guaranteed that traffic will only come from your trusted proxies, configure Athena to _always_ trust incoming request: ```crystal ATH.configure({ framework: { # The `"REMOTE_ADDRESS"` string will be replaced by the IP address from the request's `#remote_address`. trusted_proxies: ["127.0.0.1", "REMOTE_ADDRESS"], }, }) ``` That's it! It's critical that you prevent traffic from all non-trusted sources. If you allow outside traffic, they could "spoof" their true IP address and other information. ## Custom Headers Some reverse proxies do not use the common `x-forwarded-*` header names and may force you to use a custom header. In such cases you can use the [`framework.trusted_header_overrides`](/Framework/Bundle/Schema/#Athena::Framework::Bundle::Schema#trusted_header_overrides) configuration property to handle this: ```crystal ATH.configure({ framework: { # Tell Athena to look for `cloudfront-forwarded-proto` instead of the default `x-forwarded-proto`. trusted_header_overrides: { :forwarded_proto => "cloudfront-forwarded-proto", }, }, }) ``` ================================================ FILE: docs/index.cr ================================================ # This file is included by each component when building docs. # Can be used to create things in the docs that are common to all components. # Currently it is just used to ensure the top level `Athena` module exists. module Athena; end ================================================ FILE: docs/templates/crystal/material/schema.html ================================================ {% macro render_members(members, obj, heading_level, parent_id, parent_short_name) %} {% for member in members %} {% filter heading(heading_level, id="%s.%s" % (parent_id, member['name']), class="doc schema-heading", toc_label="%s.%s" % (parent_short_name, member['name'])) -%} {{ member['name'] | code_highlight(title='', language="crystal", inline=True) }} {%- endfilter %}
type: {{ member['type'] |convert_markdown_ctx(obj, heading_level+1, obj.abs_id) }}
{% if member['default'] != '``' %} default: {{ member['default'] |convert_markdown_ctx(obj, heading_level+1, obj.abs_id) }} {% else %} Required {% endif %}
{{ member['doc'] | convert_markdown_ctx(obj, heading_level+1, obj.abs_id) }}
{% if 'members' in member %}

This property consists of an object with the following properties:

{{ render_members(member['members'], obj, heading_level+1, "%s.%s" % (parent_id, member['name']), "%s.%s" % (parent_short_name, member['name'])) }}
{% endif %} {% if not loop.last %}
{% endif %} {% endfor %} {% endmacro %} {% for param in (obj.constants['CONFIG_DOCS'].value|from_json) %} {% if loop.first %}

Configuration Properties

{% endif %} {% set obj = obj.instance_methods.__getitem__(param['name']) %}
{% filter heading(heading_level+2, id=obj.abs_id, class="doc schema-heading", toc_label=obj.short_name) -%} {{ param['name'] | code_highlight(title='', language="crystal", inline=True) }} {%- endfilter %}
type: {{ param['type'] |convert_markdown_ctx(obj, heading_level+2, obj.abs_id) }}
{% if param['default'] != '``' %} default: {{ param['default'] |convert_markdown_ctx(obj, heading_level+2, obj.abs_id) }} {% else %} Required {% endif %}
{% if obj.doc %}{{ obj.doc | convert_markdown_ctx(obj, heading_level, obj.abs_id) }}{% endif %}
{% if 'members' in param %}
{% if 'Array' in param['type'] %}

This property consists of an array of objects with the following properties:

{% elif 'Hash' in param['type'] %}

This property consists of a map of key/value pairs where each value has the following properties:

{% else %}

This property consists of an object with the following properties:

{% endif %}
{{ render_members(param['members'], obj, heading_level+3, obj.abs_id, obj.short_name) }}
{% endif %}
{% if not loop.last %}
{% endif %} {% endfor %} ================================================ FILE: docs/templates/crystal/material/type.html ================================================ {{ log.debug() }}
{% if "Athena::DependencyInjection::Extension::Schema" in obj.included_modules %}
{% if obj.parent %} {% filter heading(heading_level, id=obj.abs_id, class="doc doc-heading", toc_label=obj.name) -%} {{ obj.full_name }} {%- endfilter %} {% endif %} {% if obj.doc %}{{ obj.doc |convert_markdown_ctx(obj, heading_level, obj.abs_id) }}{% endif %} {% include "schema.html" with context %}
{% else %} {% if obj.parent %} {% filter heading(heading_level, id=obj.abs_id, class="doc doc-heading", toc_label=obj.name) -%} {% if obj.is_abstract %}abstract {% endif %}{{ obj.kind }} {{ obj.full_name }} {% if obj.superclass %}
inherits {{ obj.superclass |reference }} {% endif %} {%- endfilter %} {% endif %}
{% if obj.doc %}{{ obj.doc |convert_markdown_ctx(obj, heading_level, obj.abs_id) }}{% endif %} {% with root = False, heading_level = heading_level + 1 %}
{% if obj.kind == "alias" %} Alias definition {{ obj.aliased |code_highlight(language="crystal", inline=True) }} {% endif %} {% for title, sub in [ ("Included modules", obj.included_modules), ("Extended modules", obj.extended_modules), ("Direct known subclasses", obj.subclasses), ("Direct including types", obj.including_types), ] %} {% if sub %} {{ title }} {% for other in sub %} {{ other |reference }} {% endfor %} {% endif %} {% endfor %} {% if obj.constants %} {% if obj.kind == "enum" %} {% filter heading(heading_level, id=obj.abs_id ~ "-members") %}Members{% endfilter %} {% else %} {% filter heading(heading_level, id=obj.abs_id ~ "-constants") %}Constants{% endfilter %} {% endif %} {% with heading_level = heading_level + 1 %} {% for obj in obj.constants %} {% include "constant.html" with context %} {% endfor %} {% endwith %} {% endif %} {% for title, sub in [ ("Constructors", obj.constructors), ("Class methods", obj.class_methods), ("Methods", obj.instance_methods), ("Macros", obj.macros), ] %} {% if sub %} {% filter heading(heading_level, id=obj.abs_id ~ "-" ~ title.lower().replace(" ", "-")) %}{{ title }}{% endfilter %} {% with heading_level = heading_level + 1 %} {% for obj in sub %} {% include "method.html" with context %} {% endfor %} {% endwith %} {% endif %} {% endfor %}
{% endwith %}
{% for obj in obj.types %} {% include "type.html" with context %} {% endfor %}
{% endif %} ================================================ FILE: docs/why_athena.md ================================================ ## Creating "good" Software When creating an application, actually writing the code is often the easiest part. Designing a system that will be readable, maintainable, testable, and extensible on the other hand is a much more challenging task. The features of the Athena Framework encourage creating such software. However it does not do much good without also understanding the _why_ behind the way it is designed the way it is. Let's take a moment to explore how the features mentioned in the introduction can lead to "good" software design. WARNING: As with anything in the software world, "good" software is subjective. The design decision/suggestions on this page are intended to be educational and provide "best practices" guidelines. They are _NOT_ the only way to use the framework nor prescriptive. Do whatever makes the most sense for your project. ### SOLID Principles The [SOLID](https://en.wikipedia.org/wiki/SOLID) principles are applicable to any Object Oriented Programming (OOP) language. They play a big part in the underlying architecture of the Athena Framework, and the overall ecosystem of Athena itself. There are plenty of resources online to learn more about all of the principles, but this section will focus on that of the _Dependency Inversion_ and _Single Responsibility_ principles and how an [Inversion of Control (IoC)](https://en.wikipedia.org/wiki/Inversion_of_control) service container orchestrates it all via [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection). #### Single Responsibility Just as the name implies, this principle suggests that each type should have only a single primary purpose. Having types with specialized focuses has various benefits including: * Easier to test * Less coupling due to lower amount of dependencies it requires * Easier to read and search for A more concrete example of this could be say there is a class representing an article: ```crystal class Article property title : String property author : String property body : String def initialize(@title : String, @author : String, @body : String); end def includes_word?(word : String) : Bool @body.includes? word end # ... end ``` This type currently only has a single purpose which is representing an article. It also exposes some helper methods related to querying information about each article which are also valid under this principle. However, if a new method was added to persist the article to some location, the class would now no longer have just one purpose, thus violating the single responsibility principle. In this example, it would be better to add _another_ type, say `ArticlePersister` to handle this functionality: ```crystal @[ADI::Register] class ArticlePersister def persist(article : Article) : Nil # ... end end ``` ##### Services A sharp eye will notice this type was created with the [ADI::Register](/DependencyInjection/Register/) annotation applied to it. This registers the type as a service, which is essentially just a useful object that could be used by other services. Not all types are services though, such as the `Article` type. This is because it only stores data within the domain of the application and does not provide any useful functionality on its own. More on this topic in the [dependency injection](#dependency-injection) section. #### Dependency Inversion This principle states that code should "Depend upon abstractions, [not] concretions." In other words, services should depend upon interfaces instead of concrete types. This not only makes the depending services more flexible since different implementations of the interface could be used, but also makes testing easier since mock implementations could also be used. In Crystal, an interface is nothing more than a module with abstract defs that can be included within another type in order to force the including type to define its methods.The example from the previous principle can be used to demonstrate. The `ArticlePersister` can be used to persist an article. For example say there is another service in which an article should be persisted. This could be a controller action, a console command, some sort of async consumer, etc. The easiest way to handle persisting of the article would be to do something like: ```crystal @[ADI::Register] class MyService def execute article = # ... persister = ArticlePersister.new persister.persist article end end ``` However this has some problems since it tightly couples `MyService` to the `ArticlePersister` service. Not super ideal. ```crystal def initialize @persister = ArticlePersister.new end ``` Moving the persister into an instance variable created within the constructor is a bit better but also suffers from the same issue. The ideal solution here would be to provide an `ArticlePersister` instance to `MyService` when it is instantiated: ```crystal def initialize( @persister : ArticlePersister ); end ``` The same behavior as before can also be retained, even when using this new pattern. This will use the provided instance, or fall back on a default implementation if no custom instance is provided: ```crystal def initialize( persister : ArticlePersister? = nil ) @persister = persister || ArticlePersister.new end ``` Both of these latter two examples remove the tight coupling between the two services. However there is still one thing that is less than ideal. It should be possible to persist an article in multiple places. Meaning it needs to allow for more than one implementation of `ArticlePersister` that handles different locations, such as one for a database and another for the local filesystem. The best way to handle this would be to create an interface module for this type: ```crystal module ArticlePersisterInterface abstract def persist(article : Article) : Nil end ``` From here the constructor of `MyService` should be updated to be: ```crystal def initialize( @persister : ArticlePersisterInterface ); end ``` Additionally, a [`@[ADI::AsAlias]`](https://athenaframework.org/DependencyInjection/AsAlias/) must be applied to the class. Also be sure to include the interface within `ArticlePersister`: ```crystal @[ADI::Register] class ArticlePersister include ArticlePersisterInterface def persist(article : Article) : Nil # ... end end ``` While this is a bit of extra boilerplate, it is an incredibly powerful pattern. It enables `MyService` to persist an article to anywhere, depending on what implementation instance it is instantiated with. The same pattern can be extended to make testing the service much easier. A mock implementation of `ArticlePersisterInterface` can be used to assert `MyService` calls with the proper arguments without testing more than is required. ### Flexibility Athena Framework is very flexible in that it is able to support both simple and complex use cases by adapting to the needs of the application without getting in the way of customizations the user wants to make. This is accomplished by providing all the components to the user, but not requiring they be used. If an application does not need to validate anything, the [Athena::Validator](/Validator/) component can just be ignored. But if the need ever arises it is there and well integrated into the framework. #### Dependency Injection Athena Framework includes an IoC Service Container that manages services automatically. Any service, or a useful type, annotated with [ADI::Register](/DependencyInjection/Register/), can be used in another service by defining a constructor typed to the desired service. For example: ```crystal require "athena" # Register an example service that provides a name string. @[ADI::Register] class NameProvider def name : String "World" end end # Register another service that depends on the previous service and provides a value. @[ADI::Register] class ValueProvider def initialize(@name_provider : NameProvider); end def value : String "Hello " + @name_provider.name end end # Register a service controller that depends upon the ValueProvider. @[ADI::Register] class ExampleController < ATH::Controller def initialize(@value_provider : ValueProvider); end @[ARTA::Get("/")] def get_value : String @value_provider.value end end ATH.run # GET / # => "Hello World" ``` It is worth noting again that while dependency injection is a big part of the framework, it is not necessarily required to fully understand it in order to use the framework, but like the other components, it is there if needed. Checkout [ADI::Register](/DependencyInjection/Register/), especially the [aliasing services](/DependencyInjection/Register/#Athena::DependencyInjection::Register--aliasing-services) section. Athena Framework is almost fully overridable/customizable in part since it embraces dependency injection. Want to globally customize how errors are rendered? Create a service implementing [AHK::ErrorRendererInterface](/HTTPKernel/ErrorRendererInterface/) and make it an alias of the interface: ```crystal @[ADI::Register] @[ADI::AsAlias] # Defaults to first included module ending in `Interface` class MyCustomErrorRenderer include AHK::ErrorRendererInterface # :inherit: def render(exception : ::Exception) : AHTTP::Response AHTTP::Response.new ... end end ``` Athena Framework will pick this up and use it instead of the built in version without any other required configuration changes. The same concept applies to many different features within the framework that have their own interface/default implementation. #### Middleware Unlike other frameworks, Athena Framework leverages event based middleware instead of a pipeline based approach. This enables a lot of flexibility in that there is nothing extra that needs to be done to register the listener other than creating a service for it: ```crystal @[ADI::Register] class CustomListener @[AEDA::AsEventListener] def on_response(event : AHK::Events::Response) : Nil event.response.headers["FOO"] = "BAR" end end ``` Similarly, the framework itself is implemented using the same features available to the users. Thus it is very easy to run specific listeners before/after the built-in ones if so desired. TIP: Check out the `debug:event-dispatcher` command for an easy way to see all the listeners and the order in which they are executed. ### Annotations One of the more unique aspects of Athena Framework, and the Athena ecosystem, is its use of [annotations](https://crystal-lang.org/reference/syntax_and_semantics/annotations/index.html) as a means of configuring the framework. While not everyone may like their syntax, the benefits they provide are undeniable. The main benefit being they keep the code close to where it is used. The route of a controller action is declared directly above the method that handles it and not in some other file. Metadata associated with a specific service/route is also right there with the type itself. #### Point of Extension A common way to do certain things in other frameworks is the use of macro DSLs specific to each framework. While it can work well, it makes it harder to expand upon/customize. Given annotations are a core Crystal language construct, there nothing special needed to access the annotations themselves. This can be especially useful for third party code to have a tighter integration while also being totally agnostic of what framework the code is even used in. #### User Defined Annotations One of the most powerful features Athena Framework offers is that of custom user defined annotations which provide almost an infinite amount of use cases. These annotations could be applied to controller classes and/or controller actions to expose additional information to other services, such as event listeners or [ATHR::Interfaces](/Framework/Controller/ValueResolvers/Interface/) to customize their behavior on a case by case basis. ```crystal require "athena" # Define our configuration annotation with an optional `name` argument. # A default value can also be provided, or made not nilable to be considered required. ADI.configuration_annotation MyAnnotation, name : String? = nil # Define and register our listener that will do something based on our annotation. @[ADI::Register] class MyAnnotationListener def initialize( @annotation_resolver : ATH::AnnotationResolver, ); end @[AEDA::AsEventListener] def on_view(event : AHK::Events::View) : Nil # Represents all custom annotations applied to the current AHK::Action + controller class. ann_configs = @annotation_resolver.action_annotations(event.request) # Check if this action has the annotation unless ann_configs.has? MyAnnotation # Do something based on presence/absence of it. # Would be executed for `ExampleController#one` since it does not have the annotation applied. end my_ann = ann_configs[MyAnnotation] # Access data off the annotation. if my_ann.name == "Fred" # Do something if the provided name is/is not some value. # Would be executed for `ExampleController#two` since it has the annotation applied, and name value equal to "Fred". end end end class ExampleController < ATH::Controller @[ARTA::Get("one")] def one : Int32 1 end @[ARTA::Get("two")] @[MyAnnotation(name: "Fred")] def two : Int32 2 end end ATH.run ``` ## Primary Use Cases While the components that make up Athena Framework can be used within a wide range of applications, the framework itself is best suited for a few main types, including HTTP REST APIs, CLI Applications, or a combination of both. Since both types of entry points leverage dependency injection, services can be used in both contexts, allowing the majority of code to be reused. ### HTTP REST API At its core, Athena Framework is a MVC web application framework. It can be used to serve any kind of content, but best lends itself to creating RESTful JSON APIs due to the features explained in the previous section, as well as due its native JSON support: * Objects returned from the controller are JSON serialized by default * Native support for both [ASR::Serializable](/Serializer/Serializable) and [JSON::Serializable](https://crystal-lang.org/api/JSON/Serializable.html) * Native support for DTOs to deserialize and validate, see [ATHR::RequestBody](/Framework/Controller/ValueResolvers/RequestBody/) ```crystal require "athena" struct UserCreate include AVD::Validatable include JSON::Serializable @[Assert::NotBlank] @[Assert::Email(:html5)] getter email : String # ... end class UserController < ATH::Controller @[ARTA::Post("/user")] @[ATHA::View(status: :created)] def new_user( @[ATHA::MapRequestBody] user_create : UserCreate ) : UserCreate # Use the provided UserCreate instance to create an actual User DB record. # For purposes of this example, just return the instance. user_create end end ATH.run # POST /user body: {"email":"athenaframework.org"} # => # { # "code": 422, # "message": "Validation failed", # "errors": [ # { # "property": "email", # "message": "This value is not a valid email address.", # "code": "ad9d877d-9ad1-4dd7-b77b-e419934e5910" # } # ] # } # POST /user body: {"email":"contact@athenaframework.org"} # => {"email":"contact@athenaframework.org"} ``` ### CLI Applications Athena Framework can also be used to build CLI based applications. These could either be used directly by the end user, used for internal administrative tasks, or invoked on a schedule via `cron` or something similar. ```crystal @[ACONA::AsCommand("app:create-user")] @[ADI::Register] class CreateUserCommand < ACON::Command protected def configure : Nil # ... end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # Implement all the business logic here. # Indicates the command executed successfully. Status::SUCCESS end end ``` ```shell $ ./bin/console Athena 0.18.0 Usage: command [options] [arguments] Options: -h, --help Display help for the given command. When no command is given display help for the list command -q, --quiet Do not output any message -V, --version Display this application version --ansi|--no-ansi Force (or disable --no-ansi) ANSI output -n, --no-interaction Do not ask any interactive question -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Available commands: help Display help for a command list List commands app app:create-user debug debug:event-dispatcher Display configured listeners for an application debug:router Display current routes for an application debug:router:match Simulate a path match to see which route, if any, would handle it ``` Checkout the [Console](/Console/) component for more information. ================================================ FILE: gen_doc_stubs.py ================================================ # Generates virtual doc files for the mkdocs site. # You can also run this script directly to actually write out those files, as a preview. import json from typing import Any import markdown as md import mkdocs_gen_files handler = mkdocs_gen_files.config["plugins"]["mkdocstrings"].get_handler("crystal") # get the `update_env` method of the handler update_env = handler.update_env # override the `update_env` method of the handler def patched_update_env(config: dict[str, Any]) -> None: update_env(config) def from_json(data): return json.loads(data.removesuffix("of Nil")) # patch the filter handler.env.filters["from_json"] = from_json # patch the method handler.update_env = patched_update_env root = handler.collector.root # Determine which namespace this project owns based on site_url site_url = mkdocs_gen_files.config.get("site_url", "") local_ns = site_url.rstrip("/").rsplit("/", 1)[-1] if site_url else None # Base URL for cross-project links (e.g. "https://athenaframework.org/") base_url = site_url.rstrip("/").rsplit("/", 1)[0] + "/" if site_url else "" # Get autorefs plugin for registering external type URLs autorefs = mkdocs_gen_files.config["plugins"].get("autorefs") # Source path prefixes for filtering local vs external aliases source_prefixes = [dest.src_path for dest in root.source_locations] for type in root.lookup("Athena").walk_types(): parts = type.abs_id.split("::") type_ns = parts[1] if len(parts) > 1 else None if local_ns and type_ns and type_ns != local_ns and autorefs: # External type: register full URL so autorefs treats it as external external_url = base_url + "/".join(parts[1:]) + "/" autorefs.register_url(type.abs_id, external_url) continue # Athena::Validator::Violation -> Validator/Violation/index.md filename = "/".join(parts[2:] + ["index.md"]) # Rename the root `index.md` to `top_level.md` so that the user lands on the introduction page instead of the root component module docs. # But only do this for non-framework components as the site itself is the contextual docs for the framework. if type.full_name != "Athena::Framework" and filename == "index.md": filename = "top_level.md" with mkdocs_gen_files.open(filename, "w") as f: f.write(f"# ::: {type.abs_id}\n\n") if type.locations: mkdocs_gen_files.set_edit_path(filename, type.locations[0].url) for type in root.types: # Write the entry of a top-level alias (e.g. `AED`) to its appropriate section. if type.kind == "alias": # Only write aliases whose source is local to this project if source_prefixes and type.locations: is_local = any( loc.filename.startswith(prefix) for loc in type.locations for prefix in source_prefixes ) if not is_local: continue # Athena::Validator::Annotations -> Validator/aliases.md with mkdocs_gen_files.open("aliases.md", "a") as f: f.write(f"::: {type.abs_id}\n\n") ================================================ FILE: justfile ================================================ # Configuration OUTPUT_DIR := './site' # Binaries # Scoped to the justfile so do not need to be exported UV := 'uv' # Needs to be exported so that the `spec` component can pick up on the customized $CRYSTAL env var. export CRYSTAL := 'crystal' _default: @just --list --unsorted # Installs Crystal shard dependencies [group('dev')] install: SHARDS_OVERRIDE=shard.dev.yml shards update # Run shard entrypoint with live reload [group('dev')] watch shard type='component': watchexec --restart --watch=src/ --emit-events-to=none --clear --no-project-ignore -- {{ CRYSTAL }} run src/{{ type }}s/{{ shard }}/src/{{ if shard == 'framework' { 'athena' } else { 'athena-' + shard } }}{{ if type == 'bundle' { '_bundle' } else { '' } }}.cr # Run tests with live reload [group('dev')] watch-test shard type='component': watchexec --restart --watch=src/ --emit-events-to=none --clear --no-project-ignore -- {{ CRYSTAL }} spec src/{{ type }}s/{{ shard }}/ # Run test suite; `type` is ignored when running test suite for all shards [group('dev')] test shard='all' type='component': ./scripts/test.sh {{ shard }} all {{ type }} # Run unit tests only; `type` is ignored when running test suite for all shards [group('dev')] test-unit shard='all' type='component': ./scripts/test.sh {{ shard }} unit {{ type }} # Run compiled tests only; `type` is ignored when running test suite for all shards [group('dev')] test-compiled shard='all' type='component': ./scripts/test.sh {{ shard }} compiled {{ type }} # Run all linters (format + ameba + spellcheck) [group('check')] lint: spellcheck format ameba # Check Crystal formatting [group('check')] format: {{ CRYSTAL }} tool format --check # Fix Crystal formatting issues [group('check')] format-fix: {{ CRYSTAL }} tool format # Run Ameba static analysis [group('check')] ameba: ./bin/ameba # Run typos spellchecker [group('check')] spellcheck: typos # Build the docs [group('docs')] build-docs: _symlink_lib DISABLE_MKDOCS_2_WARNING=true NO_MKDOCS_2_WARNING=1 {{ UV }} run --frozen mkdocs build -d {{ OUTPUT_DIR }} # Serve live-preview of the docs [group('docs')] serve-docs: _symlink_lib DISABLE_MKDOCS_2_WARNING=true NO_MKDOCS_2_WARNING=1 {{ UV }} run --frozen mkdocs serve --livereload # Clean docs build artifacts [group('docs')] clean-docs: rm -rf {{ OUTPUT_DIR }} find src/ -type d -name "site" -exec rm -rf {} + # Create a new change file [group('administrative')] change: changie new # Batch change files into a version changelog [group('administrative')] batch project version='patch': changie batch --project {{ project }} {{ version }} # Merge pending changelogs into CHANGELOG.md [group('administrative')] merge: changie merge # Upgrade Python dependencies [group('administrative')] upgrade: {{ UV }} lock --upgrade # Clean build artifacts and docs [group('administrative')] clean: clean-docs rm -rf .venv _symlink_lib: @ for shardDir in $(find -L src/ -maxdepth 3 -type f -name shard.yml | xargs -I{} dirname {} | sort); do \ ln --force --verbose --symbolic {{ (invocation_directory_native() / 'lib') }} "$shardDir/lib"; \ done ================================================ FILE: mkdocs-common.yml ================================================ theme: name: material palette: - media: '(prefers-color-scheme: light)' scheme: default primary: black accent: red toggle: icon: material/weather-sunny name: Switch to dark theme - media: '(prefers-color-scheme: dark)' scheme: slate primary: black accent: red toggle: icon: material/weather-night name: Switch to light theme features: - navigation.sections - navigation.instant - content.code.copy use_directory_urls: true strict: false validation: omitted_files: warn # TODO: Make this `relative_to_docs` when/if mkdocs-material projects plugin supports it absolute_links: ignore unrecognized_links: warn not_found: warn anchors: warn extra_css: - ../../../css/index.css watch: - src/ - ../../../mkdocs-common.yml - ../../../docs/templates extra: homepage: / markdown_extensions: - admonition - callouts - pymdownx.highlight - pymdownx.magiclink - pymdownx.saneheaders - pymdownx.superfences: custom_fences: - name: mermaid class: mermaid format: !!python/name:pymdownx.superfences.fence_code_format - deduplicate-toc - toc: permalink: '#' ================================================ FILE: mkdocs.yml ================================================ INHERIT: ./mkdocs-common.yml site_name: Athena site_url: https://athenaframework.org/ repo_url: https://github.com/athena-framework/athena extra_css: - css/index.css - css/monorepo.css plugins: - search - section-index - projects: projects_dir: src/components watch: - ./mkdocs-common.yml nav: - Introduction: README.md - Why Athena: why_athena.md - Getting Started: - getting_started/README.md - Routing & HTTP: getting_started/routing.md - Configuration: getting_started/configuration.md - Middleware: getting_started/middleware.md - Error Handling: getting_started/error_handling.md - Commands: getting_started/commands.md - Validation: getting_started/validation.md - Testing: getting_started/testing.md - Guides: - guides/README.md - Proxies & Load Balancers: guides/proxies.md - Bundle Reference: - bundle_reference.md - project://mercure-bundle - API Reference: - api_reference.md - project://clock - project://console - project://contracts - project://dependency_injection - project://dotenv - project://event_dispatcher - project://framework - project://http - project://http_kernel - project://image_size - project://mercure - project://mime - project://negotiation - project://routing - project://serializer - project://spec - project://validator extra: social: - icon: fontawesome/brands/github link: https://github.com/athena-framework - icon: fontawesome/brands/discord link: https://discord.gg/TmDVPb3dmr ================================================ FILE: pyproject.toml ================================================ [project] name = "athena-docs" version = "0.0.0" description = "Documentation build dependencies for the Athena ecosystem" requires-python = ">=3.13" license = {file = "LICENSE"} classifiers = [ "Private :: Do Not Upload", ] dependencies = [ "mkdocs~=1.6.1", "mkdocs-material~=9.7.0", "mkdocstrings-crystal~=0.3.9", "mkdocs-gen-files~=0.6.1", "mkdocs-literate-nav~=0.6.2", "mkdocs-section-index>=0.3.10", ] [tool.uv] required-version = "~=0.10.0" ================================================ FILE: scripts/test.sh ================================================ #!/usr/bin/env bash # $1 shard name # $2 shard type function runSpecs() ( set -e $CRYSTAL spec "${DEFAULT_BUILD_OPTIONS[@]}" "${DEFAULT_OPTIONS[@]}" "src/$2/$1/spec" ) # Runtime coverage generation logic based on https://hannes.kaeufler.net/posts/measuring-code-coverage-in-crystal-with-kcov. # Additionally generates a coverage report for unreachable code. # # Compiled time code generates a macro code coverage report for the entire shard, and each compiled sub-process spec. # # $1 shard name # $2 shard type function runSpecsWithCoverage() ( set -e SHARD_NAME=$1 SHARD_TYPE=$2 SHARD_PATH="$SHARD_TYPE/$SHARD_NAME" COVERAGE_BIN_PATH="./coverage/$SHARD_TYPE/bin/$SHARD_NAME" rm -rf "./coverage/$SHARD_PATH" mkdir -p "./coverage/$SHARD_PATH" "./coverage/$SHARD_TYPE/bin" # Build spec binary that covers entire `spec/` directory to run coverage against. echo "require \"../../../src/$SHARD_PATH/spec/**\"" > "$COVERAGE_BIN_PATH.cr" && \ $CRYSTAL build "${DEFAULT_BUILD_OPTIONS[@]}" "$COVERAGE_BIN_PATH.cr" -o "$COVERAGE_BIN_PATH" && \ ATHENA_SPEC_COVERAGE_OUTPUT_DIR="$(realpath ./coverage/$SHARD_PATH/)" \ kcov $(if $IS_CI != "true"; then echo "--cobertura-only"; fi) \ --clean \ --include-path="./src/$SHARD_PATH"\ "./coverage/$SHARD_PATH"\ "$COVERAGE_BIN_PATH"\ --junit_output="./coverage/$SHARD_PATH/junit.xml"\ "${DEFAULT_OPTIONS[@]}" if [ "$SPEC_TYPE" != "unit" ] then # Generate macro coverage report. # The report itself is sent to STDOUT while other output is sent to STDERR. # We can ignore STDERR since those failures would be captured as part of running the specs themselves. $CRYSTAL tool macro_code_coverage --no-color "$COVERAGE_BIN_PATH.cr" > "./coverage/$SHARD_PATH/macro_coverage.root.codecov.json" fi # Only runtime code can be unreachable. if [ "$SPEC_TYPE" != "compiled" ] then $CRYSTAL tool unreachable --no-color --format=codecov "$COVERAGE_BIN_PATH.cr" > "./coverage/$SHARD_PATH/unreachable.codecov.json" fi ) DEFAULT_BUILD_OPTIONS=(-Dstrict_multi_assign -Dpreview_overload_order --error-on-warnings) DEFAULT_OPTIONS=(--order=random) CRYSTAL=${CRYSTAL:=crystal} HAS_KCOV=$(if command -v "kcov" &>/dev/null; then echo "true"; else echo "false"; fi) IS_CI=${CI:="false"} # Runs the specs for all, or optionally a single shard. # Optionally generates code coverage report data as well. # # $1 - (optional) shard name to runs specs for, or "all". Defaults to "all". # $2 - (optional) "type" of specs to run: "unit", "compiled", or "all". Defaults to "all". # $3 - (optional) "type" of the shard: "component", "bundle". Defaults to "component". SHARD=${1-all} SPEC_TYPE=${2-all} SHARD_TYPE=${3-component}s if [ "$SPEC_TYPE" == "unit" ] then DEFAULT_OPTIONS+=("--tag=~compiled") elif [ "$SPEC_TYPE" == "compiled" ] then DEFAULT_OPTIONS+=("--tag=compiled") elif [ "$SPEC_TYPE" != "all" ] then echo "Second argument must be 'unit', 'compiled', or 'all' got '${2}'." exit 1 fi EXIT_CODE=0 if [ "$SHARD" != "all" ] then if [ "$HAS_KCOV" = "true" ] then runSpecsWithCoverage "$SHARD" "$SHARD_TYPE" else runSpecs "$SHARD" "$SHARD_TYPE" fi exit $? fi # If we got this far we need to run specs for all shards, so cannot just rely on `$SHARD_TYPE` for shardPath in $(find src/ -maxdepth 3 -type f -name shard.yml | xargs -I{} dirname {} | sed 's|^src/||' | sort); do type=${shardPath%/*} name=${shardPath#*/} echo "::group::$shardPath" if [ "$HAS_KCOV" = "true" ] then runSpecsWithCoverage "$name" "$type" else runSpecs "$name" "$type" fi if [ $? -eq 1 ]; then EXIT_CODE=1 fi echo "::endgroup::" done exit $EXIT_CODE ================================================ FILE: shard.dev.yml ================================================ # $ SHARDS_OVERRIDE=shard.dev.yml shards update dependencies: athena: path: ./src/components/framework athena-clock: path: ./src/components/clock athena-console: path: ./src/components/console athena-contracts: path: ./src/components/contracts athena-dependency_injection: path: ./src/components/dependency_injection athena-dotenv: path: ./src/components/dotenv athena-event_dispatcher: path: ./src/components/event_dispatcher athena-http: path: ./src/components/http athena-http_kernel: path: ./src/components/http_kernel athena-image_size: path: ./src/components/image_size athena-mercure: path: ./src/components/mercure athena-mercure_bundle: path: ./src/bundles/mercure athena-mime: path: ./src/components/mime athena-negotiation: path: ./src/components/negotiation athena-routing: path: ./src/components/routing athena-serializer: path: ./src/components/serializer athena-spec: path: ./src/components/spec athena-validator: path: ./src/components/validator ================================================ FILE: shard.prod.yml ================================================ # Used for prod builds of the docs to ensure API docs can be updated w/o a dedicated release # $ SHARDS_OVERRIDE=shard.prod.yml shards update dependencies: athena: github: athena-framework/framework branch: docs athena-clock: github: athena-framework/clock branch: docs athena-console: github: athena-framework/console branch: docs athena-contracts: github: athena-framework/contracts branch: docs athena-dependency_injection: github: athena-framework/dependency-injection branch: docs athena-dotenv: github: athena-framework/dotenv branch: docs athena-event_dispatcher: github: athena-framework/event-dispatcher branch: docs athena-http: github: athena-framework/http branch: docs athena-http_kernel: github: athena-framework/http-kernel branch: docs athena-image_size: github: athena-framework/image-size branch: docs athena-mercure: github: athena-framework/mercure branch: docs athena-mercure_bundle: github: athena-framework/mercure-bundle branch: docs athena-mime: github: athena-framework/mime branch: docs athena-negotiation: github: athena-framework/negotiation branch: docs athena-routing: github: athena-framework/routing branch: docs athena-serializer: github: athena-framework/serializer branch: docs athena-spec: github: athena-framework/spec branch: docs athena-validator: github: athena-framework/validator branch: docs ================================================ FILE: shard.yml ================================================ name: athena-ecosystem version: 0.0.0 # Min Crystal version required to run the test suite/develop on Athena. crystal: ~> 1.17 license: MIT repository: https://github.com/athena-framework/athena documentation: https://athenaframework.org description: | An ecosystem of reusable, independent components. authors: - George Dietrich dependencies: athena: github: athena-framework/framework athena-clock: github: athena-framework/clock athena-console: github: athena-framework/console athena-contracts: github: athena-framework/contracts athena-dependency_injection: github: athena-framework/dependency-injection athena-dotenv: github: athena-framework/dotenv athena-event_dispatcher: github: athena-framework/event-dispatcher athena-http: github: athena-framework/http athena-http_kernel: github: athena-framework/http-kernel athena-image_size: github: athena-framework/image-size athena-mercure: github: athena-framework/mercure athena-mercure_bundle: github: athena-framework/mercure-bundle athena-mime: github: athena-framework/mime athena-negotiation: github: athena-framework/negotiation athena-routing: github: athena-framework/routing athena-serializer: github: athena-framework/serializer athena-spec: github: athena-framework/spec athena-validator: github: athena-framework/validator development_dependencies: ameba: github: crystal-ameba/ameba version: ~> 1.6.3 athena-spec: github: athena-framework/spec version: ~> 0.4.0 ================================================ FILE: src/bundles/mercure/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/bundles/mercure/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/bundles/mercure/CHANGELOG.md ================================================ # Changelog ## [0.1.0] - 2026-04-19 _Initial release._ [0.1.0]: https://github.com/athena-framework/mercure-bundle/releases/tag/v0.1.0 ================================================ FILE: src/bundles/mercure/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/bundles/mercure/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2026 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/bundles/mercure/README.md ================================================ # MercureBundle [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/workflows/CI/badge.svg)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/mercure-bundle.svg)](https://github.com/athena-framework/mercure-bundle/releases) Integrates the Athena Mercure component into the framework. ## Getting Started Checkout the [Documentation](https://athenaframework.org/MercureBundle). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/bundles/mercure/shard.yml ================================================ name: athena-mercure_bundle version: 0.1.0 crystal: ~> 1.19 license: MIT repository: https://github.com/athena-framework/mercure-bundle documentation: https://athenaframework.org/MercureBundle description: | Integrates the Athena Mercure component into the framework authors: - George Dietrich dependencies: athena-dependency_injection: github: athena-framework/dependency-injection version: ~> 0.4.0 athena-http_kernel: github: athena-framework/http-kernel version: ~> 0.1.0 athena-mercure: github: athena-framework/mercure version: ~> 0.1.0 ================================================ FILE: src/bundles/mercure/spec/authorization_spec.cr ================================================ require "./spec_helper" struct BundleAuthorizationTest < ASPEC::TestCase def test_set_cookie : Nil token_factory = AMC::Spec::AssertingTokenFactory.new( "JWT", ["foo"], ["bar"], {"x-foo" => "baz"}, ) authorization = ABM::Authorization.new new_hub_registry(token_factory: token_factory) request = new_request(headers: ::HTTP::Headers{"host" => "example.com"}) authorization.set_cookie request, ["foo"], ["bar"], {"x-foo" => "baz"} token_factory.called?.should be_true cookies = request.attributes.get?("_mercure_authorization_cookies", Hash(String, ::HTTP::Cookie)).should_not be_nil cookies[""].value.should_not be_empty end def test_set_cookie_stores_in_request_attributes : Nil authorization = ABM::Authorization.new new_hub_registry request = new_request(headers: ::HTTP::Headers{"host" => "example.com"}) authorization.set_cookie request cookies = request.attributes.get?("_mercure_authorization_cookies", Hash(String, ::HTTP::Cookie)).should_not be_nil cookies.size.should eq 1 end def test_set_cookie_prevents_duplicate : Nil authorization = ABM::Authorization.new new_hub_registry request = new_request(headers: ::HTTP::Headers{"host" => "example.com"}) expect_raises AMC::Exception::Runtime, "The 'mercureAuthorization' cookie for the 'default hub' has already been set." do authorization.set_cookie request authorization.set_cookie request end end def test_clear_cookie : Nil authorization = ABM::Authorization.new new_hub_registry request = new_request(headers: ::HTTP::Headers{"host" => "example.com"}) authorization.clear_cookie request cookies = request.attributes.get?("_mercure_authorization_cookies", Hash(String, ::HTTP::Cookie)).should_not be_nil cookies[""].value.should be_empty end def test_create_cookie : Nil authorization = ABM::Authorization.new new_hub_registry request = new_request(headers: ::HTTP::Headers{"host" => "example.com"}) cookie = authorization.create_cookie request cookie.name.should eq "mercureAuthorization" cookie.value.should_not be_empty end end ================================================ FILE: src/bundles/mercure/spec/bundle_spec.cr ================================================ require "./spec_helper" private def assert_compiles(code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compiles code, line: line, preamble: <<-'CR' require "./spec_helper.cr" @[ADI::Register(public: true)] class MercureConsumer def initialize( @hub : AMC::Hub::Interface, @authorization : ABM::Authorization, @discovery : ABM::Discovery, ); end end CR end describe ABM, tags: "compiled" do it "registers services for a hub with a jwt secret" do assert_compiles <<-'CR' ADI.configure({ mercure: { hubs: { default: { url: "https://hub.example.com/.well-known/mercure", jwt: { secret: "looooooooooooongenoughtestsecret", publish: ["*"], subscribe: ["https://example.com/books/{id}"], }, }, }, }, }) macro finished macro finished \{% sh = ADI::ServiceContainer::SERVICE_HASH # Hub service hub = sh["mercure_hub_default"] params = hub["parameters"] # JWT factory service factory = sh["mercure_hub_default_jwt_factory"] # Token provider service (factory-based) provider = sh["mercure_hub_default_jwt_provider"] %} ASPEC.compile_time_assert(\{{ hub["class"].resolve == Athena::Mercure::Hub }}, "Expected hub class to be Athena::Mercure::Hub") ASPEC.compile_time_assert(\{{ params["url"]["value"] == "https://hub.example.com/.well-known/mercure" }}, "Expected hub url to match configuration") # Token factory should be set ASPEC.compile_time_assert(\{{ params["token_factory"]["value"] != nil }}, "Expected token_factory to be set") ASPEC.compile_time_assert(\{{ factory["class"].resolve == Athena::Mercure::TokenFactory::JWT }}, "Expected factory class to be Athena::Mercure::TokenFactory::JWT") ASPEC.compile_time_assert(\{{ provider["class"].resolve == Athena::Mercure::TokenProvider::Factory }}, "Expected provider class to be Athena::Mercure::TokenProvider::Factory") # Hub registry ASPEC.compile_time_assert(\{{ sh["mercure_hub_registry"]["class"].resolve == Athena::Mercure::Hub::Registry }}, "Expected registry class to be Athena::Mercure::Hub::Registry") # Authorization ASPEC.compile_time_assert(\{{ sh["mercure_authorization"]["class"].resolve == Athena::MercureBundle::Authorization }}, "Expected auth class to be Athena::MercureBundle::Authorization") # Discovery ASPEC.compile_time_assert(\{{ sh["mercure_discovery"]["class"].resolve == Athena::MercureBundle::Discovery }}, "Expected discovery class to be Athena::MercureBundle::Discovery") end end CR end it "registers services for a hub with a static jwt value" do assert_compiles <<-'CR' ADI.configure({ mercure: { hubs: { default: { url: "https://hub.example.com/.well-known/mercure", jwt: { value: "eyJhbGciOiJIUzI1NiJ9.static-token", }, }, }, }, }) macro finished macro finished \{% sh = ADI::ServiceContainer::SERVICE_HASH hub = sh["mercure_hub_default"] params = hub["parameters"] # Static token provider provider = sh["mercure_hub_default_jwt_provider"] %} # Token factory should be nil for static token ASPEC.compile_time_assert(\{{ params["token_factory"]["value"] == nil.id }}, "Expected token_factory to be nil") ASPEC.compile_time_assert(\{{ provider["class"].resolve == Athena::Mercure::TokenProvider::Static }}, "Expected provider class to be Athena::Mercure::TokenProvider::Static") end end CR end it "uses the first hub as default when default_hub is not set" do assert_compiles <<-'CR' ADI.configure({ mercure: { hubs: { my_hub: { url: "https://hub.example.com/.well-known/mercure", jwt: { secret: "looooooooooooongenoughtestsecret", }, }, }, }, }) macro finished macro finished \{% default_hub = ADI::ServiceContainer::SERVICE_HASH["mercure_hub_registry"]["parameters"]["default_hub"]["value"] %} ASPEC.compile_time_assert(\{{ default_hub.stringify =~ /mercure_hub_my_hub/ }}, "Expected default hub to be my_hub") end end CR end it "respects explicit default_hub setting" do assert_compiles <<-'CR' ADI.configure({ mercure: { hubs: { first: { url: "https://first.example.com/.well-known/mercure", jwt: { secret: "looooooooooooongenoughtestsecret", }, }, second: { url: "https://second.example.com/.well-known/mercure", jwt: { secret: "looooooooooooongenoughtestsecret", }, }, }, default_hub: "second", }, }) macro finished macro finished \{% default_hub = ADI::ServiceContainer::SERVICE_HASH["mercure_hub_registry"]["parameters"]["default_hub"]["value"] %} ASPEC.compile_time_assert(\{{ default_hub.stringify =~ /mercure_hub_second/ }}, "Expected default hub to be second") end end CR end it "retains named aliases for all hubs in a multi-hub configuration" do assert_compiles <<-'CR' ADI.configure({ mercure: { hubs: { first: { url: "https://first.example.com/.well-known/mercure", jwt: { secret: "looooooooooooongenoughtestsecret", }, }, second: { url: "https://second.example.com/.well-known/mercure", jwt: { secret: "looooooooooooongenoughtestsecret", }, }, }, }, }) macro finished macro finished \{% aliases = ADI::ServiceContainer::ALIASES[Athena::Mercure::Hub::Interface] # Named aliases for both hubs should be present, plus the unnamed default first_alias = aliases.find { |a| a["name"].id == "first" } second_alias = aliases.find { |a| a["name"].id == "second" } default_alias = aliases.find { |a| a["name"] == nil } %} ASPEC.compile_time_assert(\{{ first_alias["id"].stringify =~ /mercure_hub_first/ }}, "Expected first alias id to match mercure_hub_first") ASPEC.compile_time_assert(\{{ second_alias["id"].stringify =~ /mercure_hub_second/ }}, "Expected second alias id to match mercure_hub_second") ASPEC.compile_time_assert(\{{ default_alias["id"].stringify =~ /mercure_hub_first/ }}, "Expected default alias to be first hub") end end CR end it "passes cookie_lifetime from configuration" do assert_compiles <<-'CR' ADI.configure({ mercure: { hubs: { default: { url: "https://hub.example.com/.well-known/mercure", jwt: { secret: "looooooooooooongenoughtestsecret", }, }, }, default_cookie_lifetime: 2.hours, }, }) macro finished macro finished \{% lifetime = ADI::ServiceContainer::SERVICE_HASH["mercure_authorization"]["parameters"]["cookie_lifetime"]["value"] %} ASPEC.compile_time_assert(\{{ lifetime.stringify == "2.hours" }}, "Expected cookie_lifetime to be 2.hours") end end CR end end ================================================ FILE: src/bundles/mercure/spec/discovery_spec.cr ================================================ require "./spec_helper" struct DiscoveryTest < ASPEC::TestCase def test_add_link : Nil discovery = ABM::Discovery.new new_hub_registry request = new_request discovery.add_link request links = request.attributes.get? "_links", Array(String) links.should eq [%(; rel="mercure")] end def test_add_link_with_named_hub : Nil hub = AMC::Spec::MockHub.new("https://hub1.example.com/.well-known/mercure", AMC::TokenProvider::Static.new("JWT")) { "ID" } registry = AMC::Hub::Registry.new(hub, {"hub1" => hub.as(AMC::Hub::Interface)}) discovery = ABM::Discovery.new registry request = new_request discovery.add_link request, "hub1" links = request.attributes.get? "_links", Array(String) links.should eq [%(; rel="mercure")] end def test_add_link_accumulates_multiple_links : Nil hub1 = AMC::Spec::MockHub.new("https://hub1.example.com/.well-known/mercure", AMC::TokenProvider::Static.new("JWT")) { "ID" } hub2 = AMC::Spec::MockHub.new("https://hub2.example.com/.well-known/mercure", AMC::TokenProvider::Static.new("JWT")) { "ID" } registry = AMC::Hub::Registry.new(hub1, { "hub1" => hub1.as(AMC::Hub::Interface), "hub2" => hub2.as(AMC::Hub::Interface), }) discovery = ABM::Discovery.new registry request = new_request discovery.add_link request, "hub1" discovery.add_link request, "hub2" links = request.attributes.get? "_links", Array(String) links.should eq [ %(; rel="mercure"), %(; rel="mercure"), ] end def test_add_link_skips_preflight_request : Nil discovery = ABM::Discovery.new new_hub_registry request = new_request(method: "OPTIONS", headers: ::HTTP::Headers{"access-control-request-method" => "GET"}) discovery.add_link request request.attributes.has?("_links").should be_false end end ================================================ FILE: src/bundles/mercure/spec/listeners/add_link_header_spec.cr ================================================ require "../spec_helper" struct AddLinkHeaderListenerTest < ASPEC::TestCase def test_no_links_attribute : Nil event = new_response_event ABM::Listeners::AddLinkHeader.new.on_response event event.response.headers["link"]?.should be_nil end def test_single_link : Nil request = new_request request.attributes.set "_links", [%(; rel="mercure")], Array(String) event = new_response_event(request: request) ABM::Listeners::AddLinkHeader.new.on_response event event.response.headers.get("link").should eq [%(; rel="mercure")] end def test_multiple_links : Nil request = new_request request.attributes.set "_links", [ %(; rel="mercure"), %(; rel="mercure"), ], Array(String) event = new_response_event(request: request) ABM::Listeners::AddLinkHeader.new.on_response event event.response.headers.get("link").should eq [ %(; rel="mercure"), %(; rel="mercure"), ] end def test_preserves_existing_link_headers : Nil request = new_request request.attributes.set "_links", [%(; rel="mercure")], Array(String) response = AHTTP::Response.new response.headers.add "link", %(; rel="preload") event = new_response_event(request: request, response: response) ABM::Listeners::AddLinkHeader.new.on_response event event.response.headers.get("link").should eq [ %(; rel="preload"), %(; rel="mercure"), ] end end ================================================ FILE: src/bundles/mercure/spec/listeners/set_cookie_spec.cr ================================================ require "../spec_helper" struct SetCookieListenerTest < ASPEC::TestCase def test_no_cookies_attribute : Nil event = new_response_event ABM::Listeners::SetCookie.new.on_response event event.response.headers.cookies.should be_empty end def test_applies_cookies_to_response : Nil request = new_request cookies = Hash(String, ::HTTP::Cookie).new cookies[""] = ::HTTP::Cookie.new("mercureAuthorization", "token123", path: "/") request.attributes.set "_mercure_authorization_cookies", cookies, Hash(String, ::HTTP::Cookie) event = new_response_event(request: request) ABM::Listeners::SetCookie.new.on_response event event.response.headers.cookies["mercureAuthorization"].value.should eq "token123" end def test_removes_attribute_after_processing : Nil request = new_request cookies = Hash(String, ::HTTP::Cookie).new cookies[""] = ::HTTP::Cookie.new("mercureAuthorization", "token123", path: "/") request.attributes.set "_mercure_authorization_cookies", cookies, Hash(String, ::HTTP::Cookie) event = new_response_event(request: request) ABM::Listeners::SetCookie.new.on_response event request.attributes.has?("_mercure_authorization_cookies").should be_false end def test_applies_multiple_cookies : Nil request = new_request cookies = Hash(String, ::HTTP::Cookie).new cookies[""] = ::HTTP::Cookie.new("mercureAuthorization", "default-token", path: "/") cookies["hub1"] = ::HTTP::Cookie.new("mercureAuthorization", "hub1-token", path: "/hub1") request.attributes.set "_mercure_authorization_cookies", cookies, Hash(String, ::HTTP::Cookie) event = new_response_event(request: request) ABM::Listeners::SetCookie.new.on_response event # The last cookie with the same name wins in the cookies collection, # but both should have been added via << event.response.headers.cookies.size.should be >= 1 end end ================================================ FILE: src/bundles/mercure/spec/spec_helper.cr ================================================ require "spec" require "athena-spec" require "../src/athena-mercure_bundle" require "athena-mercure/src/spec" ASPEC.run_all def new_hub_registry( url : String = "https://example.com/.well-known/mercure", token_factory : AMC::TokenFactory::Interface? = AMC::TokenFactory::JWT.new("looooooooooooongenoughtestsecret", jwt_lifetime: 4000), ) : AMC::Hub::Registry AMC::Hub::Registry.new(AMC::Spec::MockHub.new(url, AMC::TokenProvider::Static.new("JWT"), token_factory: token_factory) { "ID" }) end def new_request( *, method : String = "GET", path : String = "/", headers : ::HTTP::Headers = ::HTTP::Headers.new, ) : AHTTP::Request AHTTP::Request.new(method, path, headers) end def new_response_event( request : AHTTP::Request = new_request, response : AHTTP::Response = AHTTP::Response.new, ) : AHK::Events::Response AHK::Events::Response.new(request, response) end ================================================ FILE: src/bundles/mercure/src/athena-mercure_bundle.cr ================================================ require "athena-dependency_injection" require "athena-http_kernel" require "athena-mercure" require "./authorization" require "./discovery" require "./listeners/*" # Convenience alias to make referencing `Athena::MercureBundle` types easier. alias ABM = Athena::MercureBundle # The `Athena::MercureBundle` integrates the `Athena::Mercure` component into the Athena framework. @[ADI::Bundle("mercure")] struct Athena::MercureBundle < ADI::AbstractBundle # :nodoc: PASSES = [] of _ # Represents the possible properties used to configure and customize the Mercure integration. # See the [Getting Started](/getting_started/configuration) docs for more information on how the bundle system works in Athena. # # A full example showing all properties is as follows: # # ``` # ADI.configure({ # mercure: { # hubs: { # default: { # url: "https://internal-hub/.well-known/mercure", # public_url: "https://hub.example.com/.well-known/mercure", # jwt: { # # Provide *secret* to generate JWTs dynamically via a token factory... # secret: "my-jwt-secret", # publish: ["*"], # subscribe: ["https://example.com/books/{id}"], # algorithm: :hs256, # passphrase: "", # # # ...or provide *value* to use a static JWT token directly. # # value: "eyJhbGciOiJIUzI1NiJ9...", # }, # }, # }, # default_hub: "default", # default_cookie_lifetime: 1.hour, # }, # }) # ``` module Schema include ADI::Extension::Schema # JWT configuration for authenticating with a Mercure hub. # Provide either *secret* to generate tokens dynamically, or *value* to use a static token. # # --- # >>secret: The secret key used to sign JWTs. # Required when generating tokens dynamically via a `AMC::TokenFactory::JWT` (i.e. when *value* is not set). # Should be set via ENV var. # >>publish: Topic selectors that the generated JWT grants publish access to. Included in the JWT's `mercure.publish` claim. # >>subscribe: Topic selectors that the generated JWT grants subscribe access to. Included in the JWT's `mercure.subscribe` claim. # >>algorithm: The signing algorithm used to encode the JWT. # >>passphrase: Passphrase for the secret key, if the algorithm requires one (e.g. RSA). # >>value: A pre-built static JWT token string provided via `AMC::TokenProvider::Static`. When set, the token is used as-is and *secret*, *algorithm*, and *passphrase* are ignored. # --- object_schema JWT, secret : String? = nil, publish : Array(String) = [] of String, subscribe : Array(String) = [] of String, algorithm : ::JWT::Algorithm = :hs256, passphrase : String = "", value : String? = nil # Named Mercure hub definitions. Each hub requires a *url* and *jwt* configuration. # # --- # >>url: The internal URL used by the server to publish updates to this hub. # Should be set via ENV var. # >>public_url: The public URL exposed to clients via the `Link` header for hub discovery. # Falls back to *url* if not set. Useful when the internal hub URL differs from the one clients should connect to. # Should be set via ENV var. # --- map_of hubs, url : String, public_url : String? = nil, jwt : JWT # The name of the hub to use when none is specified. Defaults to the first defined hub if not explicitly set. property default_hub : String? = nil # Default lifetime for authorization cookies set via `ABM::Authorization`. property default_cookie_lifetime : Time::Span = 1.hour end # :nodoc: module Extension macro included macro finished {% verbatim do %} {% cfg = CONFIG["mercure"] parameters = CONFIG["parameters"] default_hub_id = nil default_hub_name = nil hubs = {} of Nil => Nil hub_aliases = [] of Nil cfg["hubs"].to_a.reject { |(name, _)| name.stringify == "__nil" }.each do |(name, hub)| token_provider = nil token_factory = nil jwt = hub["jwt"] if value = jwt["value"] SERVICE_HASH[token_provider = "mercure_hub_#{name}_jwt_provider"] = { class: Athena::Mercure::TokenProvider::Static, parameters: { token: {value: value}, }, } else # TODO: Maybe support providing the factory/provider service ID? # TODO: Service is already lazy so no need for dedicated lazy service? SERVICE_HASH[token_factory = "mercure_hub_#{name}_jwt_factory"] = { class: Athena::Mercure::TokenFactory::JWT, tags: ["mercure.jwt.factory"], parameters: { jwt_secret: {value: jwt["secret"]}, algorithm: {value: jwt["algorithm"]}, # jwt_lifetime: {value: nil}, passphrase: {value: jwt["passphrase"]}, }, } SERVICE_HASH[token_provider = "mercure_hub_#{name}_jwt_provider"] = { class: Athena::Mercure::TokenProvider::Factory, tags: ["mercure.jwt.provider"], parameters: { factory: {value: token_factory.id}, subscribe: {value: jwt["subscribe"]}, publish: {value: jwt["publish"]}, }, } ALIASES[Athena::Mercure::TokenFactory::Interface] = [ {id: token_factory, public: false, name: name}, {id: token_factory, public: false, name: "#{name}_factory"}, {id: token_factory, public: false, name: "#{name}_token_factory"}, ] end if token_provider ALIASES[Athena::Mercure::TokenProvider::Interface] = [ {id: token_provider, public: false, name: name}, {id: token_provider, public: false, name: "#{name}_provider"}, {id: token_provider, public: false, name: "#{name}_token_provider"}, ] end hub_id = "mercure_hub_#{name}" publisher_id = "mercure_hub_#{name}_publisher" hubs[name.stringify] = hub_id.id if cfg["default_hub"] == name || default_hub_id == nil default_hub_name = name default_hub_id = hub_id end SERVICE_HASH[hub_id] = { class: Athena::Mercure::Hub, tags: ["mercure.hub"], parameters: { url: {value: hub["url"]}, token_provider: {value: token_provider.id}, token_factory: {value: token_factory.id}, public_url: {value: hub["public_url"]}, # http_client }, } hub_aliases << {id: hub_id, public: false, name: name} hub_aliases << {id: hub_id, public: false, name: "#{name}_hub"} end # Unnamed alias resolves to the default hub hub_aliases << {id: default_hub_id, public: false} ALIASES[Athena::Mercure::Hub::Interface] = hub_aliases SERVICE_HASH[hub_registry_id = "mercure_hub_registry"] = { class: Athena::Mercure::Hub::Registry, parameters: { default_hub: {value: default_hub_id.id}, hubs: {value: "#{hubs} of String => Athena::Mercure::Hub::Interface".id}, }, } SERVICE_HASH["mercure_authorization"] = { class: ABM::Authorization, parameters: { hub_registry: {value: hub_registry_id.id}, cookie_lifetime: {value: cfg["default_cookie_lifetime"]}, }, } SERVICE_HASH["mercure_discovery"] = { class: ABM::Discovery, parameters: { hub_registry: {value: hub_registry_id.id}, }, } %} {% end %} end end end end ADI.register_bundle Athena::MercureBundle ================================================ FILE: src/bundles/mercure/src/authorization.cr ================================================ struct Athena::MercureBundle < ADI::AbstractBundle; end # Extension of [AMC::Authorization](/Mercure/Authorization) to add support for [AHTTP::Request](/HTTP/Request). class Athena::MercureBundle::Authorization < Athena::Mercure::Authorization def initialize( hub_registry : AMC::Hub::Registry, cookie_lifetime : Time::Span = 1.hour, cookie_samesite : ::HTTP::Cookie::SameSite = :strict, ) super end # Sets the `mercureAuthorization` cookie for the provided *hub_name*. # The cookie is automatically applied to the `AHTTP::Response` via the `Listeners::SetCookie` listener. # # The JWT cookie value by default does not have access to publish or subscribe to any topic. # Be sure to set the *subscribe* and *publish* arrays to the topics you want it to be able to interact with, or `["*"]` to handle all topics. # *additional_claims* may also be used to define additional claims to the JWT if needed. def set_cookie( request : AHTTP::Request, subscribe : Array(String)? = [] of String, publish : Array(String)? = [] of String, additional_claims : Hash? = nil, hub_name : String? = nil, ) : Nil self.update_cookies request, hub_name, self.create_cookie(request, subscribe, publish, additional_claims, hub_name) end # Clears the `mercureAuthorization` cookie for the given *hub_name*. def clear_cookie( request : AHTTP::Request, hub_name : String? = nil, ) : Nil self.update_cookies request, hub_name, self.create_clear_cookie(request.request, hub_name) end # Returns a Mercure auth cookie given the provided *request* and optionally for the provided *hub_name*. # # The JWT cookie value by default does not have access to publish or subscribe to any topic. # Be sure to set the *subscribe* and *publish* arrays to the topics you want it to be able to interact with, or `["*"]` to handle all topics. # *additional_claims* may also be used to define additional claims to the JWT if needed. def create_cookie( request : AHTTP::Request, subscribe : Array(String)? = [] of String, publish : Array(String)? = [] of String, additional_claims : Hash? = nil, hub_name : String? = nil, ) : ::HTTP::Cookie super request.request, subscribe, publish, additional_claims, hub_name end private def update_cookies( request : AHTTP::Request, hub_name : String?, cookie : ::HTTP::Cookie, ) : Nil hub_name ||= "" cookies = request.attributes.get?("_mercure_authorization_cookies", Hash(String, ::HTTP::Cookie)) || Hash(String, ::HTTP::Cookie).new if cookies.has_key? hub_name raise AMC::Exception::Runtime.new "The 'mercureAuthorization' cookie for the '#{hub_name.presence ? "#{hub_name} hub" : "default hub"}' has already been set. You cannot set it two times during the same request." end cookies[hub_name] = cookie request.attributes.set "_mercure_authorization_cookies", cookies, Hash(String, ::HTTP::Cookie) end end ================================================ FILE: src/bundles/mercure/src/discovery.cr ================================================ # Extension of [AMC::Discovery](/Mercure/Discovery/) that accepts [AHTTP::Request](/HTTP/Request/) # and stores the link in a request attribute for the [AddLinkHeader](/MercureBundle/Listeners/AddLinkHeader/) listener. class Athena::MercureBundle::Discovery < AMC::Discovery def initialize( hub_registry : AMC::Hub::Registry, ) super end # Adds the mercure relation `link` header to the provided *request*, optionally for the provided *hub_name*. def add_link(request : AHTTP::Request, hub_name : String? = nil) : Nil return if self.preflight_request? request.request hub = @hub_registry.hub hub_name # TODO: Create WebLink component? links = request.attributes.get?("_links", Array(String)) || Array(String).new links << self.generate_link(hub.public_url) request.attributes.set("_links", links, Array(String)) end end ================================================ FILE: src/bundles/mercure/src/listeners/add_link_header.cr ================================================ # Adds Mercure hub `Link` headers that was stored in the request attributes via `ABM::Discovery`. @[ADI::Register] struct Athena::MercureBundle::Listeners::AddLinkHeader # :nodoc: def initialize; end @[AEDA::AsEventListener] def on_response(event : AHK::Events::Response) : Nil return unless links = event.request.attributes.get? "_links", Array(String) # TODO: Create WebLink component? links.each do |link| event.response.headers.add "link", link end end end ================================================ FILE: src/bundles/mercure/src/listeners/set_cookie.cr ================================================ # Adds `mercureAuthorization` cookies that were stored in the request attributes via `ABM::Authorization`. @[ADI::Register] struct Athena::MercureBundle::Listeners::SetCookie # :nodoc: def initialize; end @[AEDA::AsEventListener] def on_response(event : AHK::Events::Response) : Nil return unless cookies = event.request.attributes.get? "_mercure_authorization_cookies", Hash(String, ::HTTP::Cookie) event.request.attributes.remove "_mercure_authorization_cookies" cookies.each_value do |cookie| event.response.headers << cookie end end end ================================================ FILE: src/components/clock/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/clock/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/clock/CHANGELOG.md ================================================ # Changelog ## [0.3.0] - 2026-04-19 ### Removed - Remove `ACLK::Monotonic` ([#667]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/clock/releases/tag/v0.3.0 [#667]: https://github.com/athena-framework/athena/pull/667 ## [0.2.0] - 2025-01-26 ### Changed - **Breaking:** Remove `Athena::Clock::Interface#sleep(Number)` overload ([#449]) (George Dietrich) ### Fixed - Fix type error when trying to use `ACLK::Aware#now` ([#498]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/clock/releases/tag/v0.2.0 [#449]: https://github.com/athena-framework/athena/pull/449 [#498]: https://github.com/athena-framework/athena/pull/498 ## [0.1.2] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) ### Fixed - Fix that `Athena::Clock::Aware` was not required by default ([#365]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/clock/releases/tag/v0.1.2 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.1.1] - 2023-10-09 _Administrative release, no functional changes_ [0.1.1]: https://github.com/athena-framework/clock/releases/tag/v0.1.1 ## [0.1.0] - 2023-09-16 _Initial release._ [0.1.0]: https://github.com/athena-framework/clock/releases/tag/v0.1.0 ================================================ FILE: src/components/clock/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/clock/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2023 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/clock/README.md ================================================ # Clock [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/clock.svg)](https://github.com/athena-framework/clock/releases) Decouples applications from the system clock. ## Getting Started Checkout the [Documentation](https://athenaframework.org/Clock). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/clock/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.3.0 ### Remove `ACLK::Monotonic` `Time.monotonic` has been [deprecated](https://github.com/crystal-lang/rfcs/pull/15) in Crystal stdlib. The new `Time.instant` API doesn't, for good reason, doesn't have any overlap with `Time`, thus making it somewhat incompatible with `ACLK::Interface`. Use cases that require measuring time should likely just use `Time.instant` directly. ## Upgrade to 0.2.0 ### Dropped `ACLK::Interface#sleep(Number)` overload `::sleep(Number)` has been [deprecated](https://github.com/crystal-lang/crystal/pull/14962) in Crystal stdlib. The clock component follows suite, with the same migration path. Instead of `.sleep 5` do `.sleep 5.seconds`. ================================================ FILE: src/components/clock/docs/README.md ================================================ The `Athena::Clock` component allows decoupling an application from the system clock. This allows for more easily testing time sensitive code. The component provides a [ACLK::Interface](/Clock/Interface/) with the following implementations for different use cases: * [ACLK::Native](/Clock/Native/) - Provides access to the system clock for most usages * [ACLK::Monotonic](/Clock/Monotonic/) - Provides access to a high resolution, monotonic clock for when needing to measure time precisely * [ACLK::Spec::MockClock](/Clock/Spec/MockClock/) - Provides the ability to freeze and change the current time for use in tests ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-clock: github: athena-framework/clock version: ~> 0.3.0 ``` ## Usage The core `Athena::Clock` type can be used to return the current time via a global clock. ```crystal # By default, `Athena::Clock` uses the native clock implementation, # but it can be changed to any other implementation Athena::Clock.clock = ACLK::Monotonic.new # Then, obtain a clock instance clock = ACLK.clock # Optionally, with in a specific location berlin_clock = clock.in_location Time::Location.load "Europe/Berlin" # From here, get the current time as a `Time` instance now = clock.now # : ::Time # and sleep for any span of time clock.sleep 2.seconds ``` ================================================ FILE: src/components/clock/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Clock site_url: https://athenaframework.org/Clock/ repo_url: https://github.com/athena-framework/clock nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-clock/src/athena-clock.cr - ./lib/athena-clock/src/spec.cr source_locations: lib/athena-clock: https://github.com/athena-framework/clock/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/clock/shard.yml ================================================ name: athena-clock version: 0.3.0 crystal: ~> 1.4 license: MIT repository: https://github.com/athena-framework/clock documentation: https://athenaframework.org/Clock description: | Decouples applications from the system clock. authors: - George Dietrich ================================================ FILE: src/components/clock/spec/athena-clock_spec.cr ================================================ require "./spec_helper" struct ClockTest < ASPEC::TestCase include ACLK::Spec::ClockSensitive def test_functions_as_a_clock : Nil self.mock_time Time.utc 2023, 9, 16 clock = ACLK.new clock.now.to_s("%F").should eq "2023-09-16" end def test_accepts_an_existing_clock : Nil clock = ACLK.new ACLK::Spec::MockClock.new Time.utc 2023, 9, 16, 23, 53, 0 clock.now.to_s("%F").should eq "2023-09-16" end def test_in_location : Nil clock = ACLK.new location: Time::Location.load("America/New_York") utc_clock = clock.in_location Time::Location::UTC clock.should_not eq utc_clock utc_clock.now.location.should eq Time::Location::UTC end def test_sleep : Nil clock = ACLK.new ACLK::Spec::MockClock.new Time.utc 2023, 9, 16, 23, 53, 0, nanosecond: 999_000_000 location = clock.now.location clock.sleep 2.002_001.seconds clock.now.to_s("%F %H:%M:%S.%6N").should eq "2023-09-16 23:53:03.001001" clock.now.location.should eq location end def test_supports_mock_clock : Nil ACLK.clock.should be_a ACLK::Native clock = self.mock_time ACLK.clock.should be_a ACLK::Spec::MockClock ACLK.clock.should eq clock end def test_defaults_to_native_clock : Nil ACLK.clock.should be_a ACLK::Native end def test_response_to_mocked_clocks : Nil ACLK.clock.should be_a ACLK::Native self.mock_time.should be_a ACLK::Spec::MockClock self.mock_time(false).should be_a ACLK::Native end def test_ensure_mock_clock_freezes : Nil self.mock_time Time.utc 2023, 9, 16 ACLK.clock.now.to_s("%F").should eq "2023-09-16" ACLK.clock.now.shift(days: 1).to_s("%F").should eq "2023-09-17" self.shift days: 1 ACLK.clock.now.to_s("%F").should eq "2023-09-17" end end ================================================ FILE: src/components/clock/spec/aware_spec.cr ================================================ require "./spec_helper" private class Example include Athena::Clock::Aware end struct AwareTest < ASPEC::TestCase def test_happy_path : Nil instance = Example.new instance.now.should_not be_nil instance.clock = ACLK::Spec::MockClock.new Time.utc 2023, 9, 16, 23, 53, 0 instance.now.should eq Time.utc(2023, 9, 16, 23, 53, 0) end end ================================================ FILE: src/components/clock/spec/mock_clock_spec.cr ================================================ require "./spec_helper" struct MockClockTest < ASPEC::TestCase def test_allows_customizing_timezone : Nil clock = ACLK::Spec::MockClock.new location: Time::Location.load "Europe/Berlin" clock.now.location.name.should eq "Europe/Berlin" end def test_defaults_to_utc : Nil ACLK::Spec::MockClock.new.now.location.utc?.should be_true end def test_allows_specifying_a_specific_time : Nil ACLK::Spec::MockClock.new(now = Time.local).now.should eq now end def test_now : Nil before = Time.utc.to_unix_ms sleep 10.milliseconds clock = ACLK::Spec::MockClock.new sleep 10.milliseconds now = clock.now after = Time.utc.to_unix_ms now.to_unix_ms.should be > before now.to_unix_ms.should be < after clock.now.should eq clock.now end def test_sleep : Nil clock = ACLK::Spec::MockClock.new Time.utc 2023, 9, 16, 23, 53, 0, nanosecond: 999_000_000 location = clock.now.location clock.sleep 2.002_001.seconds clock.now.to_s("%F %H:%M:%S.%6N").should eq "2023-09-16 23:53:03.001001" clock.now.location.should eq location end def test_shift : Nil clock = ACLK::Spec::MockClock.new Time.utc 2023, 9, 16, 23, 53, 0 clock.shift days: 2, seconds: 12, hours: -1 clock.now.to_s("%F %H:%M:%S").should eq "2023-09-18 22:53:12" end def test_in_location : Nil clock = ACLK::Spec::MockClock.new location: Time::Location.load("America/New_York") utc_clock = clock.in_location Time::Location::UTC clock.should_not eq utc_clock utc_clock.now.location.should eq Time::Location::UTC end end ================================================ FILE: src/components/clock/spec/native_spec.cr ================================================ require "./spec_helper" struct NativeClockTest < ASPEC::TestCase def test_allows_customizing_timezone : Nil clock = ACLK::Native.new Time::Location.load "Europe/Berlin" clock.now.location.name.should eq "Europe/Berlin" end def test_defaults_to_local_tz : Nil ACLK::Native.new.now.location.local?.should be_true end def test_now : Nil clock = ACLK::Native.new before = Time.local.to_unix_ms sleep 10.milliseconds now = clock.now sleep 10.milliseconds after = Time.local.to_unix_ms now.to_unix_ms.should be > before now.to_unix_ms.should be < after end def test_sleep : Nil clock = ACLK::Native.new location = clock.now.location before = Time.local.to_unix_ms clock.sleep 0.5.seconds now = clock.now.to_unix_ms sleep 10.milliseconds after = Time.local.to_unix_ms now.should be >= (before + 1.5) now.should be < after clock.now.location.should eq location end def test_in_location : Nil clock = ACLK::Native.new Time::Location.load("America/New_York") utc_clock = clock.in_location Time::Location::UTC clock.should_not eq utc_clock utc_clock.now.location.should eq Time::Location::UTC end end ================================================ FILE: src/components/clock/spec/spec_helper.cr ================================================ require "spec" require "athena-spec" require "../src/athena-clock" require "../src/spec" ASPEC.run_all ================================================ FILE: src/components/clock/src/athena-clock.cr ================================================ require "./aware" require "./interface" require "./native" # Convenience alias to make referencing `Athena::Clock` types easier. alias ACLK = Athena::Clock # Decouples applications from the system clock. class Athena::Clock include Athena::Clock::Interface VERSION = "0.3.0" # Represents the global clock used by all `Athena::Clock` instances. # # NOTE: It is preferable injecting an `Athena::Clock::Interface` when possible versus using the global clock getter. class_property clock : ACLK::Interface = ACLK::Native.new @clock : ACLK::Interface? @location : Time::Location? def initialize( @clock : ACLK::Interface? = nil, @location : Time::Location? = nil, ) end # :inherit: def in_location(location : Time::Location) : self self.class.new @clock, location end # :inherit: def now : Time now = (@clock || self.class.clock).now (location = @location) ? now.in(location) : now end # :inherit: def sleep(span : Time::Span) : Nil (@clock || self.class.clock).sleep span end end ================================================ FILE: src/components/clock/src/aware.cr ================================================ class Athena::Clock; end # This module can be included to make a type time aware without having to alter its constructor. # # ``` # class Example # include Athena::Clock::Aware # # def expired? # self.now > some_time_instance # end # end # # # Will use a `Athena::Clock` instance if a custom one is not set on the instance. # example = Example.new # # # Or if so desired, explicitly set custom implementation. # my_clock = MySpecialClock.new # custom_example = Example.new # custom_example.clock = my_clock # ``` module Athena::Clock::Aware # TODO: Wire this up with an `@[ADI::Required]` annotation. setter clock : ACLK::Interface? = nil def now : ::Time (@clock ||= ACLK.new).now end end ================================================ FILE: src/components/clock/src/interface.cr ================================================ # Represents a clock that returns a `Time` instance, possibly in a specific location. module Athena::Clock::Interface # Returns a new clock instance set to the provided *location*. abstract def in_location(location : Time::Location) : self # Returns the current time as determined by the clock. abstract def now : Time # Sleeps for the provided *span* of time. abstract def sleep(span : Time::Span) : Nil end ================================================ FILE: src/components/clock/src/native.cr ================================================ # The default clock for most use cases which returns the current system time. # For example: # # ``` # class ExpirationChecker # def initialize(@clock : Athena::Clock::Interface); end # # def expired?(valid_until : Time) : Bool # @clock.now > valid_until # end # end # ``` struct Athena::Clock::Native include Athena::Clock::Interface @location : Time::Location def initialize( location : Time::Location? = nil, ) @location = location || Time::Location.local end # :inherit: def in_location(location : Time::Location) : self self.class.new location: location end # :inherit: def now : Time Time.local @location end # :inherit: def sleep(span : Time::Span) : Nil ::sleep span end end ================================================ FILE: src/components/clock/src/spec.cr ================================================ # A set of testing utilities/types to aid in testing `Athena::Clock` related types. # # ### Getting Started # # Require this module in your `spec_helper.cr` file: # # ``` # require "athena-clock/spec" # ``` module Athena::Clock::Spec # The mock clock is instantiated with a time and does not move forward on its own. # The time is fixed until `#sleep` or `#shift` is called. # This provides full control over what time the code assumes it's running with, # ultimately making testing time-sensitive types much easier. # # ``` # class ExpirationChecker # def initialize(@clock : Athena::Clock::Interface); end # # def expired?(valid_until : Time) : Bool # @clock.now > valid_until # end # end # # clock = ACLK::Spec::MockClock.new Time.utc 2023, 9, 16, 15, 20 # expiration_checker = ExpirationChecker.new clock # valid_until = Time.utc 2023, 9, 16, 15, 25 # # # valid_until is in the future, so not expired # expiration_checker.expired?(valid_until).should be_false # # # Sleep for 10 minutes, so time is now 2023-09-16 15:30:00, # # time is instantly changes as if 10 minutes really passed # clock.sleep 10.minutes # # expiration_checker.expired?(valid_until).should be_true # # # Time can also be shifted, either into the future or past # clock.shift minutes: -20 # # # valid_until is in the future again, so not expired # expiration_checker.expired?(valid_until).should be_false # ``` class MockClock include Athena::Clock::Interface @now : Time @location : Time::Location def initialize( now : Time = Time.local, location : Time::Location? = nil, ) @location = location || Time::Location::UTC @now = now.in @location end # :inherit: def in_location(location : Time::Location) : self self.class.new now: Time.local(location) end # :inherit: def now : Time @now end # Shifts the mocked time instance by the provided amount of time. # Positive values shift into the future, while negative values shift into the past. # # This method is essentially equivalent to calling `#sleep` with the same amount of time, but this method provides a better API in some cases. def shift(*, years : Int = 0, months : Int = 0, weeks : Int = 0, days : Int = 0, hours : Int = 0, minutes : Int = 0, seconds : Int = 0) : Nil @now = @now.shift(years: years, months: months, weeks: weeks, days: days, hours: hours, minutes: minutes, seconds: seconds) end # :inherit: def sleep(span : Time::Span) : Nil @now += span end end # An `Athena::Spec::TestCase` mix-in that allows freezing time and restoring the global clock after each test. # # ``` # struct MonthSensitiveTest < ASPEC::TestCase # include ACLK::Spec::ClockSensitive # # def test_winter_month : Nil # clock = self.mock_time Time.utc 2023, 12, 10 # # month_sensitive = MonthSensitive.new # month_sensitive.clock = clock # # month_sensitive.winter_month?.should be_true # end # # def test_non_winter_month : Nil # clock = self.mock_time Time.utc 2023, 7, 10 # # month_sensitive = MonthSensitive.new # month_sensitive.clock = clock # # month_sensitive.winter_month?.should be_false # end # end # ``` module ClockSensitive @@original_clock : ACLK::Interface? = nil # Returns a new clock instanced with the global clock value shifted by the provided amount of time. # Positive values shift into the future, while negative values shift into the past. def shift(*, years : Int = 0, months : Int = 0, weeks : Int = 0, days : Int = 0, hours : Int = 0, minutes : Int = 0, seconds : Int = 0) : ACLK::Interface self.mock_time ACLK.clock.now.shift(years: years, months: months, weeks: weeks, days: days, hours: hours, minutes: minutes, seconds: seconds) end # Returns clock instance based on the provided *now* value. # # If a `Time` instance is passed, that value is used. # If `true`, freezes the global clock to the current time. # If `false`, restores the previous global clock. def mock_time(now : Time | Bool = true) : ACLK::Interface ACLK.clock = case now in false then self.save_clock_before_test false in true then ACLK::Spec::MockClock.new in Time then ACLK::Spec::MockClock.new now end Athena::Clock.clock end protected def save_clock_before_test(save : Bool = true) : ACLK::Interface save ? (@@original_clock = ACLK.clock) : @@original_clock.not_nil! end protected def restore_clock_after_test : Nil ACLK.clock = self.save_clock_before_test false end # :nodoc: def initialize super self.save_clock_before_test end # :inherit: protected def tear_down : Nil super self.restore_clock_after_test end end end ================================================ FILE: src/components/console/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/console/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/console/CHANGELOG.md ================================================ # Changelog ## [0.4.3] - 2026-04-19 ### Added - Add opt-in support for deriving the command name from `PROGRAM_NAME` when a CLI binary is invoked via a symlink ([#645]) (George Dietrich) [0.4.3]: https://github.com/athena-framework/console/releases/tag/v0.4.3 [#645]: https://github.com/athena-framework/athena/pull/645 ## [0.4.2] - 2025-09-04 ### Added - Add ability to customize the finished state of an `ACON::Helper::ProgressIndicator` ([#535]) (George Dietrich) - Add `markdown` `ACON::Helper::Table` style ([#536]) (George Dietrich) - Add support for nested style tags ([#568]) (George Dietrich) ### Fixed - Fix `ACON::Helper::ProgressBar` messing up output in console section with EOL ([#537]) (George Dietrich) [0.4.2]: https://github.com/athena-framework/console/releases/tag/v0.4.2 [#535]: https://github.com/athena-framework/athena/pull/535 [#536]: https://github.com/athena-framework/athena/pull/536 [#568]: https://github.com/athena-framework/athena/pull/568 [#537]: https://github.com/athena-framework/athena/pull/537 ## [0.4.1] - 2025-02-08 ### Fixed - Fix incorrectly aligned block ([#519]) (Zohir Tamda) [0.4.1]: https://github.com/athena-framework/console/releases/tag/v0.4.1 [#519]: https://github.com/athena-framework/athena/pull/519 ## [0.4.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) ### Added - **Breaking:** Add `ACON::Output::Verbosity::SILENT` verbosity level ([#489]) (George Dietrich) - **Breaking:** Rename `ACON::Completion::Input#must_suggest_values_for?` to `#must_suggest_option_values_for?` ([#498]) (George Dietrich) - Update minimum `crystal` version to `~> 1.13.0` ([#498]) (George Dietrich) - Add `#assert_command_is_not_successful` spec expectation method ([#498]) (George Dietrich) - Add support for [`FORCE_COLOR`](https://force-color.org/) and improve color support logic ([#488]) (George Dietrich) ### Fixed - Fix unexpected completion value when given an array of options ([#498]) (George Dietrich) - Fix error when trying to set `ACON::Helper::Table::Style#padding_char` ([#498]) (George Dietrich) [0.4.0]: https://github.com/athena-framework/console/releases/tag/v0.4.0 [#428]: https://github.com/athena-framework/athena/pull/428 [#488]: https://github.com/athena-framework/athena/pull/488 [#489]: https://github.com/athena-framework/athena/pull/489 [#498]: https://github.com/athena-framework/athena/pull/498 ## [0.3.6] - 2024-07-31 ### Changed - **Breaking:** `ACON::Application#getter` and constructor argument must now be a `String` instead of `SemanticVersion` ([#419]) (George Dietrich) - Changed the default `ACON::Application` version to `UNKNOWN` from `0.1.0` ([#419]) (George Dietrich) - List commands in a namespace when using it as the command name ([#427]) (George Dietrich) - Use single quotes in text descriptor to quote values in the output ([#427]) (George Dietrich) [0.3.6]: https://github.com/athena-framework/console/releases/tag/v0.3.6 [#419]: https://github.com/athena-framework/athena/pull/419 [#427]: https://github.com/athena-framework/athena/pull/427 ## [0.3.5] - 2024-04-09 ### Changed - Update minimum `crystal` version to `~> 1.11.0` ([#270]) (George Dietrich) - Integrate website into monorepo ([#365]) (George Dietrich) ### Added - Support for Windows OS ([#270]) (George Dietrich) ### Fixed - Fix incorrect column/width `ACON::Terminal` values on Windows ([#361]) (George Dietrich) [0.3.5]: https://github.com/athena-framework/console/releases/tag/v0.3.5 [#270]: https://github.com/athena-framework/athena/pull/270 [#365]: https://github.com/athena-framework/athena/pull/365 [#361]: https://github.com/athena-framework/athena/pull/361 ## [0.3.4] - 2023-10-10 ### Added - Add support for tab completion to the `bash` shell when binary is in the `bin/` directory and referenced with `./` ([#323]) (George Dietrich) [0.3.4]: https://github.com/athena-framework/console/releases/tag/v0.3.4 [#323]: https://github.com/athena-framework/athena/pull/323 ## [0.3.3] - 2023-10-09 ### Changed - Update minimum `crystal` version to `~> 1.8.0` ([#282]) (George Dietrich) ### Added - **Breaking:** Add `ACON::Helper::ProgressBar` to enable rendering progress bars ([#304]) (George Dietrich) - Add native shell tab completion support for `bash`, `zsh`, and `fish` for both built-in and custom commands ([#294], [#296], [#297], [#299]) (George Dietrich) - Add `ACON::Helper::ProgressIndicator` to enable rendering spinners ([#314]) (George Dietrich) - Add support for defining a max height for an `ACON::Output::Section` ([#303]) (George Dietrich) - Add `ACON::Helper.format_time` to format a duration as a human readable string ([#304]) (George Dietrich) - Add `#assert_command_is_successful` helper method to `ACON::Spec::CommandTester` and `ACON::Spec::ApplicationTester` ([#294]) (George Dietrich) ### Fixed - Ensure long lines with URLs are not cut when wrapped ([#314]) (George Dietrich) - Do not emit erroneous newline from `ACON::Style::Athena` when it's the first thing being written ([#314]) (George Dietrich) - Fix misalignment when word wrapping a hyperlink ([#305]) (George Dietrich) - Do not emit erroneous extra newlines from an `ACON::Output::Section` ([#303]) (George Dietrich) - Fix misalignment within a vertical table with multi-line cell ([#300]) (George Dietrich) [0.3.3]: https://github.com/athena-framework/console/releases/tag/v0.3.3 [#282]: https://github.com/athena-framework/athena/pull/282 [#294]: https://github.com/athena-framework/athena/pull/294 [#296]: https://github.com/athena-framework/athena/pull/296 [#297]: https://github.com/athena-framework/athena/pull/297 [#299]: https://github.com/athena-framework/athena/pull/299 [#300]: https://github.com/athena-framework/athena/pull/300 [#303]: https://github.com/athena-framework/athena/pull/303 [#304]: https://github.com/athena-framework/athena/pull/304 [#305]: https://github.com/athena-framework/athena/pull/305 [#314]: https://github.com/athena-framework/athena/pull/314 ## [0.3.2] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) ### Fixed - Fix formatting issue in Crystal `1.8-dev` ([#258]) (George Dietrich) [0.3.2]: https://github.com/athena-framework/console/releases/tag/v0.3.2 [#261]: https://github.com/athena-framework/athena/pull/261 [#258]: https://github.com/athena-framework/athena/pull/258 ## [0.3.1] - 2023-02-04 ### Added - Add better integration between `Athena::Console` and `Athena::DependencyInjection` ([#259]) (George Dietrich) [0.3.1]: https://github.com/athena-framework/console/releases/tag/v0.3.1 [#259]: https://github.com/athena-framework/athena/pull/259 ## [0.3.0] - 2023-01-07 ### Changed - **Breaking:** deprecate command default name/description class variables in favor of the new `ACONA::AsCommand` annotation ([#214]) (George Dietrich) - **Breaking:** refactor `ACON::Command#application=` to no longer have a `nil` default value ([#217]) (George Dietrich) - **Breaking:** refactor `ACON::Command#process_title=` no longer accept `nil` ([#217]) (George Dietrich) - **Breaking:** rename `ACON::Command#process_title=` to `ACON::Command#process_title` ([#217]) (George Dietrich) ### Added - **Breaking:** add `#table` method to `ACON::Style::Interface` ([#220]) (George Dietrich) - Add `ACONA::AsCommand` annotation to configure a command's name, description, aliases, and if it should be hidden ([#214]) (George Dietrich) - Add support for generating tables ([#220]) (George Dietrich) ### Fixed - Fix issue with using `ACON::Formatter::Output#format_and_wrap` with `nil` input and an edge case when wrapping a string with a space at the limit ([#220]) (George Dietrich) - Fix `ACON::Formatter::NullStyle#*_option` method using incorrect `ACON::Formatter::Mode` type restriction ([#220]) (George Dietrich) - Fix some flakiness when testing commands with input ([#224]) (George Dietrich) - Fix compiler error when trying to use `ACON::Style::Athena#error_style` ([#240]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/console/releases/tag/v0.3.0 [#214]: https://github.com/athena-framework/athena/pull/214 [#217]: https://github.com/athena-framework/athena/pull/217 [#220]: https://github.com/athena-framework/athena/pull/220 [#224]: https://github.com/athena-framework/athena/pull/224 [#240]: https://github.com/athena-framework/athena/pull/240 ## [0.2.1] - 2022-09-05 ### Changed - **Breaking:** ensure parameter names defined on interfaces match the implementation ([#188]) (George Dietrich) ### Added - Add an `ACON::Input::Interface` based on a command line string ([#186], [#187]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/console/releases/tag/v0.2.1 [#186]: https://github.com/athena-framework/athena/pull/186 [#187]: https://github.com/athena-framework/athena/pull/187 [#188]: https://github.com/athena-framework/athena/pull/188 ## [0.2.0] - 2022-05-14 _First release a part of the monorepo._ ### Changed - **Breaking:** remove `ACON::Formatter::Mode` in favor of `Colorize::Mode`. Breaking only if not using symbol autocasting. ([#170]) (George Dietrich) - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Added - Add `VERSION` constant to `Athena::Console` namespace ([#166]) (George Dietrich) - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Fixed - Disallow multi char option shortcuts made up of diff chars ([#164]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/console/releases/tag/v0.2.0 [#164]: https://github.com/athena-framework/athena/pull/164 [#166]: https://github.com/athena-framework/athena/pull/166 [#169]: https://github.com/athena-framework/athena/pull/169 [#170]: https://github.com/athena-framework/athena/pull/170 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.1.1] - 2021-12-01 ### Fixed - **Breaking:** fix typo in parameter name of `ACON::Command#option` method ([#3]) (George Dietrich) - Fix recursive struct error ([#4]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/console/releases/tag/v0.1.1 [#3]: https://github.com/athena-framework/console/pull/3 [#4]: https://github.com/athena-framework/console/pull/4 ## [0.1.0] - 2021-10-30 _Initial release._ [0.1.0]: https://github.com/athena-framework/console/releases/tag/v0.1.0 ================================================ FILE: src/components/console/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/console/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2021 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/console/README.md ================================================ # Console [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/console.svg)](https://github.com/athena-framework/console/releases) Allows for the creation of CLI based commands. ## Getting Started Checkout the [Documentation](https://athenaframework.org/Console). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/console/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.4.0 ### New `ACON::Output::Verbosity::SILENT` verbosity level Existing commands that define a `--silent` option will have to be renamed. ### Normalization of Exception types The namespace exception types live in has changed from `ACON::Exceptions` to `ACON::Exception`. Any usages of `console` exception types will need to be updated. Some additional types have also been removed/renamed: * `ACON::Exceptions::ConsoleException` has been removed in favor of using `ACON::Exception` directly * `ACON::Exceptions::RuntimeError` has been renamed `ACON::Exception::Runtime` * `ACON::Exceptions::ValidationError` has been removed with past usages now raising an `ACON::Exception::Runtime` error If using a `rescue` statement with a parent exception type, either from the `console` component or Crystal stdlib, double check it to ensure it'll still rescue what you are expecting it will. ## Upgrade to 0.3.6 ### `ACON::Application` version is now represented as a `String` If passing a [SemanticVersion](https://crystal-lang.org/api/SemanticVersion.html) as the *version* of an `ACON::Application`, call `#to_s` on it or ideally pass a semver `String` directly. If using the `#version` getter off the `ACON::Application`, your code will need to adapt to it now being a `String`. Either by manually constructing a `SemanticVersion` or ideally just supporting the returned `String`. ## Upgrade to 0.3.3 ### New `ACON::Style::Interface` methods If implementing a custom style, you will now need to implement the following methods: - `abstract def progress_start(max : Int32? = nil) : Nil` - `abstract def progress_advance(by step : Int32 = 1) : Nil` - `abstract def progress_finish : Nil` These should use an internal `ACON::Helper::ProgressBar` customized to fit your style that delegates to the related methods. ================================================ FILE: src/components/console/docs/README.md ================================================ The `Athena::Console` component allows creating CLI based commands. This integration can be a way to define alternate entry points into your business logic, such as for use with scheduled jobs (Cron, Airflow, etc), or one-off internal/administrative things (running migrations, creating users, etc). ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-console: github: athena-framework/console version: ~> 0.4.0 ``` ## Usage In its most basic form, a [ACON::Command](/Console/Command/) consists of an `#execute` method that provides access to [input](/Console/Input/Interface/) and [output](/Console/Output/Interface/) of the command and returns a [ACON::Command::Status](/Console/Command/Status/) member. ```crystal @[ACONA::AsCommand("app:create-user", description: "Manually create a user with the provided username")] class CreateUserCommand < ACON::Command protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : Status # Implement all the business logic here. # Indicates the command executed successfully. Status::SUCCESS end end ``` However, in most cases the command will need to be configured to better fit its use case. Commands may also implement a [#configure](/Console/Command/#Athena::Console::Command--configuring-the-command) method to accomplish this. This method is where the [ACON::Input::Argument](/Console/Input/Argument/)s and [ACON::Input::Option](/Console/Input/Option/)s may be defined, but also additional help output, aliases, etc. ```crystal protected def configure : Nil self .argument("username", :required, "The username of the user") .aliases("new-user") end ``` ### Application The core of the console component is the [ACON::Application](/Console/Application/) type which is where all the registered [ACON::Command](/Console/Command/)s are stored as well as what controls what built-in command(s), global input options (flags), and [ACON::Helper](/Console/Helper/)s are available. In most cases it provides a good starting point, but may be extended/customized if needed. ```crystal # Create an ACON::Application, passing it the name of your CLI. # Optionally accepts a second argument representing the version of the application. application = ACON::Application.new "My CLI" # Register commands using the `#add` method application.add CreateUserCommand.new # Or register a block as a command directly application.register "foo" do |input, output, cmd| # Do stuff ACON::Command::Status::SUCCESS end # Run the application. # By default this uses STDIN and STDOUT for its input and output. application.run ``` ### Entrypoint The console component best works in conjunction with a dedicated Crystal file that'll be used as the entry point. Ideally this file is compiled into a dedicated binary for use in production, but is invoked directly while developing. Otherwise, any changes made to the files it requires would not be represented. The most basic example would be: ``` #!/usr/bin/env crystal # Require the component and anything extra needed based on your business logic. require "athena-console" application = ACON::Application.new "My CLI" # Add any commands defined externally, # or configure/customize the application as needed. application.run ``` The [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) allows executing the file as a command without needing the `crystal` prefix. For example `./console list` would list all commands. ### Console Completion Athena's completion script can be installed to provide auto tab completion out of the box for command and option names, and values in some cases. The script currently supports the shells: `bash` (also requires the `bash-completion` package), `fish`, and `zsh`. Run `./console completion --help` for installation instructions based on your shell. NOTE: The completion script only needs to be installed _once_, but is specific to the binary used to generate it. E.g. `./console completion` would be scoped to the `console` binary, while `./myapp completion` would be scoped to `myapp`. Once installed, restart your terminal, and you should be good to go! WARNING: The completion script may only be used with real built binaries, not temporary ones such as `crystal run src/console.cr -- completion`. This is to ensure the performance of the script is sufficient, and to avoid any issues with the naming of the temporary binary. ## Learn More * Asking [ACON::Question](/Console/Question/)s * Reusable output [styles](/Console/Formatter/OutputStyleInterface/) * High level reusable formatting [styles](/Console/Style/Interface/) * [Testing abstractions](/Console/Spec/) * [Tab Completion](/Console/Input/Interface/#Athena::Console::Input::Interface--argumentoption-value-completion) * Rendering [ACON::Helper::Table](/Console/Helper/Table/)s, [ACON::Helper::ProgressBar](/Console/Helper/ProgressBar/)s, or [ACON::Helper::ProgressIndicator](/Console/Helper/ProgressIndicator/)s * The various [Verbosity Levels](/Console/Output/Verbosity/) ================================================ FILE: src/components/console/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Console site_url: https://athenaframework.org/Console/ repo_url: https://github.com/athena-framework/console nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-clock/src/athena-clock.cr - ./lib/athena-console/src/athena-console.cr - ./lib/athena-console/src/spec.cr source_locations: lib/athena-console: https://github.com/athena-framework/console/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/console/shard.yml ================================================ name: athena-console version: 0.4.3 crystal: ~> 1.13 license: MIT repository: https://github.com/athena-framework/console documentation: https://athenaframework.org/Console description: | Allows the creation of CLI based commands. authors: - George Dietrich dependencies: athena-clock: github: athena-framework/clock version: ~> 0.3.0 ================================================ FILE: src/components/console/spec/application_spec.cr ================================================ require "./spec_helper" private class ProgramNameApplication < ACON::Application def initialize(name : String, @test_program_name : String, version : String = "UNKNOWN") super(name, version) end protected def program_name : String @test_program_name end end struct ApplicationTest < ASPEC::TestCase def tear_down : Nil ENV.delete "COLUMNS" ENV.delete "SHELL_VERBOSITY" end protected def assert_file_equals_string(filepath : String, string : String, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil normalized_path = File.join __DIR__, "fixtures", filepath string.should match(Regex.new(File.read(normalized_path).gsub EOL, "\n")), file: file, line: line end protected def ensure_static_command_help(application : ACON::Application) : Nil application.each_command do |command| command.help = command.help.gsub("%command.full_name%", "console %command.name%") end end def test_defaults : Nil application = ACON::Application.new "foo", "1.0.0" application.auto_exit?.should be_true application.catch_exceptions?.should be_true end def test_long_version : Nil ACON::Application.new("foo", "1.2.3").long_version.should eq "foo 1.2.3" end def test_long_version_non_semver : Nil ACON::Application.new("foo", "2024.1.2").long_version.should eq "foo 2024.1.2" end def test_help : Nil ACON::Application.new("foo", "1.2.3").help.should eq "foo 1.2.3" end def test_commands : Nil app = ACON::Application.new "foo" commands = app.commands commands.keys.should eq ["help", "list", "completion", "_complete"] commands["help"].should be_a ACON::Commands::Help commands["list"].should be_a ACON::Commands::List app.add FooCommand.new commands = app.commands "foo" commands.size.should eq 1 end def test_commands_with_loader : Nil app = ACON::Application.new "foo" commands = app.commands commands["help"].should be_a ACON::Commands::Help commands["list"].should be_a ACON::Commands::List app.add FooCommand.new commands = app.commands "foo" commands.size.should eq 1 app.command_loader = ACON::Loader::Factory.new({ "foo:bar1" => -> { Foo1Command.new.as ACON::Command }, }) commands = app.commands "foo" commands.size.should eq 2 commands["foo:bar"].should be_a FooCommand commands["foo:bar1"].should be_a Foo1Command end def test_add : Nil app = ACON::Application.new "foo" app.add foo = FooCommand.new commands = app.commands commands["foo:bar"].should be foo # TODO: Add a splat/enumerable overload of #add ? end def test_has_get : Nil app = ACON::Application.new "foo" app.has?("list").should be_true app.has?("afoobar").should be_false app.add foo = FooCommand.new app.has?("afoobar").should be_true app.get("afoobar").should be foo app.get("foo:bar").should be foo app = ACON::Application.new "foo" app.add FooCommand.new pointerof(app.@wants_help).value = true app.get("foo:bar").should be_a ACON::Commands::Help end def test_has_get_with_loader : Nil app = ACON::Application.new "foo" app.has?("list").should be_true app.has?("afoobar").should be_false app.add foo = FooCommand.new app.has?("afoobar").should be_true app.get("foo:bar").should be foo app.get("afoobar").should be foo app.command_loader = ACON::Loader::Factory.new({ "foo:bar1" => -> { Foo1Command.new.as ACON::Command }, }) app.has?("afoobar").should be_true app.get("foo:bar").should be foo app.get("afoobar").should be foo app.has?("foo:bar1").should be_true (foo1 = app.get("foo:bar1")).should be_a Foo1Command app.has?("afoobar1").should be_true app.get("afoobar1").should be foo1 end def test_silent_help : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false tester = ACON::Spec::ApplicationTester.new app tester.run("-h": true, "-q": true, decorated: false) tester.display.should be_empty end def test_get_missing_command : Nil app = ACON::Application.new "foo" expect_raises ACON::Exception::CommandNotFound, "The command 'foofoo' does not exist." do app.get "foofoo" end end def test_namespaces : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.add Foo1Command.new app.namespaces.should eq ["foo"] end def test_find_namespace : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.find_namespace("foo").should eq "foo" app.find_namespace("f").should eq "foo" app.add Foo1Command.new app.find_namespace("foo").should eq "foo" end def test_find_namespace_subnamespaces : Nil app = ACON::Application.new "foo" app.add FooSubnamespaced1Command.new app.add FooSubnamespaced2Command.new app.find_namespace("foo").should eq "foo" end def test_find_namespace_ambiguous : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.add BarBucCommand.new app.add Foo2Command.new expect_raises ACON::Exception::NamespaceNotFound, "The namespace 'f' is ambiguous." do app.find_namespace "f" end end def test_find_namespace_invalid : Nil app = ACON::Application.new "foo" expect_raises ACON::Exception::NamespaceNotFound, "There are no commands defined in the 'bar' namespace." do app.find_namespace "bar" end end def test_find_namespace_does_not_fail_on_deep_similar_namespaces : Nil app = ACON::Application.new "foo" app.register "foo:sublong:bar" { ACON::Command::Status::SUCCESS } app.register "bar:sub:foo" { ACON::Command::Status::SUCCESS } app.find_namespace("f:sub").should eq "foo:sublong" end def test_find : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.find("foo:bar").should be_a FooCommand app.find("h").should be_a ACON::Commands::Help app.find("f:bar").should be_a FooCommand app.find("f:b").should be_a FooCommand app.find("a").should be_a FooCommand end def test_find_non_ambiguous : Nil app = ACON::Application.new "foo" app.add TestAmbiguousCommandRegistering.new app.add TestAmbiguousCommandRegistering2.new app.find("test").name.should eq "test-ambiguous" end def test_find_unique_name_but_namespace_name : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.add Foo1Command.new app.add Foo2Command.new expect_raises ACON::Exception::CommandNotFound, "Command 'foo1' is not defined." do app.find "foo1" end end def test_find_case_sensitive_first : Nil app = ACON::Application.new "foo" app.add FooSameCaseUppercaseCommand.new app.add FooSameCaseLowercaseCommand.new app.find("f:B").should be_a FooSameCaseUppercaseCommand app.find("f:BAR").should be_a FooSameCaseUppercaseCommand app.find("f:b").should be_a FooSameCaseLowercaseCommand app.find("f:bar").should be_a FooSameCaseLowercaseCommand end def test_find_case_insensitive_fallback : Nil app = ACON::Application.new "foo" app.add FooSameCaseLowercaseCommand.new app.find("f:b").should be_a FooSameCaseLowercaseCommand app.find("f:B").should be_a FooSameCaseLowercaseCommand app.find("fOO:bar").should be_a FooSameCaseLowercaseCommand end def test_find_case_insensitive_ambiguous : Nil app = ACON::Application.new "foo" app.add FooSameCaseUppercaseCommand.new app.add FooSameCaseLowercaseCommand.new expect_raises ACON::Exception::CommandNotFound, "Command 'FOO:bar' is ambiguous." do app.find "FOO:bar" end end def test_find_command_loader : Nil app = ACON::Application.new "foo" app.command_loader = ACON::Loader::Factory.new({ "foo:bar" => -> { FooCommand.new.as ACON::Command }, }) app.find("foo:bar").should be_a FooCommand app.find("h").should be_a ACON::Commands::Help app.find("f:bar").should be_a FooCommand app.find("f:b").should be_a FooCommand app.find("a").should be_a FooCommand end @[DataProvider("ambiguous_abbreviations_provider")] def test_find_ambiguous_abbreviations(abbreviation, expected_message) : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.add Foo1Command.new app.add Foo2Command.new expect_raises ACON::Exception::CommandNotFound, expected_message do app.find abbreviation end end def ambiguous_abbreviations_provider : Tuple { {"f", "Command 'f' is not defined."}, {"a", "Command 'a' is ambiguous."}, {"foo:b", "Command 'foo:b' is ambiguous."}, } end def test_find_ambiguous_abbreviations_finds_command_if_alternatives_are_hidden : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.add FooHiddenCommand.new app.find("foo:").should be_a FooCommand end def test_find_command_equal_namespace app = ACON::Application.new "foo" app.add Foo3Command.new app.add Foo4Command.new app.find("foo3:bar").should be_a Foo3Command app.find("foo3:bar:toh").should be_a Foo4Command end def test_find_ambiguous_namespace_but_unique_name app = ACON::Application.new "foo" app.add FooCommand.new app.add FooBarCommand.new app.find("f:f").should be_a FooBarCommand end def test_find_missing_namespace app = ACON::Application.new "foo" app.add Foo4Command.new app.find("f::t").should be_a Foo4Command end @[DataProvider("invalid_command_names_single_provider")] def test_find_alternative_exception_message_single(name) : Nil app = ACON::Application.new "foo" app.add Foo3Command.new expect_raises ACON::Exception::CommandNotFound, "Did you mean this?" do app.find name end end def invalid_command_names_single_provider : Tuple { {"foo3:barr"}, {"fooo3:bar"}, } end def test_doesnt_run_alternative_namespace_name : Nil app = ACON::Application.new "foo" app.add Foo1Command.new app.auto_exit = false tester = ACON::Spec::ApplicationTester.new app tester.run command: "foos:bar1", decorated: false self.assert_file_equals_string "text/application_alternative_namespace.txt", tester.display true end def test_run_alternate_command_name : Nil app = ACON::Application.new "foo" app.add FooWithoutAliasCommand.new app.auto_exit = false tester = ACON::Spec::ApplicationTester.new app tester.inputs = ["y"] tester.run command: "foos", decorated: false output = tester.display.strip output.should contain "Command 'foos' is not defined" output.should contain "Do you want to run 'foo' instead? (yes/no) [no]:" output.should contain "execute called" end def test_dont_run_alternate_command_name : Nil app = ACON::Application.new "foo" app.add FooWithoutAliasCommand.new app.auto_exit = false tester = ACON::Spec::ApplicationTester.new app tester.inputs = ["n"] tester.run(command: "foos", decorated: false).should eq ACON::Command::Status::FAILURE output = tester.display.strip output.should contain "Command 'foos' is not defined" output.should contain "Do you want to run 'foo' instead? (yes/no) [no]:" end def test_run_namespace : Nil ENV["COLUMNS"] = "120" app = ACON::Application.new "foo" app.auto_exit = false app.add FooCommand.new app.add Foo1Command.new app.add Foo2Command.new tester = ACON::Spec::ApplicationTester.new app tester.run(command: "foo", decorated: false) # .should eq ACON::Command::Status::FAILURE output = tester.display true output.should contain "Available commands for the 'foo' namespace:" output.should contain "The foo:bar command" output.should contain "The foo:bar1 command" end def test_run_with_find_error : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false app.command_loader = MockCommandLoader.new( command_or_exception: FooCommand.new, has: false, names: ::Exception.new("Oh noes") ) expect_raises ::Exception, "Oh noes" do ACON::Spec::ApplicationTester.new(app).run command: "blah" end end def test_find_alternative_exception_message_multiple : Nil ENV["COLUMNS"] = "120" app = ACON::Application.new "foo" app.add FooCommand.new app.add Foo1Command.new app.add Foo2Command.new # Command + plural ex = expect_raises ACON::Exception::CommandNotFound do app.find "foo:BAR" end message = ex.message.should_not be_nil message.should contain "Did you mean one of these?" message.should contain "foo1:bar" message.should contain "foo:bar" # Namespace + plural ex = expect_raises ACON::Exception::CommandNotFound do app.find "foo2:bar" end message = ex.message.should_not be_nil message.should contain "Did you mean one of these?" message.should contain "foo1" app.add Foo3Command.new app.add Foo4Command.new # Subnamespace + plural ex = expect_raises ACON::Exception::CommandNotFound do app.find "foo3:" end message = ex.message.should_not be_nil message.should contain "foo3:bar" message.should contain "foo3:bar:toh" end def test_find_alternative_commands : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.add Foo1Command.new app.add Foo2Command.new ex = expect_raises ACON::Exception::CommandNotFound do app.find "Unknown command" end ex.alternatives.should be_empty ex.message.should eq "Command 'Unknown command' is not defined." # Test if "bar1" command throw a "CommandNotFoundException" and does not contain # "foo:bar" as alternative because "bar1" is too far from "foo:bar" ex = expect_raises ACON::Exception::CommandNotFound do app.find "bar1" end ex.alternatives.should eq ["afoobar1", "foo:bar1"] message = ex.message.should_not be_nil message.should contain "Command 'bar1' is not defined" message.should contain "afoobar1" message.should contain "foo:bar1" message.should_not match /foo:bar(?!1)/ end def test_find_alternative_commands_with_alias : Nil foo_command = FooCommand.new foo_command.aliases = ["foo2"] app = ACON::Application.new "foo" app.command_loader = ACON::Loader::Factory.new({ "foo3" => -> { foo_command.as ACON::Command }, }) app.add foo_command app.find("foo").should be foo_command end def test_find_alternate_namespace : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.add Foo1Command.new app.add Foo2Command.new app.add Foo3Command.new ex = expect_raises ACON::Exception::CommandNotFound, "There are no commands defined in the 'Unknown-namespace' namespace." do app.find "Unknown-namespace:Unknown-command" end ex.alternatives.should be_empty ex = expect_raises ACON::Exception::CommandNotFound do app.find "foo2:command" end ex.alternatives.should eq ["foo", "foo1", "foo3"] message = ex.message.should_not be_nil message.should contain "There are no commands defined in the 'foo2' namespace." message.should contain "foo" message.should contain "foo1" message.should contain "foo3" end def test_find_alternates_output : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.add Foo1Command.new app.add Foo2Command.new app.add Foo3Command.new app.add FooHiddenCommand.new expect_raises ACON::Exception::CommandNotFound, "There are no commands defined in the 'Unknown-namespace' namespace." do app.find "Unknown-namespace:Unknown-command" end.alternatives.should be_empty expect_raises ACON::Exception::CommandNotFound, /Command 'foo' is not defined\..*Did you mean one of these\?.*/m do app.find "foo" end.alternatives.should eq ["afoobar", "afoobar1", "afoobar2", "foo1:bar", "foo3:bar", "foo:bar", "foo:bar1"] end def test_find_double_colon_doesnt_find_command : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.add Foo4Command.new expect_raises ACON::Exception::CommandNotFound, "Command 'foo::bar' is not defined." do app.find "foo::bar" end end def test_find_hidden_command_exact_name : Nil app = ACON::Application.new "foo" app.add FooHiddenCommand.new app.find("foo:hidden").should be_a FooHiddenCommand app.find("afoohidden").should be_a FooHiddenCommand end def test_find_ambiguous_commands_if_all_alternatives_are_hidden : Nil app = ACON::Application.new "foo" app.add FooCommand.new app.add FooHiddenCommand.new app.find("foo:").should be_a FooCommand end def test_set_catch_exceptions : Nil app = ACON::Application.new "foo" app.auto_exit = false ENV["COLUMNS"] = "120" tester = ACON::Spec::ApplicationTester.new app app.catch_exceptions = true tester.run command: "foo", decorated: false self.assert_file_equals_string "text/application_renderexception1.txt", tester.display true tester.run command: "foo", decorated: false, capture_stderr_separately: true self.assert_file_equals_string "text/application_renderexception1.txt", tester.error_output true tester.display.should be_empty app.catch_exceptions = false expect_raises Exception, "Command 'foo' is not defined." do tester.run command: "foo", decorated: false end end def test_render_exception : Nil app = ACON::Application.new "foo" app.auto_exit = false ENV["COLUMNS"] = "120" tester = ACON::Spec::ApplicationTester.new app tester.run command: "foo", decorated: false, verbosity: :quiet, capture_stderr_separately: true self.assert_file_equals_string "text/application_renderexception1.txt", tester.error_output true tester.run command: "foo", decorated: false, verbosity: :verbose, capture_stderr_separately: true tester.error_output.should contain "Exception trace" tester.run command: "foo", decorated: false, verbosity: :silent, capture_stderr_separately: true tester.error_output(true).should be_empty tester.run command: "list", "--foo": true, decorated: false, capture_stderr_separately: true self.assert_file_equals_string "text/application_renderexception2.txt", tester.error_output true app.add Foo3Command.new tester = ACON::Spec::ApplicationTester.new app tester.run command: "foo3:bar", decorated: false, capture_stderr_separately: true self.assert_file_equals_string "text/application_renderexception3.txt", tester.error_output true tester.run({"command" => "foo3:bar"}, decorated: false, verbosity: :verbose) tester.display(true).should match /\[Exception\]\s*First exception/ tester.display(true).should match /\[Exception\]\s*Second exception/ tester.display(true).should match /\[Exception\]\s*Third exception/ tester.run command: "foo3:bar", decorated: true self.assert_file_equals_string "text/application_renderexception3_decorated.txt", tester.display true tester.run command: "foo3:bar", decorated: true, capture_stderr_separately: true self.assert_file_equals_string "text/application_renderexception3_decorated.txt", tester.error_output true app = ACON::Application.new "foo" app.auto_exit = false ENV["COLUMNS"] = "32" tester = ACON::Spec::ApplicationTester.new app tester.run command: "foo", decorated: false, capture_stderr_separately: true self.assert_file_equals_string "text/application_renderexception4.txt", tester.error_output true ENV["COLUMNS"] = "120" end def ptest_render_exception_double_width_characters : Nil app = ACON::Application.new "foo" app.auto_exit = false ENV["COLUMNS"] = "120" tester = ACON::Spec::ApplicationTester.new app app.register "foo" do raise "エラーメッセージ" end tester.run command: "foo", decorated: false, capture_stderr_separately: true tester.error_output.should eq RENDER_EXCEPTION_DOUBLE_WIDTH end def test_render_exception_escapes_lines : Nil app = ACON::Application.new "foo" app.auto_exit = false ENV["COLUMNS"] = "22" app.register "foo" do raise "dont break here !" end tester = ACON::Spec::ApplicationTester.new app tester.run command: "foo", decorated: false self.assert_file_equals_string "text/application_renderexception_escapeslines.txt", tester.display true ENV["COLUMNS"] = "120" end def test_render_exception_line_breaks : Nil app = ACON::Application.new "foo" app.auto_exit = false ENV["COLUMNS"] = "120" app.register "foo" do raise "\n\nline 1 with extra spaces \nline 2\n\nline 4\n" end tester = ACON::Spec::ApplicationTester.new app tester.run command: "foo", decorated: false self.assert_file_equals_string "text/application_renderexception_linebreaks.txt", tester.display true end def test_render_exception_escapes_lines_of_synopsis : Nil app = ACON::Application.new "foo" app.auto_exit = false app.register "foo" do raise "some exception" end.argument "info" tester = ACON::Spec::ApplicationTester.new app tester.run command: "foo", decorated: false self.assert_file_equals_string "text/application_renderexception_synopsis_escapeslines.txt", tester.display true end def test_run_passes_io_thru : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false app.add command = Foo1Command.new input = ACON::Input::Hash.new({"command" => "foo:bar1"}) output = ACON::Output::IO.new IO::Memory.new app.run input, output command.input.should eq input command.output.should eq output end def test_run_default_command : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false self.ensure_static_command_help app tester = ACON::Spec::ApplicationTester.new app tester.run decorated: false self.assert_file_equals_string "text/application_run1.txt", tester.display true end def test_run_help_command : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false self.ensure_static_command_help app tester = ACON::Spec::ApplicationTester.new app tester.run "--help": true, decorated: false self.assert_file_equals_string "text/application_run2.txt", tester.display true tester.run "-h": true, decorated: false self.assert_file_equals_string "text/application_run2.txt", tester.display true end def test_run_help_list_command : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false self.ensure_static_command_help app tester = ACON::Spec::ApplicationTester.new app tester.run command: "list", "--help": true, decorated: false self.assert_file_equals_string "text/application_run3.txt", tester.display true tester.run command: "list", "-h": true, decorated: false self.assert_file_equals_string "text/application_run3.txt", tester.display true end def test_run_ansi : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false tester = ACON::Spec::ApplicationTester.new app tester.run "--ansi": true tester.output.decorated?.should be_true tester.run "--no-ansi": true tester.output.decorated?.should be_false end def test_run_version : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false tester = ACON::Spec::ApplicationTester.new app tester.run "--version": true, decorated: false self.assert_file_equals_string "text/application_run4.txt", tester.display true tester.run "-V": true, decorated: false self.assert_file_equals_string "text/application_run4.txt", tester.display true end def test_run_quest : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false tester = ACON::Spec::ApplicationTester.new app tester.run command: "list", "--quiet": true, decorated: false tester.display.should be_empty tester.input.interactive?.should be_false tester.run command: "list", "-q": true, decorated: false tester.display.should be_empty tester.input.interactive?.should be_false end def test_run_verbosity : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false self.ensure_static_command_help app tester = ACON::Spec::ApplicationTester.new app tester.run command: "list", "--verbose": true, decorated: false tester.output.verbosity.should eq ACON::Output::Verbosity::VERBOSE tester.run command: "list", "--verbose": 1, decorated: false tester.output.verbosity.should eq ACON::Output::Verbosity::VERBOSE tester.run command: "list", "--verbose": 2, decorated: false tester.output.verbosity.should eq ACON::Output::Verbosity::VERY_VERBOSE tester.run command: "list", "--verbose": 3, decorated: false tester.output.verbosity.should eq ACON::Output::Verbosity::DEBUG tester.run command: "list", "--verbose": 4, decorated: false tester.output.verbosity.should eq ACON::Output::Verbosity::VERBOSE tester.run command: "list", "-v": true, decorated: false tester.output.verbosity.should eq ACON::Output::Verbosity::VERBOSE tester.run command: "list", "-vv": true, decorated: false tester.output.verbosity.should eq ACON::Output::Verbosity::VERY_VERBOSE tester.run command: "list", "-vvv": true, decorated: false tester.output.verbosity.should eq ACON::Output::Verbosity::DEBUG end def test_run_help_help_command : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false self.ensure_static_command_help app tester = ACON::Spec::ApplicationTester.new app tester.run command: "help", "--help": true, decorated: false self.assert_file_equals_string "text/application_run5.txt", tester.display true tester.run command: "help", "-h": true, decorated: false self.assert_file_equals_string "text/application_run5.txt", tester.display true end def test_run_no_interaction : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false app.add FooCommand.new tester = ACON::Spec::ApplicationTester.new app tester.run command: "foo:bar", "--no-interaction": true, decorated: false tester.display.should eq "execute called#{EOL}" tester.run command: "foo:bar", "-n": true, decorated: false tester.display.should eq "execute called#{EOL}" end def test_run_global_option_and_no_command : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false app.definition << ACON::Input::Option.new "foo", "f", :optional input = ACON::Input::ARGV.new ["--foo", "bar"] app.run(input, ACON::Output::Null.new).should eq ACON::Command::Status::SUCCESS end def test_run_verbose_value_doesnt_break_arguments : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false app.add FooCommand.new output = ACON::Output::IO.new IO::Memory.new input = ACON::Input::ARGV.new ["-v", "foo:bar"] app.run(input, output).should eq ACON::Command::Status::SUCCESS input = ACON::Input::ARGV.new ["--verbose", "foo:bar"] app.run(input, output).should eq ACON::Command::Status::SUCCESS end def test_run_returns_status_with_custom_code_on_exception : Nil app = ACON::Application.new "foo" app.auto_exit = false app.register "foo" do raise ACON::Exception::Logic.new "", code: 5 end input = ACON::Input::Hash.new({"command" => "foo"}) app.run(input, ACON::Output::Null.new).value.should eq 5 end def test_run_returns_failure_status_on_exception : Nil app = ACON::Application.new "foo" app.auto_exit = false app.register "foo" do raise "" end input = ACON::Input::Hash.new({"command" => "foo"}) app.run(input, ACON::Output::Null.new).value.should eq 1 end def test_add_option_duplicate_shortcut : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false app.definition << ACON::Input::Option.new "--env", "-e", :required, "Environment" app.register "foo" do ACON::Command::Status::SUCCESS end .aliases("f") .definition( ACON::Input::Option.new("survey", "e", :required, "Option with shortcut") ) input = ACON::Input::Hash.new({"command" => "foo"}) expect_raises ACON::Exception::Logic, "An option with shortcut 'e' already exists." do app.run input, ACON::Output::Null.new end end @[DataProvider("already_set_definition_element_provider")] def test_adding_already_set_definition_element(element) : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false app.register "foo" do ACON::Command::Status::SUCCESS end .definition(element) input = ACON::Input::Hash.new({"command" => "foo"}) expect_raises ACON::Exception::Logic do app.run input, ACON::Output::Null.new end end def already_set_definition_element_provider : Tuple { {ACON::Input::Argument.new("command", :required)}, {ACON::Input::Option.new("quiet", value_mode: :none)}, {ACON::Input::Option.new("query", "q", :none)}, } end def test_helper_set_contains_default_helpers : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false helper_set = app.helper_set helper_set.has?(ACON::Helper::Question).should be_true helper_set.has?(ACON::Helper::Formatter).should be_true end def test_adding_single_helper_overwrites_default : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false app.helper_set = ACON::Helper::HelperSet.new(ACON::Helper::Formatter.new) helper_set = app.helper_set helper_set.has?(ACON::Helper::Question).should be_false helper_set.has?(ACON::Helper::Formatter).should be_true end def test_default_input_definition_returns_default_values : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false definition = app.definition definition.has_argument?("command").should be_true definition.has_option?("help").should be_true definition.has_option?("quiet").should be_true definition.has_option?("verbose").should be_true definition.has_option?("version").should be_true definition.has_option?("ansi").should be_true definition.has_option?("no-interaction").should be_true definition.has_negation?("no-ansi").should be_true definition.has_option?("no-ansi").should be_false end # TODO: Test custom application type's helper set. def test_setting_custom_input_definition_overrides_default_values : Nil app = ACON::Application.new "foo" app.auto_exit = false app.catch_exceptions = false app.definition = ACON::Input::Definition.new( ACON::Input::Option.new "--custom", "-c", :none, "Set the custom input definition" ) definition = app.definition definition.has_argument?("command").should be_false definition.has_option?("help").should be_false definition.has_option?("quiet").should be_false definition.has_option?("verbose").should be_false definition.has_option?("version").should be_false definition.has_option?("ansi").should be_false definition.has_option?("no-interaction").should be_false definition.has_negation?("no-ansi").should be_false definition.has_option?("custom").should be_true end # TODO: Add dispatcher related specs def test_run_custom_default_command : Nil app = ACON::Application.new "foo" app.auto_exit = false app.add command = FooCommand.new app.default_command command.name tester = ACON::Spec::ApplicationTester.new app tester.run interactive: false tester.display.should eq "execute called#{EOL}" # TODO: Test custom application default. end def test_run_custom_default_command_with_option : Nil app = ACON::Application.new "foo" app.auto_exit = false app.add command = FooOptCommand.new app.default_command command.name tester = ACON::Spec::ApplicationTester.new app tester.run "--fooopt": "opt", interactive: false tester.display.should eq "execute called#{EOL}opt#{EOL}" end def test_run_custom_single_default_command : Nil app = ACON::Application.new "foo" app.auto_exit = false app.add command = FooOptCommand.new app.default_command command.name, true tester = ACON::Spec::ApplicationTester.new app tester.run tester.display.should contain "execute called" tester.run "--help": true tester.display.should contain "The foo:bar command" end def test_find_alternative_does_not_load_same_namespace_commands_on_exact_match : Nil app = ACON::Application.new "foo" app.auto_exit = false loaded = Hash(String, Bool).new app.command_loader = ACON::Loader::Factory.new({ "foo:bar" => -> do loaded["foo:bar"] = true ACON::Commands::Generic.new("foo:bar") { ACON::Command::Status::SUCCESS }.as ACON::Command end, "foo" => -> do loaded["foo"] = true ACON::Commands::Generic.new("foo") { ACON::Command::Status::SUCCESS }.as ACON::Command end, }) app.run ACON::Input::Hash.new({"command" => "foo"}), ACON::Output::Null.new loaded.should eq({"foo" => true}) end def test_command_name_mismatch_with_command_loader_raises : Nil app = ACON::Application.new "foo" app.command_loader = ACON::Loader::Factory.new({ "foo" => -> { ACON::Commands::Generic.new("bar") { ACON::Command::Status::SUCCESS }.as ACON::Command }, }) expect_raises ACON::Exception::CommandNotFound, "The 'foo' command cannot be found because it is registered under multiple names. Make sure you don't set a different name via constructor or 'name='." do app.get "foo" end end def test_use_program_name_as_command_runs_matching_command : Nil app = ProgramNameApplication.new "foo", test_program_name: "mycommand" app.auto_exit = false app.use_program_name_as_command = true app.register("mycommand") { |_, output| output.puts "mycommand executed"; ACON::Command::Status::SUCCESS } tester = ACON::Spec::ApplicationTester.new app tester.run tester.display.should contain "mycommand executed" end def test_use_program_name_as_command_falls_back_when_no_match : Nil app = ProgramNameApplication.new "foo", test_program_name: "nonexistent" app.auto_exit = false app.use_program_name_as_command = true tester = ACON::Spec::ApplicationTester.new app tester.run # Should fall back to default command (list) tester.display.should contain "Available commands:" end def test_use_program_name_as_command_takes_precedence_over_arguments : Nil app = ProgramNameApplication.new "foo", test_program_name: "mycommand" app.auto_exit = false app.use_program_name_as_command = true app.register("mycommand") do |input, output| output.puts "mycommand executed" output.puts "first arg: #{input.first_argument}" ACON::Command::Status::SUCCESS end.argument("arg", :optional) tester = ACON::Spec::ApplicationTester.new app tester.run input: {"arg" => "somevalue"} # Program name takes precedence - "somevalue" becomes an argument, not a command tester.display.should contain "mycommand executed" tester.display.should contain "first arg: somevalue" end def test_use_program_name_as_command_disabled_ignores_program_name : Nil app = ProgramNameApplication.new "foo", test_program_name: "mycommand" app.auto_exit = false app.use_program_name_as_command = false app.register("mycommand") { |_, output| output.puts "mycommand executed"; ACON::Command::Status::SUCCESS } tester = ACON::Spec::ApplicationTester.new app tester.run # Should show list since feature is disabled tester.display.should contain "Available commands:" tester.display.should_not contain "mycommand executed" end def test_use_program_name_as_command_single_command_takes_precedence : Nil app = ProgramNameApplication.new "foo", test_program_name: "mycommand" app.auto_exit = false app.use_program_name_as_command = true app.register("mycommand") { |_, output| output.puts "mycommand executed"; ACON::Command::Status::SUCCESS } app.register("singlecmd") { |_, output| output.puts "singlecmd executed"; ACON::Command::Status::SUCCESS } app.default_command "singlecmd", true tester = ACON::Spec::ApplicationTester.new app tester.run # single_command mode should take precedence tester.display.should contain "singlecmd executed" tester.display.should_not contain "mycommand executed" end end ================================================ FILE: src/components/console/spec/application_tester_spec.cr ================================================ require "./spec_helper" struct ApplicationTesterTest < ASPEC::TestCase @app : ACON::Application @tester : ACON::Spec::ApplicationTester def initialize @app = ACON::Application.new "foo" @app.auto_exit = false @app.register "foo" do |_, output| output.puts "foo" ACON::Command::Status::SUCCESS end.argument "foo" @tester = ACON::Spec::ApplicationTester.new @app @tester.run command: "foo", foo: "bar", interactive: false, decorated: false, verbosity: :verbose end def test_run : Nil @tester.input.interactive?.should be_false @tester.output.decorated?.should be_false @tester.output.verbosity.verbose?.should be_true end def test_input : Nil @tester.input.argument("foo").should eq "bar" end def test_output : Nil @tester.output.to_s.should eq "foo#{EOL}" end def test_display : Nil @tester.display.to_s.should eq "foo#{EOL}" end def test_status : Nil @tester.status.should eq ACON::Command::Status::SUCCESS end def test_inputs : Nil app = ACON::Application.new "foo" app.auto_exit = false app.register "foo" do |input, output| helper = ACON::Helper::Question.new helper.ask input, output, ACON::Question(String?).new "Q1", nil helper.ask input, output, ACON::Question(String?).new "Q2", nil helper.ask input, output, ACON::Question(String?).new "Q3", nil ACON::Command::Status::SUCCESS end tester = ACON::Spec::ApplicationTester.new app tester.inputs = ["A1", "A2", "A3"] tester.run command: "foo" tester.status.should eq ACON::Command::Status::SUCCESS tester.display.should eq "Q1Q2Q3" end def test_error_output : Nil app = ACON::Application.new "foo" app.auto_exit = false app.register "foo" do |_, output| output.as(ACON::Output::ConsoleOutput).error_output.print "foo" ACON::Command::Status::SUCCESS end.argument "foo" tester = ACON::Spec::ApplicationTester.new app tester.run command: "foo", foo: "bar", capture_stderr_separately: true tester.error_output.should eq "foo" end end ================================================ FILE: src/components/console/spec/command_spec.cr ================================================ require "./spec_helper" abstract class ACON::Command def merge_application_definition(merge_args : Bool = true) : Nil previous_def end end describe ACON::Command do describe ".new" do describe "when configured via annotation" do it "sets name and description" do command = AnnotationConfiguredCommand.new command.name.should eq "annotation:configured" command.description.should eq "Command configured via annotation" command.hidden?.should be_false command.aliases.should eq ["ac"] end it "sets the command as hidden if its name is an empty string" do command = AnnotationConfiguredHiddenCommand.new command.name.should eq "annotation:configured" command.hidden?.should be_true command.aliases.should be_empty end it "sets the command as hidden if that field is true" do command = AnnotationConfiguredHiddenFieldCommand.new command.name.should eq "annotation:configured" command.hidden?.should be_true command.aliases.should be_empty end it "sets aliases" do command = AnnotationConfiguredAliasesCommand.new command.name.should eq "annotation:configured" command.hidden?.should be_false command.aliases.should eq ["ac"] end end it "prioritizes constructor args" do command = AnnotationConfiguredCommand.new "cv" command.name.should eq "cv" command.description.should eq "Command configured via annotation" end it "raises on invalid name" do expect_raises ACON::Exception::InvalidArgument, "Command name '' is invalid." do AnnotationConfiguredCommand.new "" end expect_raises ACON::Exception::InvalidArgument, "Command name ' ' is invalid." do AnnotationConfiguredCommand.new " " end expect_raises ACON::Exception::InvalidArgument, "Command name 'foo:' is invalid." do AnnotationConfiguredCommand.new "foo:" end end end describe "#application=" do it "sets the helper_set and application" do app = ACON::Application.new "foo" command = TestCommand.new command.application = app command.application.should be app command.helper_set.should be app.helper_set end it "clears out the command's helper_set when clearing out the application" do command = TestCommand.new command.application = nil command.helper_set.should be_nil end end describe "get/set definition" do command = TestCommand.new command.definition definition = ACON::Input::Definition.new command.definition.should be definition command.definition ACON::Input::Argument.new("foo"), ACON::Input::Option.new("bar") command.definition.has_argument?("foo").should be_true command.definition.has_option?("bar").should be_true end describe "#argument" do it "basic form" do command = TestCommand.new command.argument "foo" command.definition.has_argument?("foo").should be_true end describe "suggested values" do it "array" do command = TestCommand.new command.argument "foo", suggested_values: {"a", "b", "c"} command.definition.has_argument?("foo").should be_true command.definition.argument("foo").has_completion?.should be_true end it "block" do command = TestCommand.new command.argument "foo" do ["a", "b"] end command.definition.has_argument?("foo").should be_true command.definition.argument("foo").has_completion?.should be_true end end end describe "#option" do it "basic form" do command = TestCommand.new command.option "bar" command.definition.has_option?("bar").should be_true end describe "suggested values" do it "array" do command = TestCommand.new command.option "foo", value_mode: :required, suggested_values: {"a", "b", "c"} command.definition.has_option?("foo").should be_true command.definition.option("foo").has_completion?.should be_true end it "block" do command = TestCommand.new command.option "foo", value_mode: :required do ["a", "b"] end command.definition.has_option?("foo").should be_true command.definition.option("foo").has_completion?.should be_true end end end describe "#processed_help" do it "replaces placeholders correctly" do command = TestCommand.new command.help = "The %command.name% command does... Example: %command.full_name%." command.processed_help.should start_with "The namespace:name command does" command.processed_help.should_not contain "%command.full_name%" end it "falls back on the description" do command = TestCommand.new command.help = "" command.processed_help.should eq "description" end end describe "#synopsis" do it "long" do TestCommand.new.option("foo").argument("bar").argument("info").synopsis.should eq "namespace:name [--foo] [--] [ []]" end it "short" do TestCommand.new.option("foo").argument("bar").synopsis(true).should eq "namespace:name [options] [--] []" end end describe "#usages" do it "that starts with the command's name" do TestCommand.new.usage("namespace:name foo").usages.should contain "namespace:name foo" end it "that doesn't include the command's name" do TestCommand.new.usage("bar").usages.should contain "namespace:name bar" end end # TODO: Does `#merge_application_definition` need explicit tests? describe "#run" do it "interactive" do tester = ACON::Spec::CommandTester.new TestCommand.new tester.execute interactive: true tester.display.should eq "interact called#{EOL}execute called#{EOL}" end it "non-interactive" do tester = ACON::Spec::CommandTester.new TestCommand.new tester.execute interactive: false tester.display.should eq "execute called#{EOL}" end it "invalid option" do tester = ACON::Spec::CommandTester.new TestCommand.new expect_raises ACON::Exception::InvalidOption, "The '--bar' option does not exist." do tester.execute "--bar": true end end end end ================================================ FILE: src/components/console/spec/command_tester_spec.cr ================================================ require "./spec_helper" struct CommandTesterTest < ASPEC::TestCase @command : ACON::Command @tester : ACON::Spec::CommandTester def initialize @command = ACON::Commands::Generic.new "foo" do |_, output| output.puts "foo" ACON::Command::Status::SUCCESS end @command.argument "command" @command.argument "foo" @tester = ACON::Spec::CommandTester.new @command @tester.execute foo: "bar", interactive: false, decorated: false, verbosity: :verbose end def test_execute : Nil @tester.input.interactive?.should be_false @tester.output.decorated?.should be_false @tester.output.verbosity.verbose?.should be_true end def test_input : Nil @tester.input.argument("foo").should eq "bar" end def test_output : Nil @tester.output.to_s.should eq "foo#{EOL}" end def test_display : Nil @tester.display.to_s.should eq "foo#{EOL}" end def test_display_before_calling_execute : Nil tester = ACON::Spec::CommandTester.new ACON::Commands::Generic.new "foo" { ACON::Command::Status::SUCCESS } expect_raises ACON::Exception::Logic, "Output not initialized. Did you execute the command before requesting the display?" do tester.display end end def test_status_code : Nil @tester.status.should eq ACON::Command::Status::SUCCESS end def test_command_from_application : Nil app = ACON::Application.new "foo" app.auto_exit = false app.register "foo" { |_, output| output.puts "foo"; ACON::Command::Status::SUCCESS } tester = ACON::Spec::CommandTester.new app.find "foo" tester.execute.should eq ACON::Command::Status::SUCCESS end def test_command_with_inputs : Nil questions = { "What is your name?", "How are you?", "Where do you come from?", } command = ACON::Commands::Generic.new "foo" do |input, output, c| helper = c.helper ACON::Helper::Question helper.ask input, output, ACON::Question(String?).new questions[0], nil helper.ask input, output, ACON::Question(String?).new questions[1], nil helper.ask input, output, ACON::Question(String?).new questions[2], nil ACON::Command::Status::SUCCESS end command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new tester = ACON::Spec::CommandTester.new command tester.inputs = ["Bobby", "Fine", "Germany"] tester.execute tester.status.should eq ACON::Command::Status::SUCCESS tester.display.should eq questions.join end def test_command_with_inputs_with_defaults : Nil questions = { "What is your name?", "How are you?", "Where do you come from?", } command = ACON::Commands::Generic.new "foo" do |input, output, c| helper = c.helper ACON::Helper::Question helper.ask input, output, ACON::Question(String).new questions[0], "Bobby" helper.ask input, output, ACON::Question(String).new questions[1], "Fine" helper.ask input, output, ACON::Question(String).new questions[2], "Estonia" ACON::Command::Status::SUCCESS end command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new tester = ACON::Spec::CommandTester.new command tester.inputs = ["", "", ""] tester.execute tester.status.should eq ACON::Command::Status::SUCCESS tester.display.should eq questions.join end def test_command_with_inputs_wrong_input_amount : Nil questions = { "What is your name?", "How are you?", "Where do you come from?", } command = ACON::Commands::Generic.new "foo" do |input, output, c| helper = c.helper ACON::Helper::Question helper.ask input, output, ACON::Question::Choice.new "choice", {"a", "b"} helper.ask input, output, ACON::Question(String?).new questions[0], nil helper.ask input, output, ACON::Question(String?).new questions[1], nil helper.ask input, output, ACON::Question(String?).new questions[2], nil ACON::Command::Status::SUCCESS end command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new tester = ACON::Spec::CommandTester.new command tester.inputs = ["a", "Bobby", "Fine"] expect_raises ACON::Exception::MissingInput, "Aborted." do tester.execute end end def ptest_command_with_questions_but_no_input : Nil questions = { "What is your name?", "How are you?", "Where do you come from?", } command = ACON::Commands::Generic.new "foo" do |input, output, c| helper = c.helper ACON::Helper::Question helper.ask input, output, ACON::Question::Choice.new "choice", {"a", "b"} helper.ask input, output, ACON::Question(String?).new questions[0], nil helper.ask input, output, ACON::Question(String?).new questions[1], nil helper.ask input, output, ACON::Question(String?).new questions[2], nil ACON::Command::Status::SUCCESS end command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new tester = ACON::Spec::CommandTester.new command expect_raises ACON::Exception::MissingInput, "Aborted." do tester.execute end end def test_athena_style_command_with_inputs : Nil questions = { "What is your name?", "How are you?", "Where do you come from?", } command = ACON::Commands::Generic.new "foo" do |input, output| style = ACON::Style::Athena.new input, output style.ask ACON::Question(String?).new questions[0], nil style.ask ACON::Question(String?).new questions[1], nil style.ask ACON::Question(String?).new questions[2], nil ACON::Command::Status::SUCCESS end tester = ACON::Spec::CommandTester.new command tester.inputs = ["Bobby", "Fine", "France"] tester.execute.should eq ACON::Command::Status::SUCCESS end def test_error_output : Nil command = ACON::Commands::Generic.new "foo" do |_, output| output.as(ACON::Output::ConsoleOutput).error_output.print "foo" ACON::Command::Status::SUCCESS end command.argument "command" command.argument "foo" tester = ACON::Spec::CommandTester.new command tester.execute foo: "bar", capture_stderr_separately: true tester.error_output.should eq "foo" end def test_assert_command_is_not_successful : Nil command = ACON::Commands::Generic.new "foo" do |_, _| ACON::Command::Status::FAILURE end tester = ACON::Spec::CommandTester.new command tester.execute tester.assert_command_is_not_successful end end ================================================ FILE: src/components/console/spec/commands/complete_spec.cr ================================================ require "../spec_helper" @[ACONA::AsCommand("hello|ahoy", description: "Hello test command")] private class HelloCommand < ACON::Command protected def configure : Nil self .argument("name", :required) end def complete(input : ACON::Completion::Input, suggestions : ACON::Completion::Suggestions) : Nil if input.must_suggest_argument_values_for? "name" suggestions.suggest_values "Athena", "Crystal", "Ruby" end end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end struct CompleteCommandTest < ASPEC::TestCase @command : ACON::Commands::Complete @application : ACON::Application @tester : ACON::Spec::CommandTester def initialize @command = ACON::Commands::Complete.new @application = ACON::Application.new "TEST" @application.add HelloCommand.new @command.application = @application @tester = ACON::Spec::CommandTester.new @command end def test_required_shell_option : Nil expect_raises ACON::Exception::Runtime, "The '--shell' option must be set." do self.execute end end def test_unsupported_shell_option : Nil expect_raises ACON::Exception::Runtime, "Shell completion is not supported for your shell: 'unsupported' (supported: 'bash', 'fish', 'zsh')." do self.execute({"--shell" => "unsupported"}) end end def test_completes_command_name_with_loader : Nil @application.command_loader = ACON::Loader::Factory.new({ "foo:bar1" => -> { Foo1Command.new.as ACON::Command }, }) self.execute({"--current" => "0", "--input" => [] of String}) @tester.display.should eq "#{["help", "list", "completion", "hello", "ahoy", "foo:bar1", "afoobar1"].join("\n")}#{EOL}" end def test_additional_shell_support : Nil @command = ACON::Commands::Complete.new({"supported" => ACON::Completion::Output::Bash} of String => ACON::Completion::Output::Interface.class) @command.application = @application @tester = ACON::Spec::CommandTester.new @command self.execute({"--shell" => "supported", "--current" => "0", "--input" => [] of String}) # Default shell should still be supported self.execute({"--shell" => "bash", "--current" => "0", "--input" => [] of String}) end @[DataProvider("input_and_current_option_provider")] def test_input_and_current_option_validation(input : Hash(String, _), exception_message : String?) : Nil if exception_message expect_raises ::Exception, exception_message do self.execute input.merge!({"--shell" => "bash"}) end return end self.execute input.merge!({"--shell" => "bash"}) @tester.assert_command_is_successful end def input_and_current_option_provider : Tuple { {Hash(String, String).new, "The '--current' option must be set and it must be an integer"}, { {"--current" => "a"}, "The '--current' option must be set and it must be an integer" }, { {"--current" => "0", "--input" => [] of String}, nil }, { {"--current" => "2", "--input" => [] of String}, "Current index is invalid, it must be the number of input tokens." }, { {"--current" => "0", "--input" => [] of String}, nil }, { {"--current" => "1", "--input" => ["foo:bar"] of String}, nil }, { {"--current" => "2", "--input" => ["foo:bar", "bar"] of String}, nil }, } end @[DataProvider("provide_complete_command_name_inputs")] def test_completion_command_name(input : Array(String), suggestions : Array(String)) : Nil self.execute({"--current" => "0", "--input" => input}) @tester.display.should eq "#{suggestions.join("\n")}#{EOL}" end def provide_complete_command_name_inputs : Hash { "empty" => {[] of String, ["help", "list", "completion", "hello", "ahoy"]}, "partial" => {["he"], ["help", "list", "completion", "hello", "ahoy"]}, "complete shortcut name" => {["hell"], ["hello", "ahoy"]}, "complete alias" => {["ah"], ["hello", "ahoy"]}, } end @[DataProvider("provide_input_definition_inputs")] def test_completion_command_input_definitions(input : Array(String), suggestions : Array(String)) : Nil self.execute({"--current" => "1", "--input" => input}) @tester.display.should eq "#{suggestions.join("\n")}#{EOL}" end def provide_input_definition_inputs : Hash { "definition" => {["hello", "-"], ["--help", "--silent", "--quiet", "--verbose", "--version", "--ansi", "--no-ansi", "--no-interaction"]}, "custom" => {["hello"], ["Athena", "Crystal", "Ruby"]}, "aliased definition" => {["ahoy", "-"], ["--help", "--silent", "--quiet", "--verbose", "--version", "--ansi", "--no-ansi", "--no-interaction"]}, "aliased custom" => {["ahoy"], ["Athena", "Crystal", "Ruby"]}, } end private def execute(input : Hash(String, _) = {} of String => String) : Nil # Run in verbose mode to assert exceptions @tester.execute( (!input.empty? ? {"--shell" => "bash", "--api-version" => ACON::Commands::Complete::API_VERSION.to_s}.merge(input) : input), verbosity: ACON::Output::Verbosity::DEBUG ) end end ================================================ FILE: src/components/console/spec/commands/dump_completion_spec.cr ================================================ require "../spec_helper" struct DumpCompletionCommandTest < ASPEC::TestCase @[DataProvider("complete_provider")] def test_complete(input : Array(String), expected_suggestions : Array(String)) : Nil tester = ACON::Spec::CommandCompletionTester.new ACON::Commands::DumpCompletion.new suggestions = tester.complete input suggestions.should eq expected_suggestions end def complete_provider : Hash { "shell" => {[] of String, ["bash", "fish", "zsh"]}, } end end ================================================ FILE: src/components/console/spec/commands/help_spec.cr ================================================ require "../spec_helper" struct HelpCommandTest < ASPEC::TestCase def test_execute_alias : Nil command = ACON::Commands::Help.new command.application = ACON::Application.new "foo" tester = ACON::Spec::CommandTester.new command tester.execute command_name: "li", decorated: false tester.display.should contain "list [options] [--] []" tester.display.should contain "format=FORMAT" tester.display.should contain "raw" end def test_execute : Nil command = ACON::Commands::Help.new command.application = ACON::Application.new "foo" tester = ACON::Spec::CommandTester.new command tester.execute command_name: "li", decorated: false tester.display.should contain "list [options] [--] []" tester.display.should contain "format=FORMAT" tester.display.should contain "raw" end def test_execute_application_command : Nil app = ACON::Application.new "foo" tester = ACON::Spec::CommandTester.new app.get "help" tester.execute command_name: "list" tester.display.should contain "list [options] [--] []" tester.display.should contain "format=FORMAT" tester.display.should contain "raw" end @[DataProvider("complete_provider")] def test_complete(input : Array(String), expected_suggestions : Array(String)) : Nil app = ACON::Application.new "foo" app.add FooCommand.new tester = ACON::Spec::CommandCompletionTester.new app.get "help" suggestions = tester.complete input suggestions.should eq expected_suggestions end def complete_provider : Hash { "long option" => {["--format"], ["txt"]}, "nothing" => {[] of String, ["completion", "help", "list", "foo:bar"]}, "command name" => {["f"], ["completion", "help", "list", "foo:bar"]}, } end end ================================================ FILE: src/components/console/spec/commands/lazy_spec.cr ================================================ require "../spec_helper" @[ACONA::AsCommand("blahhhh")] private class MockCommand < ACON::Command protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end describe ACON::Commands::Lazy do it "applies metadata to the instantiated command" do lazy_command = ACON::Commands::Lazy.new "cmd_name", ["foo", "bar"], "description", true, -> { MockCommand.new.as ACON::Command } command = lazy_command.command command.should be_a MockCommand command.name.should eq "cmd_name" command.aliases.should eq ["foo", "bar"] command.description.should eq "description" command.hidden?.should be_true end it "forwards methods to the wrapped command instance" do mock_command = MockCommand.new lazy_command = ACON::Commands::Lazy.new "cmd_name", ["foo", "bar"], "description", true, -> { mock_command.as ACON::Command } command = lazy_command.command command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new command.process_title "title" command.usage "usages" command.argument "name" command.option "active" command.definition.should eq mock_command.definition command.help.should eq mock_command.help command.processed_help.should eq mock_command.processed_help command.synopsis.should eq mock_command.synopsis command.usages.should eq mock_command.usages command.helper(ACON::Helper::Question).should eq mock_command.helper(ACON::Helper::Question) end it "is runnable" do command = MockCommand.new command.application = ACON::Application.new "foo" tester = ACON::Spec::CommandTester.new command tester.execute.should eq ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/commands/list_spec.cr ================================================ require "../spec_helper" private def normalize(input : String) : String input.gsub EOL, "\n" end struct ListCommandTest < ASPEC::TestCase def test_execute_lists_commands : Nil app = ACON::Application.new "foo" tester = ACON::Spec::CommandTester.new app.get("list") tester.execute command: "list", decorated: false tester.display.should match /help\s{2,}Display help for a command/ end def test_with_raw_option : Nil app = ACON::Application.new "foo" tester = ACON::Spec::CommandTester.new app.get("list") tester.execute command: "list", "--raw": true tester.display.should eq "completion Dump the shell completion script\nhelp Display help for a command\nlist List available commands\n" end def test_with_namespace_argument : Nil app = ACON::Application.new "foo" app.add FooCommand.new tester = ACON::Spec::CommandTester.new app.get("list") tester.execute command: "list", namespace: "foo", "--raw": true tester.display.should eq "foo:bar The foo:bar command\n" end def test_lists_command_in_expected_order : Nil app = ACON::Application.new "foo" app.add Foo6Command.new tester = ACON::Spec::CommandTester.new app.get("list") tester.execute command: "list", decorated: false tester.display(true).should eq normalize <<-OUTPUT foo UNKNOWN Usage: command [options] [arguments] Options: -h, --help Display help for the given command. When no command is given display help for the list command --silent Do not output any message -q, --quiet Only errors are displayed. All other output is suppressed -V, --version Display this application version --ansi|--no-ansi Force (or disable --no-ansi) ANSI output -n, --no-interaction Do not ask any interactive question -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Available commands: completion Dump the shell completion script help Display help for a command list List available commands 0foo 0foo:bar 0foo:bar command\n OUTPUT end def test_lists_commands_in_expected_order_in_raw_mode : Nil app = ACON::Application.new "foo" app.add Foo6Command.new tester = ACON::Spec::CommandTester.new app.get("list") tester.execute command: "list", "--raw": true tester.display.should eq "completion Dump the shell completion script\nhelp Display help for a command\nlist List available commands\n0foo:bar 0foo:bar command\n" end @[DataProvider("complete_provider")] def test_complete(input : Array(String), expected_suggestions : Array(String)) : Nil app = ACON::Application.new "foo" app.add FooCommand.new tester = ACON::Spec::CommandCompletionTester.new app.get "list" suggestions = tester.complete input suggestions.should eq expected_suggestions end def test_complete_var_arg : Nil app = ACON::Application.new "foo" app.add FooCommand.new ACON::Spec::CommandCompletionTester .new(app.get "list") .complete("--format") .should eq ["txt"] end def complete_provider : Hash { "--format option" => {["--format"], ["txt"]}, "empty namespace" => {[] of String, ["_global", "foo"]}, "partial namespace" => {["f"], ["_global", "foo"]}, } end end ================================================ FILE: src/components/console/spec/compiler_spec.cr ================================================ require "./spec_helper" describe Athena::Console do describe "compiler errors", tags: "compiled" do describe "when a command configured via annotation doesn't have a name" do it "non hidden no aliases" do ASPEC::Methods.assert_compile_time_error "Console command 'NoNameCommand' has an 'ACONA::AsCommand' annotation but is missing the commands's name. It was not provided as the first positional argument nor via the 'name' field.", <<-CR require "./spec_helper.cr" @[ACONA::AsCommand] class NoNameCommand < ACON::Command protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end NoNameCommand.default_name CR end it "hidden" do ASPEC::Methods.assert_compile_time_error "Console command 'NoNameCommand' has an 'ACONA::AsCommand' annotation but is missing the commands's name. It was not provided as the first positional argument nor via the 'name' field.", <<-CR require "./spec_helper.cr" @[ACONA::AsCommand(hidden: true)] class NoNameCommand < ACON::Command protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end NoNameCommand.default_name CR end end end end ================================================ FILE: src/components/console/spec/completion/input_spec.cr ================================================ require "../spec_helper" private alias Input = ACON::Completion::Input struct CompletionInputTest < ASPEC::TestCase @[DataProvider("bind_data_provider")] def test_bind(input : Input, expected_type : Input::Type, expected_name : String?, expected_value : String) : Nil definition = ACON::Input::Definition.new( ACON::Input::Option.new("with-required-value", "r", :required), ACON::Input::Option.new("with-optional-value", "o", :optional), ACON::Input::Option.new("without-value", "n", :none), ACON::Input::Argument.new("required-arg", :required), ACON::Input::Argument.new("optional-arg", :optional), ) input.bind definition input.completion_type.should eq expected_type input.completion_name.should eq expected_name input.completion_value.should eq expected_value input.must_suggest_option_values_for?("with-required-value").should be_true if expected_value.starts_with? "athe" end def bind_data_provider : Hash { # Option names "optname minimal input" => {Input.from_tokens(["-"], 0), Input::Type::OPTION_NAME, nil, "-"}, "optname partial" => {Input.from_tokens(["--with"], 0), Input::Type::OPTION_NAME, nil, "--with"}, # Option values "optvalue short" => {Input.from_tokens(["-r"], 0), Input::Type::OPTION_VALUE, "with-required-value", ""}, "optvalue short partial" => {Input.from_tokens(["-rathe"], 0), Input::Type::OPTION_VALUE, "with-required-value", "athe"}, "optvalue short space" => {Input.from_tokens(["-r"], 1), Input::Type::OPTION_VALUE, "with-required-value", ""}, "optvalue short space partial" => {Input.from_tokens(["-r", "athe"], 1), Input::Type::OPTION_VALUE, "with-required-value", "athe"}, "optvalue short before arg" => {Input.from_tokens(["-r", "athena"], 0), Input::Type::OPTION_VALUE, "with-required-value", ""}, "optvalue short optional" => {Input.from_tokens(["-o"], 0), Input::Type::OPTION_VALUE, "with-optional-value", ""}, "optvalue short space optional" => {Input.from_tokens(["-o"], 1), Input::Type::OPTION_VALUE, "with-optional-value", ""}, "optvalue long" => {Input.from_tokens(["--with-required-value="], 0), Input::Type::OPTION_VALUE, "with-required-value", ""}, "optvalue long partial" => {Input.from_tokens(["--with-required-value=ath"], 0), Input::Type::OPTION_VALUE, "with-required-value", "ath"}, "optvalue long space" => {Input.from_tokens(["--with-required-value"], 1), Input::Type::OPTION_VALUE, "with-required-value", ""}, "optvalue long space partial" => {Input.from_tokens(["--with-required-value", "ath"], 1), Input::Type::OPTION_VALUE, "with-required-value", "ath"}, "optvalue long optional" => {Input.from_tokens(["--with-optional-value="], 0), Input::Type::OPTION_VALUE, "with-optional-value", ""}, "optvalue long space optional" => {Input.from_tokens(["--with-optional-value"], 1), Input::Type::OPTION_VALUE, "with-optional-value", ""}, # Arguments "arg minimal input" => {Input.from_tokens([] of String, 0), Input::Type::ARGUMENT_VALUE, "required-arg", ""}, "arg optional" => {Input.from_tokens(["athena"], 1), Input::Type::ARGUMENT_VALUE, "optional-arg", ""}, "arg partial" => {Input.from_tokens(["ath"], 0), Input::Type::ARGUMENT_VALUE, "required-arg", "ath"}, "arg optional partial" => {Input.from_tokens(["athena", "cry"], 1), Input::Type::ARGUMENT_VALUE, "optional-arg", "cry"}, "arg after option" => {Input.from_tokens(["--without-value"], 1), Input::Type::ARGUMENT_VALUE, "required-arg", ""}, "arg after optional option" => {Input.from_tokens(["--with-optional-value", "--"], 2), Input::Type::ARGUMENT_VALUE, "required-arg", ""}, # End of definition "end" => {Input.from_tokens(["athena", "crystal"], 2), Input::Type::NONE, nil, ""}, } end @[DataProvider("last_array_argument_provider")] def test_bind_with_last_array_argument(input : Input, expected_value : String?) : Nil definition = ACON::Input::Definition.new( ACON::Input::Argument.new("list-arg", ACON::Input::Argument::Mode[:required, :is_array]), ) input.bind definition input.completion_type.should eq Input::Type::ARGUMENT_VALUE input.completion_name.should eq "list-arg" input.completion_value.should eq expected_value end def last_array_argument_provider : Tuple { {Input.from_tokens([] of String, 0), ""}, {Input.from_tokens(["athena", "crystal"], 2), ""}, {Input.from_tokens(["athena", "cry"], 1), "cry"}, } end def test_bind_argument_with_default : Nil definition = ACON::Input::Definition.new( ACON::Input::Argument.new("arg-with-default", :optional, default: "default"), ) input = Input.from_tokens [] of String, 0 input.bind definition input.completion_type.should eq Input::Type::ARGUMENT_VALUE input.completion_name.should eq "arg-with-default" input.completion_value.should eq "" input.must_suggest_argument_values_for?("arg-with-default").should be_true end @[DataProvider("from_string_provider")] def test_from_string(input_string : String, expected_tokens : Array(String)) : Nil input = Input.from_string input_string, 1 input.@tokens.should eq expected_tokens end def from_string_provider : Tuple { {"do:thing", ["do:thing"]}, {"--env prod", ["--env", "prod"]}, {"--env=prod", ["--env=prod"]}, {"-eprod", ["-eprod"]}, { %(do:thing "multi word string"), ["do:thing", %("multi word string")] }, {"do:thing 'multi word string'", ["do:thing", "'multi word string'"]}, } end end ================================================ FILE: src/components/console/spec/completion/output/bash_spec.cr ================================================ require "./completion_output_test_case" struct BashTest < CompletionOutputTestCase def completion_output : ACON::Completion::Output::Interface ACON::Completion::Output::Bash.new end def expected_options_output : String "--option1\n--negatable\n--no-negatable#{EOL}" end def expected_values_output : String "Green\nRed\nYellow#{EOL}" end end ================================================ FILE: src/components/console/spec/completion/output/completion_output_test_case.cr ================================================ require "../../spec_helper" abstract struct CompletionOutputTestCase < ASPEC::TestCase abstract def completion_output : ACON::Completion::Output::Interface abstract def expected_options_output : String abstract def expected_values_output : String def test_options : Nil options = [ ACON::Input::Option.new("option1", "o", :none, "First Option"), ACON::Input::Option.new("negatable", nil, :negatable, "Can be negative"), ] suggestions = ACON::Completion::Suggestions.new suggestions.suggest_options options buffer = IO::Memory.new self.completion_output.write suggestions, ACON::Output::IO.new buffer buffer.to_s.should eq self.expected_options_output end def test_values : Nil suggestions = ACON::Completion::Suggestions.new suggestions.suggest_value "Green", "Beans are green" suggestions.suggest_value "Red", "Roses are red" suggestions.suggest_value "Yellow", "Canaries are yellow" buffer = IO::Memory.new self.completion_output.write suggestions, ACON::Output::IO.new buffer buffer.to_s.should eq self.expected_values_output end end ================================================ FILE: src/components/console/spec/completion/output/fish_spec.cr ================================================ require "./completion_output_test_case" struct FishTest < CompletionOutputTestCase def completion_output : ACON::Completion::Output::Interface ACON::Completion::Output::Fish.new end def expected_options_output : String "--option1\n--negatable\n--no-negatable" end def expected_values_output : String "Green\nRed\nYellow" end end ================================================ FILE: src/components/console/spec/completion/output/zsh_spec.cr ================================================ require "./completion_output_test_case" struct ZshTest < CompletionOutputTestCase def completion_output : ACON::Completion::Output::Interface ACON::Completion::Output::Zsh.new end def expected_options_output : String "--option1\tFirst Option\n--negatable\tCan be negative\n--no-negatable\tCan be negative\n" end def expected_values_output : String "Green\tBeans are green\nRed\tRoses are red\nYellow\tCanaries are yellow\n" end end ================================================ FILE: src/components/console/spec/cursor_spec.cr ================================================ require "./spec_helper" struct CursorTest < ASPEC::TestCase @cursor : ACON::Cursor @output : ACON::Output::IO def initialize @output = ACON::Output::IO.new IO::Memory.new @cursor = ACON::Cursor.new @output end def test_move_up_one_line : Nil @cursor.move_up @output.to_s.should eq "\x1b[1A" end def test_move_up_multiple_lines : Nil @cursor.move_up 12 @output.to_s.should eq "\x1b[12A" end def test_move_down_one_line : Nil @cursor.move_down @output.to_s.should eq "\x1b[1B" end def test_move_down_multiple_lines : Nil @cursor.move_down 12 @output.to_s.should eq "\x1b[12B" end def test_move_right_one_line : Nil @cursor.move_right @output.to_s.should eq "\x1b[1C" end def test_move_right_multiple_lines : Nil @cursor.move_right 12 @output.to_s.should eq "\x1b[12C" end def test_move_left_one_line : Nil @cursor.move_left @output.to_s.should eq "\x1b[1D" end def test_move_left_multiple_lines : Nil @cursor.move_left 12 @output.to_s.should eq "\x1b[12D" end def test_move_to_column : Nil @cursor.move_to_column 5 @output.to_s.should eq "\x1b[5G" end def test_move_to_position : Nil @cursor.move_to_position 18, 16 @output.to_s.should eq "\x1b[17;18H" end def test_clear_line : Nil @cursor.clear_line @output.to_s.should eq "\x1b[2K" end def test_clear_line_after : Nil @cursor.clear_line_after @output.to_s.should eq "\x1b[K" end def test_clear_screen : Nil @cursor.clear_screen @output.to_s.should eq "\x1b[2J" end def test_save_position : Nil @cursor.save_position @output.to_s.should eq "\x1b7" end def test_restore_position : Nil @cursor.restore_position @output.to_s.should eq "\x1b8" end def test_hide : Nil @cursor.hide @output.to_s.should eq "\x1b[?25l" end def test_show : Nil @cursor.show @output.to_s.should eq "\x1b[?25h\x1b[?0c" end def test_clear_output : Nil @cursor.clear_output @output.to_s.should eq "\x1b[0J" end def test_current_position : Nil @cursor = ACON::Cursor.new @output, IO::Memory.new @cursor.move_to_position 10, 10 position = @cursor.current_position @output.to_s.should eq "\x1b[11;10H" position.should eq({1, 1}) end def test_current_position_tty : Nil pending! "Cursor input must be a TTY" unless STDIN.tty? @cursor = ACON::Cursor.new @output @cursor.move_to_position 10, 10 position = @cursor.current_position @output.to_s.should eq "\x1b[11;10H" position.should_not eq({1, 1}) end end ================================================ FILE: src/components/console/spec/descriptor/abstract_descriptor_test_case.cr ================================================ require "../spec_helper" require "./object_provider" abstract struct AbstractDescriptorTestCase < ASPEC::TestCase @[DataProvider("input_argument_test_data")] def test_describe_input_argument(object : ACON::Input::Argument, expected : String) : Nil self.assert_description expected, object end @[DataProvider("input_option_test_data")] def test_describe_input_option(object : ACON::Input::Option, expected : String) : Nil self.assert_description expected, object end @[DataProvider("input_definition_test_data")] def test_describe_input_definition(object : ACON::Input::Definition, expected : String) : Nil self.assert_description expected, object end @[DataProvider("command_test_data")] def test_describe_command(object : ACON::Command, expected : String) : Nil self.assert_description expected, object end @[DataProvider("application_test_data")] def test_describe_application(object : ACON::Application, expected : String) : Nil self.assert_description expected, object end def input_argument_test_data : Array self.description_test_data ObjectProvider.input_arguments end def input_option_test_data : Array self.description_test_data ObjectProvider.input_options end def input_definition_test_data : Array self.description_test_data ObjectProvider.input_definitions end def command_test_data : Array self.description_test_data ObjectProvider.commands end def application_test_data : Array self.description_test_data ObjectProvider.applications end protected abstract def descriptor : ACON::Descriptor::Interface protected abstract def format : String protected def description_test_data(data : Hash(String, _)) : Array data.map do |k, v| normalized_path = File.join __DIR__, "..", "fixtures", "text" {v, File.read "#{normalized_path}/#{k}.#{self.format}"} end end protected def assert_description(expected : String, object, context : ACON::Descriptor::Context = ACON::Descriptor::Context.new) : Nil output = ACON::Output::IO.new IO::Memory.new context = context.clone context.raw_output = true self.descriptor.describe output, object, context self.normalize_output(output.to_s).should eq self.normalize_output(expected) end private def normalize_output(output : String) : String output.gsub(EOL, "\n").strip end end ================================================ FILE: src/components/console/spec/descriptor/application_spec.cr ================================================ require "../spec_helper" private class TestApplication < ACON::Application protected def default_commands : Array(ACON::Command) [] of ACON::Command end end struct ApplicationDescriptorTest < ASPEC::TestCase @[DataProvider("namespace_provider")] def test_namespaces(expected : Array(String), names : Array(String)) : Nil app = TestApplication.new "foo" names.each do |name| app.register name do ACON::Command::Status::SUCCESS end end ACON::Descriptor::Application.new(app).namespaces.keys.should eq expected end def namespace_provider : Tuple { {["_global"], ["foobar"]}, {["a", "b"], ["b:foo", "a:foo", "b:bar"]}, {["_global", "22", "33", "b", "z"], ["z:foo", "1", "33:foo", "b:foo", "22:foo:bar"]}, } end end ================================================ FILE: src/components/console/spec/descriptor/object_provider.cr ================================================ module ObjectProvider def self.input_arguments : Hash(String, ACON::Input::Argument) { "input_argument_1" => ACON::Input::Argument.new("argument_name", :required), "input_argument_2" => ACON::Input::Argument.new("argument_name", :is_array, "argument description"), "input_argument_3" => ACON::Input::Argument.new("argument_name", :optional, "argument description", "default_value"), "input_argument_4" => ACON::Input::Argument.new("argument_name", :required, "multiline\nargument description"), "input_argument_with_style" => ACON::Input::Argument.new("argument_name", :optional, "argument description", "style"), } end def self.input_options : Hash(String, ACON::Input::Option) { "input_option_1" => ACON::Input::Option.new("option_name", "o", :none), "input_option_2" => ACON::Input::Option.new("option_name", "o", :optional, "option description", "default_value"), "input_option_3" => ACON::Input::Option.new("option_name", "o", :required, "option description"), "input_option_4" => ACON::Input::Option.new("option_name", "o", ACON::Input::Option::Value[:optional, :is_array], "option description", Array(String).new), "input_option_5" => ACON::Input::Option.new("option_name", "o", :required, "multiline\noption description"), "input_option_6" => ACON::Input::Option.new("option_name", {"o", "O"}, :required, "option with multiple shortcuts"), "input_option_with_style" => ACON::Input::Option.new("option_name", "o", :required, "option description", "style"), "input_option_with_style_array" => ACON::Input::Option.new("option_name", "o", ACON::Input::Option::Value[:required, :is_array], "option description", ["Hello", "world"]), } end def self.input_definitions : Hash(String, ACON::Input::Definition) { "input_definition_1" => ACON::Input::Definition.new, "input_definition_2" => ACON::Input::Definition.new(ACON::Input::Argument.new("argument_name", :required)), "input_definition_3" => ACON::Input::Definition.new(ACON::Input::Option.new("option_name", "o", :none)), "input_definition_4" => ACON::Input::Definition.new( ACON::Input::Argument.new("argument_name", :required), ACON::Input::Option.new("option_name", "o", :none), ), } end def self.commands : Hash(String, ACON::Command) { "command_1" => DescriptorCommand1.new, "command_2" => DescriptorCommand2.new, } end def self.applications : Hash(String, ACON::Application) { "application_1" => DescriptorApplication1.new("foo"), "application_2" => DescriptorApplication2.new, } end end ================================================ FILE: src/components/console/spec/descriptor/text_spec.cr ================================================ require "../spec_helper" require "./abstract_descriptor_test_case" struct TextDescriptorTest < AbstractDescriptorTestCase # TODO: Include test data for double width chars # For both Application and Command contexts def test_describe_application_filtered_namespace : Nil self.assert_description( File.read("#{__DIR__}/../fixtures/text/application_filtered_namespace.txt"), DescriptorApplication2.new, ACON::Descriptor::Context.new(namespace: "command4"), ) end protected def descriptor : ACON::Descriptor::Interface ACON::Descriptor::Text.new end protected def format : String "txt" end end ================================================ FILE: src/components/console/spec/fixtures/applications/descriptor1.cr ================================================ class DescriptorApplication1 < ACON::Application end ================================================ FILE: src/components/console/spec/fixtures/applications/descriptor2.cr ================================================ class DescriptorApplication2 < ACON::Application def initialize super "My Athena application", "1.0.0" self.add DescriptorCommand1.new self.add DescriptorCommand2.new self.add DescriptorCommand3.new self.add DescriptorCommand4.new end end ================================================ FILE: src/components/console/spec/fixtures/commands/annotation_configured.cr ================================================ @[ACONA::AsCommand("annotation:configured", description: "Command configured via annotation", aliases: ["ac"])] class AnnotationConfiguredCommand < ACON::Command protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/annotation_configured_aliases.cr ================================================ @[ACONA::AsCommand("annotation:configured|ac")] class AnnotationConfiguredAliasesCommand < ACON::Command protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/annotation_configured_hidden.cr ================================================ @[ACONA::AsCommand("|annotation:configured")] class AnnotationConfiguredHiddenCommand < ACON::Command protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/annotation_configured_hidden_field.cr ================================================ @[ACONA::AsCommand("annotation:configured", hidden: true)] class AnnotationConfiguredHiddenFieldCommand < ACON::Command protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/bar_buc.cr ================================================ class BarBucCommand < ACON::Command protected def configure : Nil self .name("bar:buc") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/descriptor1.cr ================================================ class DescriptorCommand1 < ACON::Command protected def configure : Nil self .name("descriptor:command1") .aliases("alias1", "alias2") .description("command 1 description") .help("command 1 help") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/descriptor2.cr ================================================ class DescriptorCommand2 < ACON::Command protected def configure : Nil self .name("descriptor:command2") .description("command 2 description") .help("command 2 help") .usage("-o|--option_name ") .usage("") .argument("argument_name", :required) .option("option_name", "o", :none) end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/descriptor3.cr ================================================ class DescriptorCommand3 < ACON::Command protected def configure : Nil self .name("descriptor:command3") .description("command 3 description") .help("command 3 help") .hidden end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/descriptor4.cr ================================================ class DescriptorCommand4 < ACON::Command protected def configure : Nil self .name("descriptor:command4") .aliases("descriptor:alias_command4", "command4:descriptor") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo.cr ================================================ class FooCommand < IOCommand protected def configure : Nil self .name("foo:bar") .description("The foo:bar command") .aliases("afoobar") end protected def interact(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil output.puts "interact called" end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status output.puts "execute called" super end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo1.cr ================================================ class Foo1Command < IOCommand protected def configure : Nil self .name("foo:bar1") .description("The foo:bar1 command") .aliases("afoobar1") end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo2.cr ================================================ class Foo2Command < IOCommand protected def configure : Nil self .name("foo1:bar") .description("The foo1:bar command") .aliases("afoobar2") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo3.cr ================================================ class Foo3Command < ACON::Command protected def configure : Nil self .name("foo3:bar") .description("The foo3:bar command") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status begin begin raise Exception.new "First exception

this is html

" rescue ex raise Exception.new "Second exception comment", ex end rescue ex raise Exception.new "Third exception comment", ex end end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo4.cr ================================================ class Foo4Command < ACON::Command protected def configure : Nil self .name("foo3:bar:toh") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo6.cr ================================================ class Foo6Command < ACON::Command protected def configure : Nil self .name("0foo:bar") .description("0foo:bar command") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo_bar.cr ================================================ class FooBarCommand < IOCommand protected def configure : Nil self .name("foobar:foo") .description("The foobar:foo command") end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo_hidden.cr ================================================ class FooHiddenCommand < ACON::Command protected def configure : Nil self .name("foo:hidden") .aliases("afoohidden") .hidden(true) end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo_opt.cr ================================================ class FooOptCommand < IOCommand protected def configure : Nil self .name("foo:bar") .description("The foo:bar command") .aliases("afoobar") .option("fooopt", "f", :optional, "fooopt description") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status super self.output.puts "execute called" self.output.puts input.option("fooopt") ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo_same_case_lowercase.cr ================================================ class FooSameCaseLowercaseCommand < ACON::Command protected def configure : Nil self .name("foo:bar") .description("foo:bar command") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo_same_case_uppercase.cr ================================================ class FooSameCaseUppercaseCommand < ACON::Command protected def configure : Nil self .name("foo:BAR") .description("foo:BAR command") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo_subnamespaced1.cr ================================================ class FooSubnamespaced1Command < IOCommand protected def configure : Nil self .name("foo:bar:baz") .description("The foo:bar:baz command") .aliases("foobarbaz") end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo_subnamespaced2.cr ================================================ class FooSubnamespaced2Command < IOCommand protected def configure : Nil self .name("foo:bar:go") .description("The foo:bar:go command") .aliases("foobargo") end end ================================================ FILE: src/components/console/spec/fixtures/commands/foo_without_alias.cr ================================================ class FooWithoutAliasCommand < IOCommand protected def configure : Nil self .name("foo") .description("The foo command") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status output.puts "execute called" ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/io.cr ================================================ abstract class IOCommand < ACON::Command getter! input : ACON::Input::Interface getter! output : ACON::Output::Interface protected def execute(@input : ACON::Input::Interface, @output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/test.cr ================================================ class TestCommand < ACON::Command protected def configure : Nil self .name("namespace:name") .description("description") .aliases("name") .help("help") end protected def interact(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil output.puts "interact called" end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status output.puts "execute called" ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/test_ambiguous_command_registering1.cr ================================================ class TestAmbiguousCommandRegistering < ACON::Command protected def configure : Nil self .name("test-ambiguous") .description("The test-ambiguous command") .aliases("test") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status output.puts "test-ambiguous" ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/commands/test_ambiguous_command_registering2.cr ================================================ class TestAmbiguousCommandRegistering2 < ACON::Command protected def configure : Nil self .name("test-ambiguous2") .description("The test-ambiguous2 command") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status output.puts "test-ambiguous2" ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/spec/fixtures/helper/table/borderless.txt ================================================ =============== ========================== ================== ISBN Title Author =============== ========================== ================== 99921-58-10-7 Divine Comedy Dante Alighieri 9971-5-0210-0 A Tale of Two Cities Charles Dickens 960-425-059-0 The Lord of the Rings J. R. R. Tolkien 80-902734-1-6 And Then There Were None Agatha Christie =============== ========================== ================== ================================================ FILE: src/components/console/spec/fixtures/helper/table/borderless_vertical.txt ================================================ ============================== ISBN: 99921-58-10-7 Title: Divine Comedy Author: Dante Alighieri Price: 9.95 ============================== ISBN: 9971-5-0210-0 Title: A Tale of Two Cities Author: Charles Dickens Price: 139.25 ============================== ================================================ FILE: src/components/console/spec/fixtures/helper/table/box.txt ================================================ ┌───────────────┬──────────────────────────┬──────────────────┐ │ ISBN │ Title │ Author │ ├───────────────┼──────────────────────────┼──────────────────┤ │ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri │ │ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens │ │ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien │ │ 80-902734-1-6 │ And Then There Were None │ Agatha Christie │ └───────────────┴──────────────────────────┴──────────────────┘ ================================================ FILE: src/components/console/spec/fixtures/helper/table/compact.txt ================================================ ISBN Title Author 99921-58-10-7 Divine Comedy Dante Alighieri 9971-5-0210-0 A Tale of Two Cities Charles Dickens 960-425-059-0 The Lord of the Rings J. R. R. Tolkien 80-902734-1-6 And Then There Were None Agatha Christie ================================================ FILE: src/components/console/spec/fixtures/helper/table/compact_vertical.txt ================================================ ISBN: 99921-58-10-7 Title: Divine Comedy Author: Dante Alighieri Price: 9.95 ISBN: 9971-5-0210-0 Title: A Tale of Two Cities Author: Charles Dickens Price: 139.25 ================================================ FILE: src/components/console/spec/fixtures/helper/table/default.txt ================================================ +---------------+--------------------------+------------------+ | ISBN | Title | Author | +---------------+--------------------------+------------------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | | 80-902734-1-6 | And Then There Were None | Agatha Christie | +---------------+--------------------------+------------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_cells_with_colspan.txt ================================================ +-------------------------------+-------------------------------+-----------------------------+ | ISBN | Title | Author | +-------------------------------+-------------------------------+-----------------------------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | +-------------------------------+-------------------------------+-----------------------------+ | Divine Comedy(Dante Alighieri) | +-------------------------------+-------------------------------+-----------------------------+ | Arduino: A Quick-Start Guide | Mark Schmidt | +-------------------------------+-------------------------------+-----------------------------+ | 9971-5-0210-0 | A Tale of | | | Two Cities | +-------------------------------+-------------------------------+-----------------------------+ | Cupiditate dicta atque porro, tempora exercitationem modi animi nulla nemo vel nihil! | +-------------------------------+-------------------------------+-----------------------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_cells_with_formatting_tags.txt ================================================ +---------------+----------------------+-----------------+ | ISBN | Title | Author | +---------------+----------------------+-----------------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | +---------------+----------------------+-----------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_cells_with_non_formatting_tags.txt ================================================ +----------------------------------+----------------------+-----------------+ | ISBN | Title | Author | +----------------------------------+----------------------+-----------------+ | 99921-58-10-700 | Divine Com | Dante Alighieri | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | +----------------------------------+----------------------+-----------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_cells_with_rowspan.txt ================================================ +---------------+---------------+-----------------+ | ISBN | Title | Author | +---------------+---------------+-----------------+ | 9971-5-0210-0 | Divine Comedy | Dante Alighieri | | | | | | | The Lord of | J. R. | | | the Rings | R. Tolkien | +---------------+---------------+-----------------+ | 80-902734-1-6 | And Then | Agatha Christie | | 80-902734-1-7 | There | Test | | | Were None | | +---------------+---------------+-----------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_cells_with_rowspan_and_colspan.txt ================================================ +------------------+---------+-----------------+ | ISBN | Title | Author | +------------------+---------+-----------------+ | 9971-5-0210-0 | Dante Alighieri | | | Charles Dickens | +------------------+---------+-----------------+ | Dante Alighieri | 9971-5-0210-0 | | J. R. R. Tolkien | | | J. R. R | | +------------------+---------+-----------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_cells_with_rowspan_and_colspan_and_alignment.txt ================================================ +---------------+---------------+-------------------------------------------+ | ISBN | Title | Author | +---------------+---------------+-------------------------------------------+ | 978 | De Monarchia | Dante Alighieri | | 99921-58-10-7 | Divine Comedy | spans multiple rows rows Dante Alighieri | | | | spans multiple rows rows | +---------------+---------------+-------------------------------------------+ | test | tttt | +---------------+---------------+-------------------------------------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_cells_with_rowspan_and_colspan_and_custom_format.txt ================================================ +----------------+---------------+---------------------+ | ISBN | Title | Author | +----------------+---------------+---------------------+ | 978-0521567817 | De Monarchia | Dante Alighieri | | 978-0804169127 | Divine Comedy | spans multiple rows | | test | tttt | +----------------+---------------+---------------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_cells_with_rowspan_and_colspan_and_fgbg.txt ================================================ +---------------+---------------+-------------------------------------------+ | 978 | De Monarchia | Dante Alighieri | | 99921-58-10-7 | Divine Comedy | spans multiple rows rows Dante Alighieri | | | | spans multiple rows rows | +---------------+---------------+-------------------------------------------+ | test | tttt | +---------------+---------------+-------------------------------------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_cells_with_rowspan_and_colspan_and_line_breaks.txt ================================================ +-----------------+-------+-----------------+ | ISBN | Title | Author | +-----------------+-------+-----------------+ | 9971 | Dante Alighieri | | -5- | Charles Dickens | | 021 | | | 0-0 | | +-----------------+-------+-----------------+ | Dante Alighieri | 9971 | | Charles Dickens | -5- | | | 021 | | | 0-0 | +-----------------+-------+-----------------+ | 9971 | Dante | | -5- | Alighieri | | 021 | | | 0-0 | | +-----------------+-------+-----------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_cells_with_rowspan_and_colspan_no_separators.txt ================================================ +-----------------+-------+-----------------+ | ISBN | Title | Author | +-----------------+-------+-----------------+ | 9971 | Dante Alighieri | | -5- | Charles Dickens | | 021 | | | 0-0 | | | Dante Alighieri | 9971 | | Charles Dickens | -5- | | | 021 | | | 0-0 | +-----------------+-------+-----------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_cells_with_rowspan_and_colspan_separator_in_rowspan.txt ================================================ +---------------+-----------------+ | ISBN | Author | +---------------+-----------------+ | 9971-5-0210-0 | Dante Alighieri | | |-----------------| | | Charles Dickens | +---------------+-----------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_colspan_and_table_cell_with_comment_style.txt ================================================ +-----------------+------------------+---------+ | Long Title | +-----------------+------------------+---------+ | 9971-5-0210-0 | +-----------------+------------------+---------+ | Dante Alighieri | J. R. R. Tolkien | J. R. R | +-----------------+------------------+---------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_formatted_row_with_line_breaks.txt ================================================ +-------+------------+ | Dont break | | here | +-------+------------+ | foo | Dont break | | bar | here | +-------+------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_headerless.txt ================================================ +---------------+--------------------------+------------------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | | 9971-5-0210-0 | | | | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | | 80-902734-1-6 | And Then There Were None | Agatha Christie | +---------------+--------------------------+------------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_line_break_after_colspan_cell.txt ================================================ +-----+-----+-----+ | Foo | Bar | Baz | +-----+-----+-----+ | foo | baz | | bar | qux | +-----+-----+-----+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_line_breaks_after_colspan_cell.txt ================================================ +-----+-----+------+ | Foo | Bar | Baz | +-----+-----+------+ | foo | baz | | bar | qux | | | quux | +-----+-----+------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_missing_cell_values.txt ================================================ +---------------+--------------------------+------------------+ | ISBN | Title | | +---------------+--------------------------+------------------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | | 9971-5-0210-0 | | | | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | | 80-902734-1-6 | And Then There Were None | Agatha Christie | +---------------+--------------------------+------------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_multiline_cells.txt ================================================ +---------------+----------------------------+-----------------+ | ISBN | Title | Author | +---------------+----------------------------+-----------------+ | 99921-58-10-7 | Divine | Dante Alighieri | | | Comedy | | | 9971-5-0210-2 | Harry Potter | Rowling | | | and the Chamber of Secrets | Joanne K. | | 9971-5-0210-2 | Harry Potter | Rowling | | | and the Chamber of Secrets | Joanne K. | | 960-425-059-0 | The Lord of the Rings | J. R. R. | | | | Tolkien | +---------------+----------------------------+-----------------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_multiple_header_lines.txt ================================================ +------+-------+--------+ | Main title | +------+-------+--------+ | ISBN | Title | Author | +------+-------+--------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_no_rows.txt ================================================ +------+-------+ | ISBN | Title | +------+-------+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/default_row_with_multiple_cells.txt ================================================ +---+--+--+---+--+---+--+---+--+ | 1 | 2 | 3 | 4 | +---+--+--+---+--+---+--+---+--+ ================================================ FILE: src/components/console/spec/fixtures/helper/table/double_box_separator.txt ================================================ ╔═══════════════╤══════════════════════════╤══════════════════╗ ║ ISBN │ Title │ Author ║ ╠═══════════════╪══════════════════════════╪══════════════════╣ ║ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri ║ ║ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens ║ ╟───────────────┼──────────────────────────┼──────────────────╢ ║ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien ║ ║ 80-902734-1-6 │ And Then There Were None │ Agatha Christie ║ ╚═══════════════╧══════════════════════════╧══════════════════╝ ================================================ FILE: src/components/console/spec/fixtures/helper/table/markdown.txt ================================================ | ISBN | Title | Author | |---------------|--------------------------|------------------| | 99921-58-10-7 | Divine Comedy | Dante Alighieri | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | | 80-902734-1-6 | And Then There Were None | Agatha Christie | ================================================ FILE: src/components/console/spec/fixtures/helper/table/suggested_vertical.txt ================================================ ------------------------------ ISBN: 99921-58-10-7 Title: Divine Comedy Author: Dante Alighieri Price: 9.95 ------------------------------ ISBN: 9971-5-0210-0 Title: A Tale of Two Cities Author: Charles Dickens Price: 139.25 ------------------------------ ================================================ FILE: src/components/console/spec/fixtures/style/backslashes.txt ================================================ Title ending with \\ =================== Section ending with \\ --------------------- ================================================ FILE: src/components/console/spec/fixtures/style/block.txt ================================================ ! \[CAUTION\] Lorem ipsum dolor sit amet ================================================ FILE: src/components/console/spec/fixtures/style/block_line_endings.txt ================================================ Lorem ipsum dolor sit amet \* Lorem ipsum dolor sit amet \* consectetur adipiscing elit Lorem ipsum dolor sit amet \* Lorem ipsum dolor sit amet \* consectetur adipiscing elit Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet consectetur adipiscing elit Lorem ipsum dolor sit amet \/\/ Lorem ipsum dolor sit amet \/\/ \/\/ consectetur adipiscing elit ================================================ FILE: src/components/console/spec/fixtures/style/block_no_prefix_type.txt ================================================ \[TEST\] Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua\. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat\. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur\. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum ================================================ FILE: src/components/console/spec/fixtures/style/block_padding.txt ================================================ \e\[30;42m \e\[39;49m \e\[30;42m \[OK\] Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore \e\[39;49m \e\[30;42m magna aliqua\. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo \e\[39;49m \e\[30;42m consequat\. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur\. \e\[39;49m \e\[30;42m Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum \e\[39;49m \e\[30;42m \e\[39;49m ================================================ FILE: src/components/console/spec/fixtures/style/block_prefix_no_type.txt ================================================ \$ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna \$ aliqua\. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat\. \$ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur\. Excepteur sint \$ occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum ================================================ FILE: src/components/console/spec/fixtures/style/blocks.txt ================================================ \[WARNING\] Warning ! \[CAUTION\] Caution \[ERROR\] Error \[OK\] Success ! \[NOTE\] Note \[INFO\] Info X \[CUSTOM\] Custom block ================================================ FILE: src/components/console/spec/fixtures/style/closing_tag.txt ================================================ \e\[30;46mdo you want \e\[39;49m\e\[33msomething\e\[39m\e\[30;46m\?\e\[39;49m ================================================ FILE: src/components/console/spec/fixtures/style/definition_list.txt ================================================ ---------- --------- foo bar ---------- --------- this is a title ---------- --------- foo2 bar2 ---------- --------- ================================================ FILE: src/components/console/spec/fixtures/style/emojis.txt ================================================ \[OK\] Lorem ipsum dolor sit amet \[OK\] Lorem ipsum dolor sit amet with one emoji 🎉 \[OK\] Lorem ipsum dolor sit amet with so many of them 👩‍🌾👩‍🌾👩‍🌾👩‍🌾👩‍🌾 ================================================ FILE: src/components/console/spec/fixtures/style/empty_buffer.txt ================================================ Hello ================================================ FILE: src/components/console/spec/fixtures/style/horizontal_table.txt ================================================ --- --- --- --- a 1 4 7 b 2 5 8 c 3 9 d --- --- --- --- ================================================ FILE: src/components/console/spec/fixtures/style/long_line_block.txt ================================================ X \[CUSTOM\] Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et X dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea X commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat X nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit X anim id est laborum ================================================ FILE: src/components/console/spec/fixtures/style/long_line_block_wrapping.txt ================================================ § \[CUSTOM\] Lopadotemachoselachogaleokranioleipsanodrimhypotrimmatosilphioparaomelitokatakechymenokichlepikossyphophatto § peristeralektryonoptekephalliokigklopeleiolagoiosiraiobaphetraganopterygon ================================================ FILE: src/components/console/spec/fixtures/style/long_line_comment.txt ================================================ // Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna // aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. // Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur // sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum ================================================ FILE: src/components/console/spec/fixtures/style/long_line_comment_decorated.txt ================================================ \/\/ Árvíztűrőtükörfúrógép 🎼 Lorem ipsum dolor sit \e\[33m💕 amet, consectetur adipisicing elit, sed do eiusmod tempor incididu \e\[39m \/\/ \e\[33mlabore et dolore magna aliqua\. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex\e\[39m \/\/ \e\[33mea commodo consequat\. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \e\[39m \/\/ \e\[33mpariatur\.\e\[39m Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est \/\/ laborum ================================================ FILE: src/components/console/spec/fixtures/style/multi_line_block.txt ================================================ X \[CUSTOM\] Custom block X X Second custom block line ================================================ FILE: src/components/console/spec/fixtures/style/nested_tag_prefix.txt ================================================ ║ \[★\] Árvíztűrőtükörfúrógép Lorem ipsum dolor sit \e\[33mamet, consectetur adipisicing elit, sed do eiusmod tempor incididunt \e\[39m ║ \e\[33m ut labore et dolore magna aliqua\. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut \e\[39m ║ \e\[33m aliquip ex ea commodo consequat\. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu \e\[39m ║ \e\[33m fugiat nulla pariatur\.\e\[39m Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit ║ anim id est laborum ================================================ FILE: src/components/console/spec/fixtures/style/non_interactive_question.txt ================================================ Title ===== Duis aute irure dolor in reprehenderit in voluptate velit esse ================================================ FILE: src/components/console/spec/fixtures/style/table.txt ================================================ ----- ------- \e\[32m Foo \e\[39m \e\[32m Bar \e\[39m ----- ------- Biz Baz 12 false ----- ------- ================================================ FILE: src/components/console/spec/fixtures/style/table_horizontal.txt ================================================ ----- ----- ------- \e\[32m Foo \e\[39m Biz 12 \e\[32m Bar \e\[39m Baz false ----- ----- ------- ================================================ FILE: src/components/console/spec/fixtures/style/table_vertical.txt ================================================ ------------ \[33mFoo\[39m: Biz \[33mBar\[39m: Baz ------------ \[33mFoo\[39m: 12 \[33mBar\[39m: false ------------ ================================================ FILE: src/components/console/spec/fixtures/style/text_block_blank_line.txt ================================================ \* Lorem ipsum dolor sit amet \* consectetur adipiscing elit \[OK\] Lorem ipsum dolor sit amet ================================================ FILE: src/components/console/spec/fixtures/style/title_block.txt ================================================ Title ===== \[WARNING\] Lorem ipsum dolor sit amet Title ===== ================================================ FILE: src/components/console/spec/fixtures/style/titles.txt ================================================ First title =========== Second title ============ ================================================ FILE: src/components/console/spec/fixtures/style/titles_text.txt ================================================ Lorem ipsum dolor sit amet First title =========== Lorem ipsum dolor sit amet Second title ============ Lorem ipsum dolor sit amet Third title =========== Lorem ipsum dolor sit amet Fourth title ============ Lorem ipsum dolor sit amet Fifth title =========== Lorem ipsum dolor sit amet Sixth title =========== ================================================ FILE: src/components/console/spec/fixtures/text/application_1.txt ================================================ foo UNKNOWN Usage: command [options] [arguments] Options: -h, --help Display help for the given command. When no command is given display help for the list command --silent Do not output any message -q, --quiet Only errors are displayed. All other output is suppressed -V, --version Display this application version --ansi|--no-ansi Force (or disable --no-ansi) ANSI output -n, --no-interaction Do not ask any interactive question -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Available commands: completion Dump the shell completion script help Display help for a command list List available commands ================================================ FILE: src/components/console/spec/fixtures/text/application_2.txt ================================================ My Athena application 1.0.0 Usage: command [options] [arguments] Options: -h, --help Display help for the given command. When no command is given display help for the list command --silent Do not output any message -q, --quiet Only errors are displayed. All other output is suppressed -V, --version Display this application version --ansi|--no-ansi Force (or disable --no-ansi) ANSI output -n, --no-interaction Do not ask any interactive question -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Available commands: completion Dump the shell completion script help Display help for a command list List available commands descriptor descriptor:command1 [alias1|alias2] command 1 description descriptor:command2 command 2 description descriptor:command4 [descriptor:alias_command4|command4:descriptor] ================================================ FILE: src/components/console/spec/fixtures/text/application_alternative_namespace.txt ================================================ There are no commands defined in the 'foos' namespace\. Did you mean this\? foo ================================================ FILE: src/components/console/spec/fixtures/text/application_filtered_namespace.txt ================================================ My Athena application 1.0.0 Usage: command [options] [arguments] Options: -h, --help Display help for the given command. When no command is given display help for the list command --silent Do not output any message -q, --quiet Only errors are displayed. All other output is suppressed -V, --version Display this application version --ansi|--no-ansi Force (or disable --no-ansi) ANSI output -n, --no-interaction Do not ask any interactive question -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Available commands for the 'command4' namespace: command4:descriptor ================================================ FILE: src/components/console/spec/fixtures/text/application_renderexception1.txt ================================================ Command 'foo' is not defined. ================================================ FILE: src/components/console/spec/fixtures/text/application_renderexception2.txt ================================================ The '--foo' option does not exist\. list \[--raw\] \[--format FORMAT\] \[--short\] \[--\] \[\] ================================================ FILE: src/components/console/spec/fixtures/text/application_renderexception3.txt ================================================ In \w+.cr line \d+: Third exception comment In foo3.cr line \d+: Second exception comment In foo3.cr line \d+: First exception

this is html

foo3:bar ================================================ FILE: src/components/console/spec/fixtures/text/application_renderexception3_decorated.txt ================================================ \e\[33mIn \w+\.cr line \d+:\e\[39m \e\[97;41m \e\[39;49m \e\[97;41m Third exception comment \e\[39;49m \e\[97;41m \e\[39;49m \e\[33mIn foo3\.cr line \d+:\e\[39m \e\[97;41m \e\[39;49m \e\[97;41m Second exception comment \e\[39;49m \e\[97;41m \e\[39;49m \e\[33mIn foo3\.cr line \d+:\e\[39m \e\[97;41m \e\[39;49m \e\[97;41m First exception

this is html

\e\[39;49m \e\[97;41m \e\[39;49m \e\[32mfoo3:bar\e\[39m ================================================ FILE: src/components/console/spec/fixtures/text/application_renderexception4.txt ================================================ Command 'foo' is not define d. ================================================ FILE: src/components/console/spec/fixtures/text/application_renderexception_doublewidth1.txt ================================================ At spec/application_spec.cr:\d+:\d+ in '->' エラーメッセージ foo ================================================ FILE: src/components/console/spec/fixtures/text/application_renderexception_escapeslines.txt ================================================ In \w+\.cr line \d+: dont break here < info>!<\/info> foo ================================================ FILE: src/components/console/spec/fixtures/text/application_renderexception_linebreaks.txt ================================================ In \w+\.cr line \d+: line 1 with extra spaces line 2 line 4 foo ================================================ FILE: src/components/console/spec/fixtures/text/application_renderexception_synopsis_escapeslines.txt ================================================ In \w+\.cr line \d+: some exception foo \[\] ================================================ FILE: src/components/console/spec/fixtures/text/application_run1.txt ================================================ foo UNKNOWN Usage: command \[options\] \[arguments\] Options: -h, --help Display help for the given command\. When no command is given display help for the list command --silent Do not output any message -q, --quiet Only errors are displayed. All other output is suppressed -V, --version Display this application version --ansi\|--no-ansi Force \(or disable --no-ansi\) ANSI output -n, --no-interaction Do not ask any interactive question -v\|vv\|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Available commands: completion Dump the shell completion script help Display help for a command list List available commands ================================================ FILE: src/components/console/spec/fixtures/text/application_run2.txt ================================================ Description: List available commands Usage: list \[options\] \[--\] \[\] Arguments: namespace Only list commands in this namespace Options: --raw To output raw command list --format=FORMAT The output format \(txt\) \[default: "txt"\] --short To skip describing command's arguments -h, --help Display help for the given command. When no command is given display help for the list command --silent Do not output any message -q, --quiet Only errors are displayed. All other output is suppressed -V, --version Display this application version --ansi\|--no-ansi Force \(or disable --no-ansi\) ANSI output -n, --no-interaction Do not ask any interactive question -v\|vv\|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Help: The list command lists all commands: console list You can also display the commands for a specific namespace: console list test It's also possible to get raw list of commands \(useful for embedding command runner\): console list --raw ================================================ FILE: src/components/console/spec/fixtures/text/application_run3.txt ================================================ Description: List available commands Usage: list \[options\] \[--\] \[\] Arguments: namespace Only list commands in this namespace Options: --raw To output raw command list --format=FORMAT The output format \(txt\) \[default: "txt"\] --short To skip describing command's arguments -h, --help Display help for the given command\. When no command is given display help for the list command --silent Do not output any message -q, --quiet Only errors are displayed. All other output is suppressed -V, --version Display this application version --ansi\|--no-ansi Force \(or disable --no-ansi\) ANSI output -n, --no-interaction Do not ask any interactive question -v\|vv\|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Help: The list command lists all commands: console list You can also display the commands for a specific namespace: console list test It's also possible to get raw list of commands \(useful for embedding command runner\): console list --raw ================================================ FILE: src/components/console/spec/fixtures/text/application_run4.txt ================================================ foo UNKNOWN ================================================ FILE: src/components/console/spec/fixtures/text/application_run5.txt ================================================ Description: Display help for a command Usage: help \[options\] \[--\] \[\] Arguments: command_name The command name \[default: "help"\] Options: --format=FORMAT The output format \(txt\) \[default: "txt"\] --raw To output raw command help -h, --help Display help for the given command\. When no command is given display help for the list command --silent Do not output any message -q, --quiet Only errors are displayed. All other output is suppressed -V, --version Display this application version --ansi\|--no-ansi Force \(or disable --no-ansi\) ANSI output -n, --no-interaction Do not ask any interactive question -v\|vv\|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Help: The help command displays help for a given command: console help list To display the list of available commands, please use the list command\. ================================================ FILE: src/components/console/spec/fixtures/text/command_1.txt ================================================ Description: command 1 description Usage: descriptor:command1 alias1 alias2 Help: command 1 help ================================================ FILE: src/components/console/spec/fixtures/text/command_2.txt ================================================ Description: command 2 description Usage: descriptor:command2 [options] [--] \ descriptor:command2 -o|--option_name \ descriptor:command2 \ Arguments: argument_name Options: -o, --option_name Help: command 2 help ================================================ FILE: src/components/console/spec/fixtures/text/input_argument_1.txt ================================================ argument_name ================================================ FILE: src/components/console/spec/fixtures/text/input_argument_2.txt ================================================ argument_name argument description ================================================ FILE: src/components/console/spec/fixtures/text/input_argument_3.txt ================================================ argument_name argument description [default: "default_value"] ================================================ FILE: src/components/console/spec/fixtures/text/input_argument_4.txt ================================================ argument_name multiline argument description ================================================ FILE: src/components/console/spec/fixtures/text/input_argument_with_style.txt ================================================ argument_name argument description [default: "\style\"] ================================================ FILE: src/components/console/spec/fixtures/text/input_definition_1.txt ================================================ ================================================ FILE: src/components/console/spec/fixtures/text/input_definition_2.txt ================================================ Arguments: argument_name ================================================ FILE: src/components/console/spec/fixtures/text/input_definition_3.txt ================================================ Options: -o, --option_name ================================================ FILE: src/components/console/spec/fixtures/text/input_definition_4.txt ================================================ Arguments: argument_name Options: -o, --option_name ================================================ FILE: src/components/console/spec/fixtures/text/input_option_1.txt ================================================ -o, --option_name ================================================ FILE: src/components/console/spec/fixtures/text/input_option_2.txt ================================================ -o, --option_name[=OPTION_NAME] option description [default: "default_value"] ================================================ FILE: src/components/console/spec/fixtures/text/input_option_3.txt ================================================ -o, --option_name=OPTION_NAME option description ================================================ FILE: src/components/console/spec/fixtures/text/input_option_4.txt ================================================ -o, --option_name[=OPTION_NAME] option description (multiple values allowed) ================================================ FILE: src/components/console/spec/fixtures/text/input_option_5.txt ================================================ -o, --option_name=OPTION_NAME multiline option description ================================================ FILE: src/components/console/spec/fixtures/text/input_option_6.txt ================================================ -o|O, --option_name=OPTION_NAME option with multiple shortcuts ================================================ FILE: src/components/console/spec/fixtures/text/input_option_with_style.txt ================================================ -o, --option_name=OPTION_NAME option description [default: "\style\"] ================================================ FILE: src/components/console/spec/fixtures/text/input_option_with_style_array.txt ================================================ -o, --option_name=OPTION_NAME option description [default: ["\Hello\","\world\"]] (multiple values allowed) ================================================ FILE: src/components/console/spec/formatter/null_spec.cr ================================================ require "../spec_helper" struct NullFormatterTest < ASPEC::TestCase def test_has_style : Nil ACON::Formatter::Null.new.has_style?("error").should be_false end def test_style : Nil ACON::Formatter::Null.new.style("error").should be_a ACON::Formatter::NullStyle end end ================================================ FILE: src/components/console/spec/formatter/null_style_spec.cr ================================================ require "../spec_helper" struct NullStyleTest < ASPEC::TestCase def test_apply : Nil ACON::Formatter::NullStyle.new.apply("foo").should eq "foo" end def test_set_foreground : Nil style = ACON::Formatter::NullStyle.new style.foreground = :red style.apply("foo").should eq "foo" end def test_set_background : Nil style = ACON::Formatter::NullStyle.new style.background = :red style.apply("foo").should eq "foo" end def test_options : Nil style = ACON::Formatter::NullStyle.new style.add_option :bold style.apply("foo").should eq "foo" style.remove_option :bold style.apply("foo").should eq "foo" end end ================================================ FILE: src/components/console/spec/formatter/output_formatter_spec.cr ================================================ require "../spec_helper" struct OutputFormatterTest < ASPEC::TestCase @formatter : ACON::Formatter::Output def initialize @formatter = ACON::Formatter::Output.new true end def test_format_empty_tag : Nil @formatter.format("foo<>bar").should eq "foo<>bar" end def test_format_lg_char_escaping : Nil @formatter.format("foo\\bar \\ baz \\").should eq "foo << \e[32mbar \\ baz\e[39m \\" @formatter.format("\\some info\\").should eq "some info" ACON::Formatter::Output.escape("some info").should eq "\\some info\\" @formatter.format("Some\\Path\\ToFile does work very well!").should eq "\e[33mSome\\Path\\ToFile does work very well!\e[39m" end def test_format_built_in_styles : Nil @formatter.has_style?("error").should be_true @formatter.has_style?("info").should be_true @formatter.has_style?("comment").should be_true @formatter.has_style?("question").should be_true @formatter.format("some error").should eq "\e[97;41msome error\e[39;49m" @formatter.format("some info").should eq "\e[32msome info\e[39m" @formatter.format("some comment").should eq "\e[33msome comment\e[39m" @formatter.format("some question").should eq "\e[30;46msome question\e[39;49m" end def test_format_nested_styles : Nil @formatter.format("some some info error").should eq "\e[97;41msome \e[39;49m\e[32msome info\e[39m\e[97;41m error\e[39;49m" end def test_format_deeply_nested_styles : Nil @formatter.format("errorinfocommenterror").should eq "\e[97;41merror\e[39;49m\e[32minfo\e[39m\e[33mcomment\e[39m\e[97;41merror\e[39;49m" end def test_format_adjacent_styles : Nil @formatter.format("some errorsome info").should eq "\e[97;41msome error\e[39;49m\e[32msome info\e[39m" end def test_format_adjacent_styles_not_greedy : Nil @formatter.format("(>=2.0,<2.3)").should eq "(\e[32m>=2.0,<2.3\e[39m)" end def test_format_style_escaping : Nil @formatter.format(%((#{@formatter.class.escape "z>=2.0,<\\<))).should eq "(\e[32mz>=2.0,<<#{@formatter.class.escape "some error"})).should eq "\e[32msome error\e[39m" end def test_format_custom_style : Nil style = ACON::Formatter::OutputStyle.new :blue, :white @formatter.set_style "test", style @formatter.style("test").should eq style @formatter.style("info").should_not eq style style = ACON::Formatter::OutputStyle.new :blue, :white @formatter.set_style "b", style @formatter.format("some messagecustom").should eq "\e[34;107msome message\e[39;49m\e[34;107mcustom\e[39;49m" # TODO: Also assert it works when nested. end def test_format_redefine_style : Nil style = ACON::Formatter::OutputStyle.new :blue, :white @formatter.set_style "info", style @formatter.format("some custom message").should eq "\e[34;107msome custom message\e[39;49m" end def test_format_inline_style : Nil @formatter.format("some text").should eq "\e[34;41msome text\e[39;49m" @formatter.format("some text").should eq "\e[34;41msome text\e[39;49m" end @[DataProvider("inline_style_options_provider")] def test_format_inline_style_options(tag : String, expected : String?, input : String?, truecolor : Bool) : Nil if truecolor && "truecolor" != ENV["COLORTERM"]? pending! "The terminal does not support true colors." end style_string = tag.strip "<>" style = @formatter.create_style_from_string style_string if expected.nil? style.should be_nil expected = "#{tag}#{input}" @formatter.format(expected).should eq expected else style.should be_a ACON::Formatter::OutputStyle @formatter.format("#{tag}#{input}").should eq expected @formatter.format("#{tag}#{input}").should eq expected end end def inline_style_options_provider : Tuple { {"", nil, nil, false}, {"", nil, nil, false}, {"", "\e[32m[test]\e[39m", "[test]", false}, {"", "\e[32;44ma\e[39;49m", "a", false}, {"", "\e[32;1mb\e[39;22m", "b", false}, {"", "\e[32;7m\e[39;27m", "", false}, {"", "\e[32;1;4mz\e[39;22;24m", "z", false}, {"", "\e[32;1;4;7md\e[39;22;24;27m", "d", false}, {"", "\e[38;2;0;255;0;48;2;0;0;255m[test]\e[39;49m", "[test]", true}, } end def test_format_non_style_tag : Nil @formatter .format("some styled

single-char tag

") .should eq "\e[32msome \e[39m\e[32m\e[39m\e[32m \e[39m\e[32m\e[39m\e[32m styled \e[39m\e[32m

\e[39m\e[32msingle-char tag\e[39m\e[32m

\e[39m" end def test_format_long_string : Nil long = "\\" * 14_000 @formatter.format("some error#{long}").should eq "\e[97;41msome error\e[39;49m#{long}" end def test_has_style : Nil @formatter = ACON::Formatter::Output.new @formatter.has_style?("error").should be_true @formatter.has_style?("info").should be_true @formatter.has_style?("comment").should be_true @formatter.has_style?("question").should be_true end @[DataProvider("decorated_and_non_decorated_output")] def test_format_not_decorated(input : String, expected_non_decorated_output : String, expected_decorated_output : String, term_emulator : String) : Nil previous_term_emulator = ENV["TERMINAL_EMULATOR"]? ENV["TERMINAL_EMULATOR"] = term_emulator begin ACON::Formatter::Output.new(true).format(input).should eq expected_decorated_output ACON::Formatter::Output.new(false).format(input).should eq expected_non_decorated_output ensure if previous_term_emulator ENV["TERMINAL_EMULATOR"] = previous_term_emulator else ENV.delete "TERMINAL_EMULATOR" end end end def decorated_and_non_decorated_output : Tuple { {"some error", "some error", "\e[97;41msome error\e[39;49m", "foo"}, {"some info", "some info", "\e[32msome info\e[39m", "foo"}, {"some comment", "some comment", "\e[33msome comment\e[39m", "foo"}, {"some question", "some question", "\e[30;46msome question\e[39;49m", "foo"}, {"some text with inline style", "some text with inline style", "\e[31msome text with inline style\e[39m", "foo"}, {"some URL", "some URL", "\e]8;;idea://open/?file=/path/SomeFile.php&line=12\e\\some URL\e]8;;\e\\", "foo"}, {"some URL", "some URL", "some URL", "JetBrains-JediTerm"}, } end def test_format_with_line_breaks : Nil @formatter.format("\nsome text").should eq "\e[32m\nsome text\e[39m" @formatter.format("some text\n").should eq "\e[32msome text\n\e[39m" @formatter.format("\nsome text\n").should eq "\e[32m\nsome text\n\e[39m" @formatter.format("\nsome text\nmore text\n").should eq "\e[32m\nsome text\nmore text\n\e[39m" end def test_format_and_wrap : Nil @formatter.format_and_wrap("ooobar bbz", 2).should eq "oo\no\e[97;41mb\e[39;49m\n\e[97;41mar\e[39;49m\nbb\nz" @formatter.format_and_wrap("pre foo bar baz post", 2).should eq "pr\ne \e[97;41m\e[39;49m\n\e[97;41mfo\e[39;49m\n\e[97;41mo \e[39;49m\n\e[97;41mba\e[39;49m\n\e[97;41mr \e[39;49m\n\e[97;41mba\e[39;49m\n\e[97;41mz\e[39;49m \npo\nst" @formatter.format_and_wrap("pre foo bar baz post", 3).should eq "pre\e[97;41m\e[39;49m\n\e[97;41mfoo\e[39;49m\n\e[97;41mbar\e[39;49m\n\e[97;41mbaz\e[39;49m\npos\nt" @formatter.format_and_wrap("pre foo bar baz post", 4).should eq "pre \e[97;41m\e[39;49m\n\e[97;41mfoo \e[39;49m\n\e[97;41mbar \e[39;49m\n\e[97;41mbaz\e[39;49m \npost" @formatter.format_and_wrap("pre foo bbr baz post", 5).should eq "pre \e[97;41mf\e[39;49m\n\e[97;41moo bb\e[39;49m\n\e[97;41mr baz\e[39;49m\npost" @formatter.format_and_wrap("Lorem ipsum dolor sit amet", 4).should eq "Lore\nm \e[97;41mip\e[39;49m\n\e[97;41msum\e[39;49m \ndolo\nr \e[32msi\e[39m\n\e[32mt\e[39m am\net" @formatter.format_and_wrap("Lorem ipsum dolor sit amet", 8).should eq "Lorem \e[97;41mip\e[39;49m\n\e[97;41msum\e[39;49m dolo\nr \e[32msit\e[39m am\net" @formatter.format_and_wrap("Lorem ipsum dolor sit, amet et laudantium architecto", 18).should eq "Lorem \e[97;41mipsum\e[39;49m dolor \e[32m\e[39m\n\e[32msit\e[39m, \e[97;41mamet\e[39;49m et \e[32mlauda\e[39m\n\e[32mntium\e[39m architecto" end def test_format_and_wrap_non_decorated : Nil @formatter = ACON::Formatter::Output.new @formatter.format_and_wrap("ooobar baz", 2).should eq "oo\nob\nar\nba\nz" @formatter.format_and_wrap("pre foo bbr baz post", 2).should eq "pr\ne \nfo\no \nbb\nr \nba\nz \npo\nst" @formatter.format_and_wrap("pre foo bar baz post", 3).should eq "pre\nfoo\nbar\nbaz\npos\nt" @formatter.format_and_wrap("pre foo bar baz post", 4).should eq "pre \nfoo \nbar \nbaz \npost" @formatter.format_and_wrap("pre foo bbr baz post", 5).should eq "pre f\noo bb\nr baz\npost" @formatter.format_and_wrap(nil, 5).should eq "" @formatter.format_and_wrap("And Then There Were None", 15).should eq "And Then There \nWere None" end end ================================================ FILE: src/components/console/spec/formatter/output_formatter_style_spec.cr ================================================ require "../spec_helper" describe ACON::Formatter::OutputStyle do it ".new" do ACON::Formatter::OutputStyle.new(:green, :black, Colorize::Mode[:bold, :underline]) .apply("foo").should eq "\e[32;40;1;4mfoo\e[39;49;22;24m" ACON::Formatter::OutputStyle.new(:red, options: Colorize::Mode::Blink) .apply("foo").should eq "\e[31;5mfoo\e[39;25m" ACON::Formatter::OutputStyle.new(background: :white) .apply("foo").should eq "\e[107mfoo\e[49m" ACON::Formatter::OutputStyle.new("red", "#000000", Colorize::Mode[:bold, :underline]) .apply("foo").should eq "\e[31;48;2;0;0;0;1;4mfoo\e[39;49;22;24m" end describe "foreground=" do it "with ANSI color" do style = ACON::Formatter::OutputStyle.new style.foreground = :black style.apply("foo").should eq "\e[30mfoo\e[39m" end it "with default value" do style = ACON::Formatter::OutputStyle.new style.foreground = :default style.apply("foo").should eq "foo" end it "with HEX RGB value" do style = ACON::Formatter::OutputStyle.new style.foreground = "#aedfff" style.apply("foo").should eq "\e[38;2;174;223;255mfoo\e[39m" end it "with invalid color" do style = ACON::Formatter::OutputStyle.new expect_raises ArgumentError do style.foreground = "invalid" end end end describe "background=" do it "with ANSI color" do style = ACON::Formatter::OutputStyle.new style.background = :black style.apply("foo").should eq "\e[40mfoo\e[49m" end it "with default value" do style = ACON::Formatter::OutputStyle.new style.background = :default style.apply("foo").should eq "foo" end it "with HEX RGB value" do style = ACON::Formatter::OutputStyle.new style.background = "#aedfff" style.apply("foo").should eq "\e[48;2;174;223;255mfoo\e[49m" end it "with invalid color" do style = ACON::Formatter::OutputStyle.new expect_raises ArgumentError do style.background = "invalid" end end end it "add/remove_option" do style = ACON::Formatter::OutputStyle.new style.add_option "reverse" style.add_option "hidden" style.apply("foo").should eq "\e[7;8mfoo\e[27;28m" style.add_option "bold" style.apply("foo").should eq "\e[1;7;8mfoo\e[22;27;28m" style.remove_option "reverse" style.apply("foo").should eq "\e[1;8mfoo\e[22;28m" style.add_option "bold" style.apply("foo").should eq "\e[1;8mfoo\e[22;28m" style.options = Colorize::Mode::Bold style.apply("foo").should eq "\e[1mfoo\e[22m" end it "href" do previous_term_emulator = ENV["TERMINAL_EMULATOR"]? ENV.delete "TERMINAL_EMULATOR" style = ACON::Formatter::OutputStyle.new begin style.href = "idea://open/?file=/path/SomeFile.php&line=12" style.apply("some URL").should eq "\e]8;;idea://open/?file=/path/SomeFile.php&line=12\e\\some URL\e]8;;\e\\" ensure if previous_term_emulator ENV["TERMINAL_EMULATOR"] = previous_term_emulator else ENV.delete "TERMINAL_EMULATOR" end end end end ================================================ FILE: src/components/console/spec/formatter/output_formatter_style_stack_spec.cr ================================================ require "../spec_helper" describe ACON::Formatter::OutputStyleStack do it "#<<" do stack = ACON::Formatter::OutputStyleStack.new stack << ACON::Formatter::OutputStyle.new :white, :black stack << (s2 = ACON::Formatter::OutputStyle.new :yellow, :blue) stack.current.should eq s2 stack << (s3 = ACON::Formatter::OutputStyle.new :green, :red) stack.current.should eq s3 end describe "#pop" do it "returns the oldest style" do stack = ACON::Formatter::OutputStyleStack.new stack << (s1 = ACON::Formatter::OutputStyle.new :white, :black) stack << (s2 = ACON::Formatter::OutputStyle.new :yellow, :blue) stack.pop.should eq s2 stack.pop.should eq s1 end it "returns the default style if empty" do stack = ACON::Formatter::OutputStyleStack.new style = ACON::Formatter::OutputStyle.new stack.pop.should eq style end it "allows popping a specific style" do stack = ACON::Formatter::OutputStyleStack.new stack << (s1 = ACON::Formatter::OutputStyle.new :white, :black) stack << (s2 = ACON::Formatter::OutputStyle.new :yellow, :blue) stack << ACON::Formatter::OutputStyle.new :green, :red stack.pop(s2).should eq s2 stack.pop.should eq s1 end it "nested styles" do stack = ACON::Formatter::OutputStyleStack.new stack << (s1 = ACON::Formatter::OutputStyle.new :white, :red) stack << (s2 = ACON::Formatter::OutputStyle.new :green, :default) stack.pop(s2).should eq s2 stack.pop(s1).should eq s1 end it "invalid pop" do stack = ACON::Formatter::OutputStyleStack.new stack << ACON::Formatter::OutputStyle.new :white, :black expect_raises ACON::Exception::InvalidArgument, "Provided style is not present in the stack." do stack.pop ACON::Formatter::OutputStyle.new :yellow, :blue end end end end ================================================ FILE: src/components/console/spec/helper/abstract_question_helper_test_case.cr ================================================ require "../spec_helper" abstract struct AbstractQuestionHelperTest < ASPEC::TestCase def initialize @helper_set = ACON::Helper::HelperSet.new ACON::Helper::Formatter.new @output = ACON::Output::IO.new IO::Memory.new, decorated: false end protected def with_input(data : String, interactive : Bool = true, & : ACON::Input::Interface -> Nil) : Nil input_stream = IO::Memory.new data input = ACON::Input::Hash.new input.stream = input_stream input.interactive = interactive yield input end protected def assert_output_contains(string : String, normalize : Bool = false) : Nil stream = @output.io stream.rewind output = stream.to_s if normalize output = output.gsub EOL, "\n" end output.should contain string end end ================================================ FILE: src/components/console/spec/helper/athena_question_spec.cr ================================================ require "../spec_helper" require "./abstract_question_helper_test_case" struct AthenaQuestionTest < AbstractQuestionHelperTest @helper : ACON::Helper::Question def initialize @helper = ACON::Helper::AthenaQuestion.new super end def test_ask_choice_question : Nil heroes = ["Superman", "Batman", "Spiderman"] self.with_input "\n1\n 1 \nGeorge\n1\nGeorge" do |input| question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes, 2 question.max_attempts = 1 # First answer is empty, so should use default @helper.ask(input, @output, question).should eq "Spiderman" self.assert_output_contains "Who is your favorite superhero? [Spiderman]" question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes question.max_attempts = 1 @helper.ask(input, @output, question).should eq "Batman" @helper.ask(input, @output, question).should eq "Batman" question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes question.error_message = "Input '%s' is not a superhero!" question.max_attempts = 2 @helper.ask(input, @output, question).should eq "Batman" self.assert_output_contains "Input 'George' is not a superhero!" begin question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes, 1 question.max_attempts = 1 @helper.ask input, @output, question rescue ex : ACON::Exception::InvalidArgument ex.message.should eq "Value 'George' is invalid." end end end def test_ask_multiple_choice : Nil heroes = ["Superman", "Batman", "Spiderman"] self.with_input "1\n0,2\n 0 , 2 \n\n\n" do |input| question = ACON::Question::MultipleChoice.new "Who is your favorite superhero?", heroes question.max_attempts = 1 @helper.ask(input, @output, question).should eq ["Batman"] @helper.ask(input, @output, question).should eq ["Superman", "Spiderman"] @helper.ask(input, @output, question).should eq ["Superman", "Spiderman"] question = ACON::Question::MultipleChoice.new "Who is your favorite superhero?", heroes, "0,1" question.max_attempts = 1 @helper.ask(input, @output, question).should eq ["Superman", "Batman"] self.assert_output_contains "Who is your favorite superhero? [Superman, Batman]" question = ACON::Question::MultipleChoice.new "Who is your favorite superhero?", heroes, " 0 , 1 " question.max_attempts = 1 @helper.ask(input, @output, question).should eq ["Superman", "Batman"] self.assert_output_contains "Who is your favorite superhero? [Superman, Batman]" end end def test_ask_choice_with_choice_value_as_default : Nil question = ACON::Question::Choice.new "Who is your favorite superhero?", ["Superman", "Batman", "Spiderman"], "Batman" question.max_attempts = 1 self.with_input "Batman\n" do |input| @helper.ask(input, @output, question).should eq "Batman" end self.assert_output_contains "Who is your favorite superhero? [Batman]" end def test_ask_returns_nil_if_validator_allows_it : Nil question = ACON::Question(String?).new "Who is your favorite superhero?", nil question.validator do |value| value end self.with_input "\n" do |input| @helper.ask(input, @output, question).should be_nil end end def test_ask_escapes_default_value : Nil self.with_input "\\" do |input| question = ACON::Question.new "Can I have a backslash?", "\\" @helper.ask input, @output, question self.assert_output_contains %q(Can I have a backslash? [\]) end end def test_ask_format_and_escape_label : Nil question = ACON::Question.new %q(Do you want to use Foo\Bar or Foo\Baz\?), "Foo\\Baz" self.with_input "Foo\\Bar" do |input| @helper.ask input, @output, question end self.assert_output_contains %q( Do you want to use Foo\Bar or Foo\Baz\? [Foo\Baz]:) end def test_ask_label_trailing_backslash : Nil question = ACON::Question(String?).new "Question with a trailing \\", nil self.with_input "sure" do |input| @helper.ask input, @output, question end self.assert_output_contains "Question with a trailing \\" end def test_ask_raises_on_missing_input : Nil self.with_input "" do |input| question = ACON::Question(String?).new "What's your name?", nil expect_raises ACON::Exception::MissingInput, "Aborted." do @helper.ask input, @output, question end end end def test_ask_choice_question_padding : Nil question = ACON::Question::Choice.new "qqq", {"foo" => "foo", "żółw" => "bar", "łabądź" => "baz"} self.with_input "foo\n" do |input| @helper.ask input, @output, question end self.assert_output_contains <<-OUT, true qqq: [foo ] foo [żółw ] bar [łabądź] baz > OUT end def test_ask_choice_question_custom_prompt : Nil question = ACON::Question::Choice.new "qqq", {"foo"} question.prompt = " >ccc> " self.with_input "foo\n" do |input| @helper.ask input, @output, question end self.assert_output_contains <<-OUT, true qqq: [0] foo >ccc> OUT end def test_ask_multiline_question_includes_help_text : Nil expected = "Write an essay (press Ctrl+D to continue)" # TODO: Update expected message on windows # expected = "Write an essay (press Ctrl+Z then Enter to continue)" question = ACON::Question(String?).new "Write an essay", nil question.multi_line = true self.with_input "\\" do |input| @helper.ask input, @output, question end self.assert_output_contains expected end end ================================================ FILE: src/components/console/spec/helper/formatter_spec.cr ================================================ require "../spec_helper" private def normalize(input : String) : String input.gsub EOL, "\n" end describe ACON::Helper::Formatter do it "#format_section" do ACON::Helper::Formatter.new.format_section("cli", "some text to display").should eq "[cli] some text to display" end describe "#format_block" do it "formats" do formatter = ACON::Helper::Formatter.new formatter.format_block("Some text to display", "error").should eq " Some text to display " formatter.format_block({"Some text to display", "foo bar"}, "error").should eq " Some text to display \n foo bar " formatter.format_block("Some text to display", "error", true).should eq normalize <<-BLOCK Some text to display BLOCK end it "formats with diacritic letters" do formatter = ACON::Helper::Formatter.new formatter.format_block("Du texte à afficher", "error", true).should eq normalize <<-BLOCK Du texte à afficher BLOCK end pending "formats with double with characters" do end it "escapes < within the block" do ACON::Helper::Formatter.new.format_block("some info", "error", true).should eq normalize <<-BLOCK \\some info\\ BLOCK end end describe "#truncate" do it "with shorter length than message with suffix" do formatter = ACON::Helper::Formatter.new message = "testing wrapping" formatter.truncate(message, 4).should eq "test..." formatter.truncate(message, 14).should eq "testing wrappi..." formatter.truncate(message, 16).should eq "testing wrapping..." formatter.truncate("zażółć gęślą jaźń", 12).should eq "zażółć gęślą..." end it "with custom suffix" do ACON::Helper::Formatter.new.truncate("testing truncate", 4, "!").should eq "test!" end it "with longer length than message with suffix" do ACON::Helper::Formatter.new.truncate("test", 10).should eq "test" end it "with negative length" do formatter = ACON::Helper::Formatter.new message = "testing truncate" formatter.truncate(message, -3).should eq "testing trunc..." formatter.truncate(message, -100).should eq "..." end end end ================================================ FILE: src/components/console/spec/helper/helper_set_spec.cr ================================================ require "../spec_helper" describe ACON::Helper::HelperSet do describe "compiler errors", tags: "compiled" do it "when the provided helper type is not an `ACON::Helper::Interface`" do ASPEC::Methods.assert_compile_time_error "Helper class type 'String' is not an 'ACON::Helper::Interface'.", <<-CR require "../spec_helper.cr" ACON::Helper::HelperSet.new[String]? CR end end end ================================================ FILE: src/components/console/spec/helper/helper_spec.cr ================================================ require "../spec_helper" struct HelperTest < ASPEC::TestCase @[TestWith( {0, "< 1 sec"}, {1, "1 sec"}, {2, "2 secs"}, {59, "59 secs"}, {60, "1 min"}, {61, "1 min"}, {119, "1 min"}, {120, "2 mins"}, {121, "2 mins"}, {4.minutes, "4 mins"}, {3599, "59 mins"}, {3600, "1 hr"}, {7199, "1 hr"}, {7200, "2 hrs"}, {7201, "2 hrs"}, {86399, "23 hrs"}, {86400, "1 day"}, {86401, "1 day"}, {172_799, "1 day"}, {172_800, "2 days"}, {172_801, "2 days"}, )] def test_format_time(seconds : Int32 | Time::Span, expected : String) : Nil ACON::Helper.format_time(seconds).should eq expected end @[TestWith( {"abc", "abc"}, {"abc", "abc"}, {"a\033[1;36mbc", "abc"}, {"a\033]8;;http://url\033\\b\033]8;;\033\\c", "abc"}, )] def test_remove_docoration(decorated_text : String, expected : String) : Nil ACON::Helper.remove_decoration(ACON::Formatter::Output.new, decorated_text).should eq expected end end ================================================ FILE: src/components/console/spec/helper/output_wrapper_spec.cr ================================================ struct OutputWrapperTest < ASPEC::TestCase def test_wrap_no_cut : Nil ACON::Helper::OutputWrapper.new.wrap( "Árvíztűrőtükörfúrógép https://github.com/crystal/crystal Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent vestibulum nulla quis urna maximus porttitor. Donec ullamcorper risus at libero ornare efficitur.", 20, ).should eq <<-TEXT Árvíztűrőtükörfúrógé p https://github.com/crystal/crystal Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent vestibulum nulla quis urna maximus porttitor. Donec ullamcorper risus at libero ornare efficitur. TEXT end def test_wrap_with_cut : Nil ACON::Helper::OutputWrapper.new(true).wrap( "Árvíztűrőtükörfúrógép https://github.com/crystal/crystal Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent vestibulum nulla quis urna maximus porttitor. Donec ullamcorper risus at libero ornare efficitur.", 20, ).should eq <<-TEXT Árvíztűrőtükörfúrógé p https://github.com/c rystal/crystal Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent vestibulum nulla quis urna maximus porttitor. Donec ullamcorper risus at libero ornare efficitur. TEXT end end ================================================ FILE: src/components/console/spec/helper/progress_bar_spec.cr ================================================ require "../spec_helper" struct ProgressBarTest < ASPEC::TestCase @clock : ACLK::Spec::MockClock def initialize ENV["COLUMNS"] = "120" @clock = ACLK::Spec::MockClock.new end protected def tear_down : Nil ENV.delete "COLUMNS" end def test_multiple_start : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.start bar.advance bar.start self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 1 [->--------------------------]"), self.generate_output(" 0 [>---------------------------]"), ) end def test_advance : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.start at: 15 bar.advance self.assert_output( output, " 15 [--------------->------------]", self.generate_output(" 16 [---------------->-----------]"), ) end def test_resume_with_max : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 5_000, 0 bar.start at: 1_000 self.assert_output( output, " 1000/5000 [=====>----------------------] 20%", ) end def test_regular_time_estimation : Nil bar = ACON::Helper::ProgressBar.new self.output, 1_200, 0, clock: @clock bar.start bar.advance bar.advance @clock.sleep 1.second bar.estimated.should eq 600 end def test_resumed_time_estimation : Nil bar = ACON::Helper::ProgressBar.new self.output, 1_200, 0, clock: @clock bar.start at: 599 bar.advance @clock.sleep 1.second bar.estimated.should eq 1_200 bar.remaining.should eq 600 end def test_advance_with_step : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.start bar.advance 5 self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 5 [----->----------------------]"), ) end def test_advance_multiple_times : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.start bar.advance 3 bar.advance 2 self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 3 [--->------------------------]"), self.generate_output(" 5 [----->----------------------]"), ) end def test_advance_over_max : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 10, 0 bar.progress = 9 bar.advance bar.advance self.assert_output( output, " 9/10 [=========================>--] 90%", self.generate_output(" 10/10 [============================] 100%"), self.generate_output(" 11/11 [============================] 100%"), ) end def test_regress : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.start bar.advance bar.advance bar.advance -1 self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 1 [->--------------------------]"), self.generate_output(" 2 [-->-------------------------]"), self.generate_output(" 1 [->--------------------------]"), ) end def test_regress_multiple_times : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.start bar.advance 3 bar.advance 3 bar.advance -1 bar.advance -2 self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 3 [--->------------------------]"), self.generate_output(" 6 [------>---------------------]"), self.generate_output(" 5 [----->----------------------]"), self.generate_output(" 3 [--->------------------------]"), ) end def test_regress_with_step : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.start bar.advance 4 bar.advance 4 bar.advance -2 self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 4 [---->-----------------------]"), self.generate_output(" 8 [-------->-------------------]"), self.generate_output(" 6 [------>---------------------]"), ) end def test_regress_below_min : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 10, 0 bar.progress = 1 bar.advance -1 bar.advance -1 self.assert_output( output, " 1/10 [==>-------------------------] 10%", self.generate_output(" 0/10 [>---------------------------] 0%"), ) end def test_format_max_constructor_no_format : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 10, 0 bar.start bar.advance 10 bar.finish self.assert_output( output, " 0/10 [>---------------------------] 0%", self.generate_output(" 10/10 [============================] 100%"), ) end def test_format_max_start_no_format : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.start 10 bar.advance 10 bar.finish self.assert_output( output, " 0/10 [>---------------------------] 0%", self.generate_output(" 10/10 [============================] 100%"), ) end def test_format_max_constructor_explicit_format_before_start : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 10, 0 bar.format = :normal bar.start bar.advance 10 bar.finish self.assert_output( output, " 0/10 [>---------------------------] 0%", self.generate_output(" 10/10 [============================] 100%"), ) end def test_format_max_start_explicit_format_before_start : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.format = :normal bar.start 10 bar.advance 10 bar.finish self.assert_output( output, " 0/10 [>---------------------------] 0%", self.generate_output(" 10/10 [============================] 100%") ) end def test_customiations : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 10, 0 bar.bar_width = 10 bar.bar_character = "_" bar.empty_bar_character = " " bar.progress_character = "/" bar.format = " %current%/%max% [%bar%] %percent:3s%%" bar.start bar.advance self.assert_output( output, " 0/10 [/ ] 0%", self.generate_output(" 1/10 [_/ ] 10%"), ) end def test_display_without_start : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 50, 0 bar.display self.assert_output( output, " 0/50 [>---------------------------] 0%" ) end def test_display_quiet_verbosity : Nil bar = ACON::Helper::ProgressBar.new output = self.output(verbosity: :quiet), 50, 0 bar.display self.assert_output( output, "" ) end def test_finish_without_start : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 50, 0 bar.finish self.assert_output( output, " 50/50 [============================] 100%" ) end def test_percent : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 50, 0 bar.start bar.display bar.advance bar.advance self.assert_output( output, " 0/50 [>---------------------------] 0%", self.generate_output(" 1/50 [>---------------------------] 2%"), self.generate_output(" 2/50 [=>--------------------------] 4%"), ) end def test_overwrite_with_shorter_line : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 50, 0 bar.format = " %current%/%max% [%bar%] %percent:3s%%" bar.start bar.display bar.advance # Set short format bar.format = " %current%/%max% [%bar%]" bar.advance self.assert_output( output, " 0/50 [>---------------------------] 0%", self.generate_output(" 1/50 [>---------------------------] 2%"), self.generate_output(" 2/50 [=>--------------------------]"), ) end def test_overwrite_with_section_output : Nil sections = Array(ACON::Output::Section).new acon_output = self.output output = ACON::Output::Section.new acon_output.io, sections, verbosity: acon_output.verbosity, decorated: acon_output.decorated?, formatter: ACON::Formatter::Output.new bar = ACON::Helper::ProgressBar.new output, 50, 0 bar.start bar.display bar.advance bar.advance self.assert_output( output, " 0/50 [>---------------------------] 0%#{EOL}", "\e[1A\e[0J 1/50 [>---------------------------] 2%#{EOL}", "\e[1A\e[0J 2/50 [=>--------------------------] 4%#{EOL}", ) end def test_overwrite_with_ansi_section_output : Nil ENV["COLUMNS"] = "43" sections = Array(ACON::Output::Section).new acon_output = self.output output = ACON::Output::Section.new acon_output.io, sections, verbosity: acon_output.verbosity, decorated: acon_output.decorated?, formatter: ACON::Formatter::Output.new bar = ACON::Helper::ProgressBar.new output, 50, 0 bar.format = " \e[44;37m%current%/%max%\e[0m [%bar%] %percent:3s%%" bar.start bar.display bar.advance bar.advance self.assert_output( output, " \e[44;37m 0/50\e[0m [>---------------------------] 0%#{EOL}", "\e[1A\e[0J \e[44;37m 1/50\e[0m [>---------------------------] 2%#{EOL}", "\e[1A\e[0J \e[44;37m 2/50\e[0m [=>--------------------------] 4%#{EOL}", ) end def test_overwrite_multiple_progress_bars_with_section_output : Nil sections = Array(ACON::Output::Section).new acon_output = self.output output1 = ACON::Output::Section.new acon_output.io, sections, verbosity: acon_output.verbosity, decorated: acon_output.decorated?, formatter: ACON::Formatter::Output.new output2 = ACON::Output::Section.new acon_output.io, sections, verbosity: acon_output.verbosity, decorated: acon_output.decorated?, formatter: ACON::Formatter::Output.new bar1 = ACON::Helper::ProgressBar.new output1, 50, 0 bar2 = ACON::Helper::ProgressBar.new output2, 50, 0 bar1.start bar2.start bar2.advance bar1.advance self.assert_output( acon_output, " 0/50 [>---------------------------] 0%#{EOL}", " 0/50 [>---------------------------] 0%#{EOL}", "\e[1A\e[0J 1/50 [>---------------------------] 2%#{EOL}", "\e[2A\e[0J 1/50 [>---------------------------] 2%#{EOL}", "\e[1A\e[0J 1/50 [>---------------------------] 2%#{EOL}", " 1/50 [>---------------------------] 2%#{EOL}", ) end def test_message : Nil bar = ACON::Helper::ProgressBar.new self.output, minimum_seconds_between_redraws: 0 bar.message.should be_nil bar.set_message "other message", "other-message" bar.set_message "my message" bar.message.should eq "my message" bar.message("other-message").should eq "other message" end def test_overwrite_with_new_lines_in_message : Nil ACON::Helper::ProgressBar.set_format_definition "test", "%current%/%max% [%bar%] %percent:3s%% %message% EXISTING TEXT." bar = ACON::Helper::ProgressBar.new output = self.output, 50, 0 bar.format = "test" bar.start bar.display bar.set_message "MESSAGE\nTEXT!" bar.advance bar.set_message "OTHER\nTEXT!" bar.advance self.assert_output( output, " 0/50 [>---------------------------] 0% %message% EXISTING TEXT.", "\e[1G\e[2K 1/50 [>---------------------------] 2% MESSAGE\nTEXT! EXISTING TEXT.", "\e[1G\e[2K\e[1A\e[1G\e[2K 2/50 [=>--------------------------] 4% OTHER\nTEXT! EXISTING TEXT.", ) end def test_overwrite_with_section_output_with_newlines_in_message : Nil sections = Array(ACON::Output::Section).new acon_output = self.output output = ACON::Output::Section.new acon_output.io, sections, verbosity: acon_output.verbosity, decorated: acon_output.decorated?, formatter: ACON::Formatter::Output.new ACON::Helper::ProgressBar.set_format_definition "test", "%current%/%max% [%bar%] %percent:3s%% %message% EXISTING TEXT." bar = ACON::Helper::ProgressBar.new output, 50, 0 bar.format = "test" bar.start bar.display bar.set_message "MESSAGE\nTEXT!" bar.advance bar.set_message "OTHER\nTEXT!" bar.advance self.assert_output( output, " 0/50 [>---------------------------] 0% %message% EXISTING TEXT.#{EOL}", "\e[1A\e[0J 1/50 [>---------------------------] 2% MESSAGE\nTEXT! EXISTING TEXT.#{EOL}", "\e[2A\e[0J 2/50 [=>--------------------------] 4% OTHER\nTEXT! EXISTING TEXT.#{EOL}", ) end def test_multiple_sections_with_custom_format : Nil sections = Array(ACON::Output::Section).new acon_output = self.output output1 = ACON::Output::Section.new acon_output.io, sections, verbosity: acon_output.verbosity, decorated: acon_output.decorated?, formatter: ACON::Formatter::Output.new output2 = ACON::Output::Section.new acon_output.io, sections, verbosity: acon_output.verbosity, decorated: acon_output.decorated?, formatter: ACON::Formatter::Output.new ACON::Helper::ProgressBar.set_format_definition "custom", "%current%/%max% [%bar%] %percent:3s%% Fruitcake marzipan toffee. Cupcake gummi bears tart dessert ice cream chupa chups cupcake chocolate bar sesame snaps. Croissant halvah cookie jujubes powder macaroon. Fruitcake bear claw bonbon jelly beans oat cake pie muffin Fruitcake marzipan toffee." bar1 = ACON::Helper::ProgressBar.new output1, 50, 0 bar2 = ACON::Helper::ProgressBar.new output2, 50, 0 bar2.format = "custom" bar1.start bar2.start bar1.advance bar2.advance self.assert_output( acon_output, " 0/50 [>---------------------------] 0%#{EOL}", " 0/50 [>] 0% Fruitcake marzipan toffee. Cupcake gummi bears tart dessert ice cream chupa chups cupcake chocolate bar sesame snaps. Croissant halvah cookie jujubes powder macaroon. Fruitcake bear claw bonbon jelly beans oat cake pie muffin Fruitcake marzipan toffee.#{EOL}", "\e[4A\e[0J 0/50 [>] 0% Fruitcake marzipan toffee. Cupcake gummi bears tart dessert ice cream chupa chups cupcake chocolate bar sesame snaps. Croissant halvah cookie jujubes powder macaroon. Fruitcake bear claw bonbon jelly beans oat cake pie muffin Fruitcake marzipan toffee.#{EOL}", "\e[3A\e[0J 1/50 [>---------------------------] 2%#{EOL}", " 0/50 [>] 0% Fruitcake marzipan toffee. Cupcake gummi bears tart dessert ice cream chupa chups cupcake chocolate bar sesame snaps. Croissant halvah cookie jujubes powder macaroon. Fruitcake bear claw bonbon jelly beans oat cake pie muffin Fruitcake marzipan toffee.#{EOL}", "\e[3A\e[0J 1/50 [>] 2% Fruitcake marzipan toffee. Cupcake gummi bears tart dessert ice cream chupa chups cupcake chocolate bar sesame snaps. Croissant halvah cookie jujubes powder macaroon. Fruitcake bear claw bonbon jelly beans oat cake pie muffin Fruitcake marzipan toffee.#{EOL}", ) end def test_start_with_max : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.format = "%current%/%max% [%bar%]" bar.start 50 bar.advance self.assert_output( output, " 0/50 [>---------------------------]", self.generate_output(" 1/50 [>---------------------------]"), ) end def test_set_current_progress : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 50, 0 bar.start bar.display bar.advance bar.progress = 15 bar.progress = 25 self.assert_output( output, " 0/50 [>---------------------------] 0%", self.generate_output(" 1/50 [>---------------------------] 2%"), self.generate_output(" 15/50 [========>-------------------] 30%"), self.generate_output(" 25/50 [==============>-------------] 50%"), ) end def test_set_current_progress_before_start : Nil bar = ACON::Helper::ProgressBar.new self.output, minimum_seconds_between_redraws: 0 bar.progress = 15 bar.start_time.should_not be_nil end def test_redraw_frequency : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 6, 0 bar.redraw_frequency = 2 bar.start bar.progress = 1 bar.advance 2 bar.advance 2 bar.advance self.assert_output( output, " 0/6 [>---------------------------] 0%", self.generate_output(" 3/6 [==============>-------------] 50%"), self.generate_output(" 5/6 [=======================>----] 83%"), self.generate_output(" 6/6 [============================] 100%"), ) end def test_redraw_frequency_is_at_least_one_if_zero_given : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.redraw_frequency = 0 bar.start bar.advance self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 1 [->--------------------------]"), ) end def test_redraw_frequency_is_at_least_one_if_negative_given : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.redraw_frequency = -1 bar.start bar.advance self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 1 [->--------------------------]"), ) end def test_multi_byte_support : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.start bar.bar_character = "■" bar.advance 3 self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 3 [■■■>------------------------]"), ) end def test_clear : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 50, 0 bar.start bar.advance 25 bar.clear self.assert_output( output, " 0/50 [>---------------------------] 0%", self.generate_output(" 25/50 [==============>-------------] 50%"), self.generate_output(""), ) end def test_percent_not_hundred_before_complete : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 200, 0 bar.start bar.display bar.advance 199 bar.advance self.assert_output( output, " 0/200 [>---------------------------] 0%", self.generate_output(" 199/200 [===========================>] 99%"), self.generate_output(" 200/200 [============================] 100%"), ) end def test_non_decorated_output : Nil bar = ACON::Helper::ProgressBar.new output = self.output(decorated: false), 200, 0 bar.start 200.times do bar.advance end bar.finish self.assert_output( output, " 0/200 [>---------------------------] 0%#{EOL}", " 20/200 [==>-------------------------] 10%#{EOL}", " 40/200 [=====>----------------------] 20%#{EOL}", " 60/200 [========>-------------------] 30%#{EOL}", " 80/200 [===========>----------------] 40%#{EOL}", " 100/200 [==============>-------------] 50%#{EOL}", " 120/200 [================>-----------] 60%#{EOL}", " 140/200 [===================>--------] 70%#{EOL}", " 160/200 [======================>-----] 80%#{EOL}", " 180/200 [=========================>--] 90%#{EOL}", " 200/200 [============================] 100%", ) end def test_non_decorated_output_with_clear : Nil bar = ACON::Helper::ProgressBar.new output = self.output(decorated: false), 50, 0 bar.start bar.progress = 25 bar.clear bar.progress = 50 bar.finish self.assert_output( output, " 0/50 [>---------------------------] 0%#{EOL}", " 25/50 [==============>-------------] 50%#{EOL}", " 50/50 [============================] 100%", ) end def test_non_decorated_output_without_max : Nil bar = ACON::Helper::ProgressBar.new output = self.output(decorated: false), minimum_seconds_between_redraws: 0 bar.start bar.advance self.assert_output( output, " 0 [>---------------------------]#{EOL}", " 1 [->--------------------------]", ) end def test_parallel_bars : Nil output = self.output bar1 = ACON::Helper::ProgressBar.new output, 2, minimum_seconds_between_redraws: 0 bar2 = ACON::Helper::ProgressBar.new output, 3, minimum_seconds_between_redraws: 0 bar2.progress_character = "#" bar3 = ACON::Helper::ProgressBar.new output, minimum_seconds_between_redraws: 0 bar1.start output.print "\n" bar2.start output.print "\n" bar3.start 1.upto 3 do |idx| # Up two lines output.print "\e[2A" if idx <= 2 bar1.advance end output.print "\n" bar2.advance output.print "\n" bar3.advance end output.print "\e[2A" output.print "\n" output.print "\n" bar3.finish self.assert_output( output, " 0/2 [>---------------------------] 0%\n", " 0/3 [#---------------------------] 0%\n", " 0 [>---------------------------]", "\e[2A", self.generate_output(" 1/2 [==============>-------------] 50%"), "\n", self.generate_output(" 1/3 [=========#------------------] 33%"), "\n", self.generate_output(" 1 [->--------------------------]").rstrip, "\e[2A", self.generate_output(" 2/2 [============================] 100%"), "\n", self.generate_output(" 2/3 [==================#---------] 66%"), "\n", self.generate_output(" 2 [-->-------------------------]").rstrip, "\e[2A", "\n", self.generate_output(" 3/3 [============================] 100%"), "\n", self.generate_output(" 3 [--->------------------------]").rstrip, "\e[2A", "\n", "\n", self.generate_output(" 3 [============================]").rstrip, ) end def test_without_max : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.start bar.advance bar.advance bar.advance bar.finish self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 1 [->--------------------------]"), self.generate_output(" 2 [-->-------------------------]"), self.generate_output(" 3 [--->------------------------]"), self.generate_output(" 3 [============================]"), ) end def test_setting_max_during_progression : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.start bar.progress = 2 bar.max_steps = 10 bar.progress = 5 bar.max_steps = 100 bar.progress = 10 bar.finish self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 2 [-->-------------------------]"), self.generate_output(" 5/10 [==============>-------------] 50%"), self.generate_output(" 10/100 [==>-------------------------] 10%"), self.generate_output(" 100/100 [============================] 100%"), ) end def test_with_small_screen : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 ENV["COLUMNS"] = "12" bar.start bar.advance ENV["COLUMNS"] = "120" self.assert_output( output, " 0 [>---]", self.generate_output(" 1 [->--]"), ) end def test_custom_placeholder_format : Nil ACON::Helper::ProgressBar.set_placeholder_formatter "remaining_steps" do |bar| "#{bar.max_steps - bar.progress}" end bar = ACON::Helper::ProgressBar.new output = self.output, 3, 0 bar.format = " %remaining_steps% [%bar%]" bar.start bar.advance bar.finish self.assert_output( output, " 3 [>---------------------------]", self.generate_output(" 2 [=========>------------------]"), self.generate_output(" 0 [============================]"), ) end def test_adding_instance_placeholder_formatter : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 3, 0 bar.format = " %countdown% [%bar%]" bar.set_placeholder_formatter "countdown" do "#{bar.max_steps - bar.progress}" end bar.start bar.advance bar.finish self.assert_output( output, " 3 [>---------------------------]", self.generate_output(" 2 [=========>------------------]"), self.generate_output(" 0 [============================]"), ) end def test_multiline_format : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 3, 0 bar.format = "%bar%\nfoobar" bar.start bar.advance bar.clear bar.finish self.assert_output( output, ">---------------------------\nfoobar", self.generate_output("=========>------------------\nfoobar"), "\e[1G\e[2K\e[1A", self.generate_output(""), self.generate_output("============================"), "\nfoobar", ) end def test_set_format_no_max : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.format = :normal bar.start self.assert_output( output, " 0 [>---------------------------]", ) end def test_set_format_with_max : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 10, 0 bar.format = :normal bar.start self.assert_output( output, " 0/10 [>---------------------------] 0%", ) end def test_unicode : Nil ACON::Helper::ProgressBar.set_format_definition( "test", "%current%/%max% [%bar%] %percent:3s%% %message% Fruitcake marzipan toffee. Cupcake gummi bears tart dessert ice cream chupa chups cupcake chocolate bar sesame snaps. Croissant halvah cookie jujubes powder macaroon. Fruitcake bear claw bonbon jelly beans oat cake pie muffin Fruitcake marzipan toffee." ) bar = ACON::Helper::ProgressBar.new output = self.output, 10, 0 bar.format = "test" bar.progress_character = "💧" bar.start output.io.to_s.should contain " 0/10 [💧] 0%" end @[TestWith( {"debug"}, {"very_verbose"}, {"verbose"}, {"normal"}, )] def test_formats_without_max(format : String) : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 bar.format = format bar.start output.io.to_s.should_not be_empty end def test_bar_width_with_multiline_format : Nil ENV["COLUMNS"] = "10" bar = ACON::Helper::ProgressBar.new self.output, minimum_seconds_between_redraws: 0 bar.format = "%bar%\n0123456789" # Before starting bar.bar_width = 5 bar.bar_width.should eq 5 # After starting bar.start bar.bar_width.should eq 5 ENV["COLUMNS"] = "120" end def test_min_and_max_seconds_between_redraws : Nil bar = ACON::Helper::ProgressBar.new output = self.output, clock: @clock bar.redraw_frequency = 1 bar.minimum_seconds_between_redraws = 5 bar.maximum_seconds_between_redraws = 10 bar.start bar.progress = 1 @clock.sleep 10.seconds bar.progress = 2 @clock.sleep 20.seconds bar.progress = 3 self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 2 [-->-------------------------]"), self.generate_output(" 3 [--->------------------------]"), ) end def test_max_seconds_between_redraws : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0, clock: @clock bar.redraw_frequency = 4 # Disable step based redraw bar.start bar.progress = 1 # No threshold hit, no redraw bar.maximum_seconds_between_redraws = 2 @clock.sleep 1.second bar.progress = 2 # Still no redraw because it takes 2 seconds for a redraw @clock.sleep 1.second bar.progress = 3 # 1 + 1 = 2 -> redraw bar.progress = 4 # step based redraw freq hit, redraw even without sleep bar.progress = 5 # No threshold hit, no redraw bar.maximum_seconds_between_redraws = 3 @clock.sleep 2.seconds bar.progress = 6 # No redraw even though 2 seconds passed. Throttling has priority bar.maximum_seconds_between_redraws = 2 bar.progress = 7 # Throttling relaxed, draw self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 3 [--->------------------------]"), self.generate_output(" 4 [---->-----------------------]"), self.generate_output(" 7 [------->--------------------]"), ) end def test_min_seconds_between_redraws : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0, clock: @clock bar.redraw_frequency = 1 bar.minimum_seconds_between_redraws = 1 bar.start bar.progress = 1 # Too fast, should not draw @clock.sleep 1.second bar.progress = 2 # 1 second passed, draw bar.minimum_seconds_between_redraws = 2 @clock.sleep 1.second bar.progress = 3 # 1 second passed, but the threshold was changed, should not draw @clock.sleep 1.second bar.progress = 4 # 1 + 1 seconds = 2 seconds passed, draw bar.progress = 5 # No threshold hit, should not draw self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 2 [-->-------------------------]"), self.generate_output(" 4 [---->-----------------------]"), ) end def test_no_write_when_message_is_same : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 2 bar.start bar.advance bar.display self.assert_output( output, " 0/2 [>---------------------------] 0%", self.generate_output(" 1/2 [==============>-------------] 50%"), ) end def test_iterate : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 result = [] of Int32 bar.iterate [1, 2] do |value| result << value end result.should eq [1, 2] self.assert_output( output, " 0/2 [>---------------------------] 0%", self.generate_output(" 1/2 [==============>-------------] 50%"), self.generate_output(" 2/2 [============================] 100%"), ) end def test_iterate_iterator : Nil bar = ACON::Helper::ProgressBar.new output = self.output, minimum_seconds_between_redraws: 0 result = [] of Int32 bar.iterate [1, 2].each do |value| result << value end result.should eq [1, 2] self.assert_output( output, " 0 [>---------------------------]", self.generate_output(" 1 [->--------------------------]"), self.generate_output(" 2 [-->-------------------------]"), self.generate_output(" 2 [============================]"), ) end def test_ansi_colors_and_emojis : Nil ENV["COLUMNS"] = "156" idx = 0 ACON::Helper::ProgressBar.set_placeholder_formatter "custom_memory" do mem = 100_000 * idx colors = idx.zero? ? "44;37" : "41;37" idx += 1 "\e[#{colors}m #{mem.humanize_bytes} \e[0m" end bar = ACON::Helper::ProgressBar.new output = self.output, 15, 0 bar.format = " \033[44;37m %title:-37s% \033[0m\n %current%/%max% %bar% %percent:3s%%\n 🏁 %remaining:-10s% %custom_memory:37s%" bar.bar_character = done = "\033[32m●\033[0m" bar.empty_bar_character = empty = "\033[31m●\033[0m" bar.progress_character = progress = "\033[32m➤ \033[0m" bar.set_message "Starting the demo... fingers crossed", "title" bar.start self.assert_output( output, " \033[44;37m Starting the demo... fingers crossed \033[0m\n", " 0/15 #{progress}#{empty * 26} 0%\n", " \xf0\x9f\x8f\x81 < 1 sec \033[44;37m 0B \033[0m", ) output.io.as(IO::Memory).clear bar.set_message "Looks good to me...", "title" bar.advance 4 self.assert_output( output, self.generate_output( " \e[44;37m Looks good to me... \e[0m\n", " 4/15 #{done * 7}#{progress}#{empty * 19} 26%\n", " \xf0\x9f\x8f\x81 < 1 sec \e[41;37m 98kiB \e[0m", ) ) output.io.as(IO::Memory).clear bar.set_message "Thanks, bye", "title" bar.finish self.assert_output( output, self.generate_output( " \e[44;37m Thanks, bye \e[0m\n", " 15/15 #{done * 28} 100%\n", " \xf0\x9f\x8f\x81 < 1 sec \e[41;37m 195kiB \e[0m", ) ) ENV["COLUMNS"] = "120" end def test_multiline_format_is_fully_cleared : Nil bar = ACON::Helper::ProgressBar.new output = self.output, 3 bar.format = "%current%/%max%\n%message%\nFoo" bar.set_message "1234567890" bar.start bar.display bar.set_message "ABC" bar.advance bar.display bar.set_message "A" bar.advance bar.display bar.finish self.assert_output( output, "0/3\n1234567890\nFoo", self.generate_output("1/3\nABC\nFoo"), self.generate_output("2/3\nA\nFoo"), self.generate_output("3/3\nA\nFoo"), ) end def test_multiline_format_is_fully_correct_with_manual_cleanup : Nil bar = ACON::Helper::ProgressBar.new output = self.output bar.set_message %(Processing "foobar"...) bar.format = "[%bar%]\n%message%" bar.start bar.clear output.puts "Foo!" bar.display bar.finish self.assert_output( output, "[>---------------------------]\n", "Processing \"foobar\"...", "\x1B[1G\x1B[2K\x1B[1A", self.generate_output(""), "Foo!#{EOL}", self.generate_output("[--->------------------------]"), "\nProcessing \"foobar\"...", self.generate_output("[----->----------------------]\nProcessing \"foobar\"..."), ) end def test_overwrite_with_section_output_and_eol : Nil sections = Array(ACON::Output::Section).new acon_output = self.output output = ACON::Output::Section.new acon_output.io, sections, verbosity: acon_output.verbosity, decorated: acon_output.decorated?, formatter: ACON::Formatter::Output.new bar = ACON::Helper::ProgressBar.new output, 50, 0 bar.format = "[%bar%] %percent:3s%%#{EOL}%message%#{EOL}" bar.set_message "" bar.start bar.display bar.set_message "Doing something..." bar.advance bar.set_message "Doing something foo..." bar.advance self.assert_output( output, "[>---------------------------] 0%#{EOL}#{EOL}", "\x1b[2A\x1b[0J[>---------------------------] 2%#{EOL}Doing something...#{EOL}", "\x1b[2A\x1b[0J[=>--------------------------] 4%#{EOL}Doing something foo...#{EOL}", ) end def test_overwrite_with_section_output_and_eol_with_empty_message : Nil sections = Array(ACON::Output::Section).new acon_output = self.output output = ACON::Output::Section.new acon_output.io, sections, verbosity: acon_output.verbosity, decorated: acon_output.decorated?, formatter: ACON::Formatter::Output.new bar = ACON::Helper::ProgressBar.new output, 50, 0 bar.format = "[%bar%] %percent:3s%%#{EOL}%message%" bar.set_message "Start" bar.start bar.display bar.set_message "" bar.advance bar.set_message "Doing something..." bar.advance self.assert_output( output, "[>---------------------------] 0%#{EOL}Start#{EOL}", "\x1b[2A\x1b[0J[>---------------------------] 2%#{EOL}", "\x1b[1A\x1b[0J[=>--------------------------] 4%#{EOL}Doing something...#{EOL}", ) end def test_overwrite_with_section_output_and_eol_with_empty_message_comment : Nil sections = Array(ACON::Output::Section).new acon_output = self.output output = ACON::Output::Section.new acon_output.io, sections, verbosity: acon_output.verbosity, decorated: acon_output.decorated?, formatter: ACON::Formatter::Output.new bar = ACON::Helper::ProgressBar.new output, 50, 0 bar.format = "[%bar%] %percent:3s%%#{EOL}%message%" bar.set_message "Start" bar.start bar.display bar.set_message "" bar.advance bar.set_message "Doing something..." bar.advance self.assert_output( output, "[>---------------------------] 0%#{EOL}\x1b[33mStart\x1b[39m#{EOL}", "\x1b[2A\x1b[0J[>---------------------------] 2%#{EOL}", "\x1b[1A\x1b[0J[=>--------------------------] 4%#{EOL}\x1b[33mDoing something...\x1b[39m#{EOL}", ) end private def generate_output(*expected : String) : String self.generate_output expected.join end private def generate_output(expected : String) : String count = expected.count '\n' sub_str = if count > 0 "\e[1G\e[2K\e[1A" * count else "" end "#{sub_str}\e[1G\e[2K#{expected}" end private def output(decorated : Bool = true, verbosity : ACON::Output::Verbosity = :normal) : ACON::Output::Interface ACON::Output::IO.new IO::Memory.new, decorated: decorated, verbosity: verbosity end private def assert_output(output : ACON::Output::Interface, start : String, *frames : String, line : Int32 = __LINE__, file : String = __FILE__) : Nil self.assert_output output, start, frames, line: line, file: file end private def assert_output(output : ACON::Output::Interface, start : String, frames : Enumerable(String) = [] of String, *, line : Int32 = __LINE__, file : String = __FILE__) : Nil expected = String.build do |io| io << start frames.join io end output.io.to_s.should eq(expected), line: line, file: file end end ================================================ FILE: src/components/console/spec/helper/progress_indicator_spec.cr ================================================ require "../spec_helper" struct ProgressIndicatorTest < ASPEC::TestCase @clock : ACLK::Spec::MockClock def initialize @clock = ACLK::Spec::MockClock.new end def test_set_placeholder_formatter : Nil ACON::Helper::ProgressIndicator.set_placeholder_formatter "custom-message" do # Return any arbitrary string "My Custom Message" end ACON::Helper::ProgressIndicator .placeholder_formatter("custom-message") .try(&.call(ACON::Helper::ProgressIndicator.new self.output(decorated: false))) .should eq "My Custom Message" end def test_default_indicator : Nil indicator = ACON::Helper::ProgressIndicator.new output = self.output, clock: @clock indicator.start "Starting..." @clock.sleep 101.milliseconds indicator.advance @clock.sleep 101.milliseconds indicator.advance @clock.sleep 101.milliseconds indicator.advance @clock.sleep 101.milliseconds indicator.advance @clock.sleep 101.milliseconds indicator.advance @clock.sleep 101.milliseconds indicator.message = "Advancing..." indicator.advance indicator.finish "Done..." indicator.start "Starting Again..." @clock.sleep 101.milliseconds indicator.advance indicator.finish "Done Again..." self.assert_output( output, self.generate_output(" - Starting..."), self.generate_output(" \\ Starting..."), self.generate_output(" | Starting..."), self.generate_output(" / Starting..."), self.generate_output(" - Starting..."), self.generate_output(" \\ Starting..."), self.generate_output(" \\ Advancing..."), self.generate_output(" | Advancing..."), self.generate_output(" ✔ Done..."), EOL, self.generate_output(" - Starting Again..."), self.generate_output(" \\ Starting Again..."), self.generate_output(" ✔ Done Again..."), EOL, ) end def test_non_decorated : Nil indicator = ACON::Helper::ProgressIndicator.new output = self.output(decorated: false) indicator.start "Starting..." indicator.advance indicator.advance indicator.message = "Midway..." indicator.advance indicator.advance indicator.finish "Done..." self.assert_output( output, " Starting...#{EOL}", " Midway...#{EOL}", " Done...#{EOL}#{EOL}", ) end def test_custom_indicator_values : Nil indicator = ACON::Helper::ProgressIndicator.new output = self.output, indicator_values: %w(a b c), clock: @clock indicator.start "Starting..." @clock.sleep 101.milliseconds indicator.advance @clock.sleep 101.milliseconds indicator.advance @clock.sleep 101.milliseconds indicator.advance self.assert_output( output, self.generate_output(" a Starting..."), self.generate_output(" b Starting..."), self.generate_output(" c Starting..."), self.generate_output(" a Starting..."), ) end def test_custom_finished_indicator_value : Nil indicator = ACON::Helper::ProgressIndicator.new output = self.output, finished_indicator: "✅", clock: @clock indicator.start "Starting..." @clock.sleep 101.milliseconds indicator.finish "Done" self.assert_output( output, self.generate_output(" - Starting..."), self.generate_output(" ✅ Done"), EOL ) end def test_custom_finished_indicator_value_finish : Nil indicator = ACON::Helper::ProgressIndicator.new output = self.output, clock: @clock indicator.start "Starting..." @clock.sleep 101.milliseconds indicator.finish "Done", "|==|" self.assert_output( output, self.generate_output(" - Starting..."), self.generate_output(" |==| Done"), EOL ) end def test_requires_at_least_two_indicator_characters : Nil expect_raises ACON::Exception::InvalidArgument, "Must have at least 2 indicator value characters." do ACON::Helper::ProgressIndicator.new self.output, indicator_values: %w(a) end end def test_cannot_start_already_started_indicator : Nil indicator = ACON::Helper::ProgressIndicator.new self.output indicator.start "Starting..." expect_raises ACON::Exception::Logic, "Progress indicator is already started." do indicator.start "Starting Again..." end end def test_cannot_advance_unstarted_indicator : Nil indicator = ACON::Helper::ProgressIndicator.new self.output expect_raises ACON::Exception::Logic, "Progress indicator has not yet been started." do indicator.advance end end def test_cannot_finish_unstarted_indicator : Nil indicator = ACON::Helper::ProgressIndicator.new self.output expect_raises ACON::Exception::Logic, "Progress indicator has not yet been started." do indicator.finish "Finishing..." end end @[TestWith( {ACON::Helper::ProgressIndicator::Format::DEBUG}, {ACON::Helper::ProgressIndicator::Format::VERY_VERBOSE}, {ACON::Helper::ProgressIndicator::Format::VERBOSE}, {ACON::Helper::ProgressIndicator::Format::NORMAL}, )] def test_formats(format : ACON::Helper::ProgressIndicator::Format) : Nil indicator = ACON::Helper::ProgressIndicator.new output = self.output, format: format indicator.start "Starting..." indicator.advance output.io.to_s.should_not be_empty end private def generate_output(expected : String) : String count = expected.count '\n' sub_str = if count > 0 "\033[#{count}A" else "" end "\x0D\x1B[2K#{sub_str}#{expected}" end private def output(decorated : Bool = true, verbosity : ACON::Output::Verbosity = :normal) : ACON::Output::Interface ACON::Output::IO.new IO::Memory.new, decorated: decorated, verbosity: verbosity end private def assert_output(output : ACON::Output::Interface, start : String, *frames : String, line : Int32 = __LINE__, file : String = __FILE__) : Nil self.assert_output output, start, frames, line: line, file: file end private def assert_output(output : ACON::Output::Interface, start : String, frames : Enumerable(String) = [] of String, *, line : Int32 = __LINE__, file : String = __FILE__) : Nil expected = String.build do |io| io << start frames.join io end output.io.to_s.should eq(expected), line: line, file: file end end ================================================ FILE: src/components/console/spec/helper/question_spec.cr ================================================ require "../spec_helper" require "./abstract_question_helper_test_case" struct QuestionHelperTest < AbstractQuestionHelperTest @helper : ACON::Helper::Question def initialize @helper = ACON::Helper::Question.new super end def tear_down : Nil ENV.delete "COLUMNS" end def test_ask_choice_question : Nil heroes = ["Superman", "Batman", "Spiderman"] self.with_input "\n1\n 1 \nGeorge\n1\nGeorge\n\n\n" do |input| question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes, 2 question.max_attempts = 1 # First answer is empty, so should use default @helper.ask(input, @output, question).should eq "Spiderman" question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes question.max_attempts = 1 @helper.ask(input, @output, question).should eq "Batman" @helper.ask(input, @output, question).should eq "Batman" question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes question.error_message = "Input '%s' is not a superhero!" question.max_attempts = 2 @helper.ask(input, @output, question).should eq "Batman" begin question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes, 1 question.max_attempts = 1 @helper.ask input, @output, question rescue ex : ACON::Exception::InvalidArgument ex.message.should eq "Value 'George' is invalid." end question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes, "0" question.max_attempts = 1 @helper.ask(input, @output, question).should eq "Superman" end end def test_ask_choice_question_non_interactive : Nil heroes = ["Superman", "Batman", "Spiderman"] self.with_input "\n1\n 1 \nGeorge\n1\nGeorge\n1\n", false do |input| question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes, 0 @helper.ask(input, @output, question).should eq "Superman" question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes, "Batman" @helper.ask(input, @output, question).should eq "Batman" question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes @helper.ask(input, @output, question).should be_nil question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes, 0 question.validator = nil @helper.ask(input, @output, question).should eq "Superman" begin question = ACON::Question::Choice.new "Who is your favorite superhero?", heroes @helper.ask input, @output, question rescue ex : ACON::Exception::InvalidArgument ex.message.should eq "Value '' is invalid." end end end def test_ask_multiple_choice : Nil heroes = ["Superman", "Batman", "Spiderman"] self.with_input "1\n0,2\n 0 , 2 \n\n\n" do |input| question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heroes question.max_attempts = 1 @helper.ask(input, @output, question).should eq ["Batman"] @helper.ask(input, @output, question).should eq ["Superman", "Spiderman"] @helper.ask(input, @output, question).should eq ["Superman", "Spiderman"] question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heroes, "0,1" question.max_attempts = 1 @helper.ask(input, @output, question).should eq ["Superman", "Batman"] question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heroes, " 0 , 1 " question.max_attempts = 1 @helper.ask(input, @output, question).should eq ["Superman", "Batman"] end end def test_ask_multiple_choice_non_interactive : Nil heroes = ["Superman", "Batman", "Spiderman"] self.with_input "1\n0,2\n 0 , 2 ", false do |input| question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heroes, "0,1" @helper.ask(input, @output, question).should eq ["Superman", "Batman"] question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heroes, " 0 , 1 " question.validator = nil @helper.ask(input, @output, question).should eq ["Superman", "Batman"] question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heroes, "0,Batman" @helper.ask(input, @output, question).should eq ["Superman", "Batman"] question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heroes @helper.ask(input, @output, question).should be_nil question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", {"a" => "Batman", "b" => "Superman"}, "a" @helper.ask(input, @output, question).should eq ["Batman"] begin question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heroes, "" @helper.ask input, @output, question rescue ex : ACON::Exception::InvalidArgument ex.message.should eq "Value '' is invalid." end end end def test_ask : Nil self.with_input "\n8AM\n" do |input| question = ACON::Question.new "What time is it?", "2PM" @helper.ask(input, @output, question).should eq "2PM" question = ACON::Question.new "What time is it?", "2PM" @helper.ask(input, @output, question).should eq "8AM" self.assert_output_contains "What time is it?" end end def test_ask_non_trimmed : Nil question = ACON::Question.new "What time is it?", "2PM" question.trimmable = false self.with_input " 8AM " do |input| @helper.ask(input, @output, question).should eq " 8AM " end self.assert_output_contains "What time is it?" end # TODO: Add autocompleter tests def test_ask_hidden : Nil question = ACON::Question.new "What time is it?", "2PM" question.hidden = true self.with_input "8AM\n" do |input| @helper.ask(input, @output, question).should eq "8AM" end self.assert_output_contains "What time is it?" end def test_ask_hidden_non_trimmed : Nil question = ACON::Question.new "What time is it?", "2PM" question.hidden = true question.trimmable = false self.with_input " 8AM" do |input| @helper.ask(input, @output, question).should eq " 8AM" end self.assert_output_contains "What time is it?" end def test_ask_multi_line : Nil essay = <<-ESSAY Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque pretium lectus quis suscipit porttitor. Sed pretium bibendum vestibulum. Etiam accumsan, justo vitae imperdiet aliquet, neque est sagittis mauris, sed interdum massa leo id leo. Aliquam rhoncus, libero ac blandit convallis, est sapien hendrerit nulla, vitae aliquet tellus orci a odio. Aliquam gravida ante sit amet massa lacinia, ut condimentum purus venenatis. Vivamus et erat dictum, euismod neque in, laoreet odio. Aenean vitae tellus at leo vestibulum auctor id eget urna. ESSAY question = ACON::Question(String?).new "Write an essay", nil question.multi_line = true self.with_input essay do |input| @helper.ask(input, @output, question).should eq essay end end def test_ask_multi_line_response_with_single_newline : Nil question = ACON::Question(String?).new "Write an essay", nil question.multi_line = true self.with_input "\n" do |input| @helper.ask(input, @output, question).should be_nil end end def test_ask_multi_line_response_with_data_after_newline : Nil question = ACON::Question(String?).new "Write an essay", nil question.multi_line = true self.with_input "\nSome Text" do |input| @helper.ask(input, @output, question).should be_nil end end def test_ask_multi_line_response_multiple_newlines_at_end : Nil question = ACON::Question(String?).new "Write an essay", nil question.multi_line = true self.with_input "Some Text\n\n" do |input| @helper.ask(input, @output, question).should eq "Some Text" end end @[DataProvider("confirmation_provider")] def test_ask_confirmation(answer : String, expected : Bool, default : Bool) : Nil question = ACON::Question::Confirmation.new "Some question", default self.with_input "#{answer}\n" do |input| @helper.ask(input, @output, question).should eq expected end end def confirmation_provider : Tuple { {"", true, true}, {"", false, false}, {"y", true, false}, {"yes", true, false}, {"n", false, true}, {"no", false, true}, } end def test_ask_confirmation_custom_true_answer : Nil question = ACON::Question::Confirmation.new "Some question", false, /^(j|y)/i self.with_input "j\ny\n" do |input| @helper.ask(input, @output, question).should be_true @helper.ask(input, @output, question).should be_true end end def test_ask_and_validate : Nil error = "This is not a color!" question = ACON::Question.new " What is your favorite color?", "white" question.max_attempts = 2 question.validator do |answer| raise ACON::Exception::Runtime.new error unless answer.in? "white", "black" answer end self.with_input "\nblack\n" do |input| @helper.ask(input, @output, question).should eq "white" @helper.ask(input, @output, question).should eq "black" end self.with_input "green\nyellow\norange\n" do |input| expect_raises ACON::Exception::Runtime, error do @helper.ask input, @output, question end end end @[DataProvider("simple_answer_provider")] def test_ask_choice_simple_answers(answer, expected : String) : Nil choices = [ "My environment 1", "My environment 2", "My environment 3", ] question = ACON::Question::Choice.new "Please select the environment to load", choices question.max_attempts = 1 self.with_input "#{answer}\n" do |input| @helper.ask(input, @output, question).should eq expected end end def simple_answer_provider : Tuple { {0, "My environment 1"}, {1, "My environment 2"}, {2, "My environment 3"}, {"My environment 1", "My environment 1"}, {"My environment 2", "My environment 2"}, {"My environment 3", "My environment 3"}, } end @[DataProvider("special_character_provider")] def test_ask_special_characters_multiple_choice(answer : String, expected : Array(String)) : Nil choices = [ ".", "src", ] question = ACON::Question::MultipleChoice.new "Please select the environment to load", choices question.max_attempts = 1 self.with_input "#{answer}\n" do |input| @helper.ask(input, @output, question).should eq expected end end def special_character_provider : Tuple { {".", ["."]}, {".,src", [".", "src"]}, } end @[DataProvider("answer_provider")] def test_ask_choice_hash_choices(answer : String, expected : String) : Nil choices = { "env_1" => "My environment 1", "env_2" => "My environment", "env_3" => "My environment", } question = ACON::Question::Choice.new "Please select the environment to load", choices question.max_attempts = 1 self.with_input "#{answer}\n" do |input| @helper.ask(input, @output, question).should eq expected end end def answer_provider : Tuple { {"env_1", "My environment 1"}, {"env_2", "My environment"}, {"env_3", "My environment"}, {"My environment 1", "My environment 1"}, } end def test_ask_ambiguous_choice : Nil choices = { "env_1" => "My first environment", "env_2" => "My environment", "env_3" => "My environment", } question = ACON::Question::Choice.new "Please select the environment to load", choices question.max_attempts = 1 self.with_input "My environment\n" do |input| expect_raises ACON::Exception::InvalidArgument, "The provided answer is ambiguous. Value should be one of 'env_2' or 'env_3'." do @helper.ask input, @output, question end end end def test_ask_non_interactive : Nil question = ACON::Question.new "Some question", "some answer" self.with_input "yes", false do |input| @helper.ask(input, @output, question).should eq "some answer" end end def test_ask_raises_on_missing_input : Nil question = ACON::Question.new "Some question", "some answer" self.with_input "" do |input| expect_raises ACON::Exception::MissingInput, "Aborted." do @helper.ask input, @output, question end end end # TODO: What to do if the input is ""? def test_question_validator_repeats_the_prompt : Nil tries = 0 app = ACON::Application.new "foo" app.auto_exit = false app.register "question" do |input, output| question = ACON::Question(String?).new "This is a promptable question", nil question.validator do |answer| tries += 1 raise "" unless answer.presence answer end ACON::Helper::Question.new.ask input, output, question ACON::Command::Status::SUCCESS end tester = ACON::Spec::ApplicationTester.new app tester.inputs = ["", "not-empty"] tester.run(command: "question", interactive: true).should eq ACON::Command::Status::SUCCESS tries.should eq 2 end end ================================================ FILE: src/components/console/spec/helper/table_spec.cr ================================================ require "../spec_helper" struct TableSpec < ASPEC::TestCase @output : IO protected def get_table_contents(table_name : String) : String File.read(File.join __DIR__, "..", "fixtures", "helper", "table", "#{table_name}.txt") # .gsub(EOL, "\n") end def initialize @output = IO::Memory.new end protected def tear_down : Nil @output.close end def test_rows_headers_overloads : Nil ACON::Helper::Table.new(output = self.io_output) .headers(["1", "2", 3]) .headers([4, 5, 6]) .headers(false, true, false) .add_row(["Foo", 123, 19.075]) .add_row("Bar", 456, false) .add_rows([ ["Baz"], ["Biz"], ]) .row(0, %w(a b c)) .render self.output_content(output).should eq self.normalize <<-TABLE +-------+------+-------+ | false | true | false | +-------+------+-------+ | a | b | c | | Bar | 456 | false | | Baz | | | | Biz | | | +-------+------+-------+ TABLE end @[DataProvider("render_provider")] def test_render(headers, rows, style : String, expected : String, decorated : Bool) : Nil table = ACON::Helper::Table.new output = self.io_output decorated table .headers(headers) .rows(rows) .style(style) .render self.output_content(output).should eq expected.gsub EOL, "\n" end def render_provider : Hash books = [ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens"], ["960-425-059-0", "The Lord of the Rings", "J. R. R. Tolkien"], ["80-902734-1-6", "And Then There Were None", "Agatha Christie"], ] { "Default style" => { [["ISBN", "Title", "Author"]], books, style = "default", self.get_table_contents(style), false, }, "Markdown style" => { [["ISBN", "Title", "Author"]], books, style = "markdown", self.get_table_contents(style), false, }, "Compact style" => { [["ISBN", "Title", "Author"]], books, style = "compact", self.get_table_contents(style), false, }, "Borderless style" => { [["ISBN", "Title", "Author"]], books, style = "borderless", self.get_table_contents(style), false, }, "Box style" => { [["ISBN", "Title", "Author"]], books, style = "box", self.get_table_contents(style), false, }, "Double box with separator" => { [["ISBN", "Title", "Author"]], [ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens"], ACON::Helper::Table::Separator.new, ["960-425-059-0", "The Lord of the Rings", "J. R. R. Tolkien"], ["80-902734-1-6", "And Then There Were None", "Agatha Christie"], ], "double-box", self.get_table_contents("double_box_separator"), false, }, "Default missing cell values" => { [["ISBN", "Title"]], [ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], ["9971-5-0210-0"], ["960-425-059-0", "The Lord of the Rings", "J. R. R. Tolkien"], ["80-902734-1-6", "And Then There Were None", "Agatha Christie"], ], "default", self.get_table_contents("default_missing_cell_values"), false, }, "Default no headers" => { [[] of String], [ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], ["9971-5-0210-0"], ["960-425-059-0", "The Lord of the Rings", "J. R. R. Tolkien"], ["80-902734-1-6", "And Then There Were None", "Agatha Christie"], ], "default", self.get_table_contents("default_headerless"), false, }, "Default multiline cells" => { [["ISBN", "Title", "Author"]], [ ["99921-58-10-7", "Divine\nComedy", "Dante Alighieri"], ["9971-5-0210-2", "Harry Potter\nand the Chamber of Secrets", "Rowling\nJoanne K."], ["9971-5-0210-2", "Harry Potter\nand the Chamber of Secrets", "Rowling\nJoanne K."], ["960-425-059-0", "The Lord of the Rings", "J. R. R.\nTolkien"], ], "default", self.get_table_contents("default_multiline_cells"), false, }, "Default no rows" => { [["ISBN", "Title"]], [] of String, "default", self.get_table_contents("default_no_rows"), false, }, "Default no rows or headers" => { [[] of String], [] of String, "default", "", false, }, "Default tags used for output formatting" => { [["ISBN", "Title", "Author"]], [ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens"], ], "default", self.get_table_contents("default_cells_with_formatting_tags"), false, }, "Default tags not used for output formatting" => { [["ISBN", "Title", "Author"]], [ ["99921-58-10-700", "Divine Com", "Dante Alighieri"], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens"], ], "default", self.get_table_contents("default_cells_with_non_formatting_tags"), false, }, "Default cells with colspan" => { [["ISBN", "Title", "Author"]], [ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], ACON::Helper::Table::Separator.new, [ ACON::Helper::Table::Cell.new("Divine Comedy(Dante Alighieri)", colspan: 3), ], ACON::Helper::Table::Separator.new, [ ACON::Helper::Table::Cell.new("Arduino: A Quick-Start Guide", colspan: 2), "Mark Schmidt", ], ACON::Helper::Table::Separator.new, [ "9971-5-0210-0", ACON::Helper::Table::Cell.new("A Tale of \nTwo Cities", colspan: 2), ], ACON::Helper::Table::Separator.new, [ ACON::Helper::Table::Cell.new("Cupiditate dicta atque porro, tempora exercitationem modi animi nulla nemo vel nihil!", colspan: 3), ], ], "default", self.get_table_contents("default_cells_with_colspan"), false, }, "Default cell after colspan contains line break" => { [["Foo", "Bar", "Baz"]], [ [ ACON::Helper::Table::Cell.new("foo\nbar", colspan: 2), "baz\nqux", ], ], "default", self.get_table_contents("default_line_break_after_colspan_cell"), false, }, "Default cell after colspan contains multiple line breaks" => { [["Foo", "Bar", "Baz"]], [ [ ACON::Helper::Table::Cell.new("foo\nbar", colspan: 2), "baz\nqux\nquux", ], ], "default", self.get_table_contents("default_line_breaks_after_colspan_cell"), false, }, "Default cell with rowspan" => { [["ISBN", "Title", "Author"]], [ [ ACON::Helper::Table::Cell.new("9971-5-0210-0", rowspan: 3), ACON::Helper::Table::Cell.new("Divine Comedy", rowspan: 2), "Dante Alighieri", ], [] of String, ["The Lord of \nthe Rings", "J. R. \nR. Tolkien"], ACON::Helper::Table::Separator.new, ["80-902734-1-6", ACON::Helper::Table::Cell.new("And Then \nThere \nWere None", rowspan: 3), "Agatha Christie"], ["80-902734-1-7", "Test"], ], "default", self.get_table_contents("default_cells_with_rowspan"), false, }, "Default cell with rowspan and rowspan" => { [["ISBN", "Title", "Author"]], [ [ ACON::Helper::Table::Cell.new("9971-5-0210-0", rowspan: 2, colspan: 2), "Dante Alighieri", ], ["Charles Dickens"], ACON::Helper::Table::Separator.new, [ "Dante Alighieri", ACON::Helper::Table::Cell.new("9971-5-0210-0", rowspan: 3, colspan: 2), ], ["J. R. R. Tolkien"], ["J. R. R"], ], "default", self.get_table_contents("default_cells_with_rowspan_and_colspan"), false, }, "Default cell with rowspan and colspan that contain new lines" => { [["ISBN", "Title", "Author"]], [ [ ACON::Helper::Table::Cell.new("9971\n-5-\n021\n0-0", rowspan: 2, colspan: 2), "Dante Alighieri", ], ["Charles Dickens"], ACON::Helper::Table::Separator.new, [ "Dante Alighieri", ACON::Helper::Table::Cell.new("9971\n-5-\n021\n0-0", rowspan: 2, colspan: 2), ], ["Charles Dickens"], ACON::Helper::Table::Separator.new, [ ACON::Helper::Table::Cell.new("9971\n-5-\n021\n0-0", rowspan: 2, colspan: 2), ACON::Helper::Table::Cell.new("Dante \nAlighieri", rowspan: 2, colspan: 1), ], ], "default", self.get_table_contents("default_cells_with_rowspan_and_colspan_and_line_breaks"), false, }, "Default cell with rowspan and colspan without table separators" => { [["ISBN", "Title", "Author"]], [ [ ACON::Helper::Table::Cell.new("9971\n-5-\n021\n0-0", rowspan: 2, colspan: 2), "Dante Alighieri", ], ["Charles Dickens"], [ "Dante Alighieri", ACON::Helper::Table::Cell.new("9971\n-5-\n021\n0-0", rowspan: 2, colspan: 2), ], ["Charles Dickens"], ], "default", self.get_table_contents("default_cells_with_rowspan_and_colspan_no_separators"), false, }, "Default cell with rowspan and colspan with separators inside a rowspan" => { [["ISBN", "Author"]], [ [ ACON::Helper::Table::Cell.new("9971-5-0210-0", rowspan: 3, colspan: 1), "Dante Alighieri", ], [ACON::Helper::Table::Separator.new], ["Charles Dickens"], ], "default", self.get_table_contents("default_cells_with_rowspan_and_colspan_separator_in_rowspan"), false, }, "Default cell with multiple header lines" => { [ [ACON::Helper::Table::Cell.new("Main title", colspan: 3)], ["ISBN", "Title", "Author"], ], [] of String, "default", self.get_table_contents("default_multiple_header_lines"), false, }, "Default row with multiple cells" => { [[] of String], [ [ ACON::Helper::Table::Cell.new("1", colspan: 3), ACON::Helper::Table::Cell.new("2", colspan: 2), ACON::Helper::Table::Cell.new("3", colspan: 2), ACON::Helper::Table::Cell.new("4", colspan: 2), ], ], "default", self.get_table_contents("default_row_with_multiple_cells"), false, }, "Default colspan and table cells with comment style" => { [ [ ACON::Helper::Table::Cell.new("Long Title", colspan: 3), ], ], [ [ ACON::Helper::Table::Cell.new("9971-5-0210-0", colspan: 3), ], ACON::Helper::Table::Separator.new, [ "Dante Alighieri", "J. R. R. Tolkien", "J. R. R", ], ], "default", self.get_table_contents("default_colspan_and_table_cell_with_comment_style"), true, }, "Default row with formatted cells containing a newline" => { [[] of String], [ [ ACON::Helper::Table::Cell.new("Dont break\nhere", colspan: 2), ], ACON::Helper::Table::Separator.new, [ "foo", ACON::Helper::Table::Cell.new("Dont break\nhere", rowspan: 2), ], [ "bar", ], ], "default", self.get_table_contents("default_formatted_row_with_line_breaks"), true, }, "Default cells with rowspan and colspan with alignment" => { [ ACON::Helper::Table::Cell.new("ISBN", style: ACON::Helper::Table::CellStyle.new(align: :right)), "Title", ACON::Helper::Table::Cell.new("Author", style: ACON::Helper::Table::CellStyle.new(align: :center)), ], [ [ ACON::Helper::Table::Cell.new("978", style: ACON::Helper::Table::CellStyle.new(align: :center)), "De Monarchia", ACON::Helper::Table::Cell.new( "Dante Alighieri \nspans multiple rows rows Dante Alighieri \nspans multiple rows rows", rowspan: 2, style: ACON::Helper::Table::CellStyle.new(align: :center) ), ], [ "99921-58-10-7", "Divine Comedy", ], ACON::Helper::Table::Separator.new, [ ACON::Helper::Table::Cell.new("test", colspan: 2, style: ACON::Helper::Table::CellStyle.new(align: :center)), ACON::Helper::Table::Cell.new("tttt", style: ACON::Helper::Table::CellStyle.new(align: :right)), ], ], "default", self.get_table_contents("default_cells_with_rowspan_and_colspan_and_alignment"), false, }, "Default cells with rowspan and colspan with fg,bg" => { [] of String, [ [ ACON::Helper::Table::Cell.new("978", style: ACON::Helper::Table::CellStyle.new(foreground: "black", background: "green")), "De Monarchia", ACON::Helper::Table::Cell.new("Dante Alighieri \nspans multiple rows rows Dante Alighieri \nspans multiple rows rows", rowspan: 2, style: ACON::Helper::Table::CellStyle.new(foreground: "red", background: "green", align: :center)), ], [ "99921-58-10-7", "Divine Comedy", ], ACON::Helper::Table::Separator.new, [ ACON::Helper::Table::Cell.new("test", colspan: 2, style: ACON::Helper::Table::CellStyle.new(foreground: "red", background: "green", align: :center)), ACON::Helper::Table::Cell.new("tttt", style: ACON::Helper::Table::CellStyle.new(foreground: "red", background: "green", align: :right)), ], ], "default", self.get_table_contents("default_cells_with_rowspan_and_colspan_and_fgbg"), true, }, "Default cells with rowspan and colspan > 1 with custom cell format" => { [ ACON::Helper::Table::Cell.new("ISBN", style: ACON::Helper::Table::CellStyle.new(format: "%s")), "Title", "Author", ], [ [ "978-0521567817", "De Monarchia", ACON::Helper::Table::Cell.new("Dante Alighieri\nspans multiple rows", rowspan: 2, style: ACON::Helper::Table::CellStyle.new(format: "%s")), ], ["978-0804169127", "Divine Comedy"], [ ACON::Helper::Table::Cell.new("test", colspan: 2, style: ACON::Helper::Table::CellStyle.new(format: "%s")), "tttt", ], ], "default", self.get_table_contents("default_cells_with_rowspan_and_colspan_and_custom_format"), true, }, } end # TODO: Enable when multi byte string widths are supported def ptest_render_multi_byte : Nil table = ACON::Helper::Table.new output = self.io_output table .headers(["🍝"]) .rows([[1234]]) .style("default") .render self.output_content(output).should eq self.normalize <<-TABLE +------+ | 🍝 | +------+ | 1234 | +------+ TABLE end def test_render_table_cell_numeric_int_value : Nil table = ACON::Helper::Table.new output = self.io_output table .rows([[ACON::Helper::Table::Cell.new(1234)]]) .render self.output_content(output).should eq self.normalize <<-TABLE +------+ | 1234 | +------+ TABLE end def test_render_table_cell_numeric_float_value : Nil table = ACON::Helper::Table.new output = self.io_output table .rows([[ACON::Helper::Table::Cell.new(3.14)]]) .render self.output_content(output).should eq self.normalize <<-TABLE +------+ | 3.14 | +------+ TABLE end def test_render_custom_style : Nil style = ACON::Helper::Table::Style.new style .horizontal_border_chars('.') .vertical_border_chars('.') .default_crossing_char('.') ACON::Helper::Table.set_style_definition "dotfull", style table = ACON::Helper::Table.new output = self.io_output table .headers(["Foo"]) .rows([["Bar"]]) .style("dotfull") .render self.output_content(output).should eq self.normalize <<-TABLE ....... . Foo . ....... . Bar . ....... TABLE end def test_render_multiple_times : Nil table = ACON::Helper::Table.new output = self.io_output table .rows([[ACON::Helper::Table::Cell.new("foo", colspan: 2)]]) .render table.render table.render self.output_content(output).should eq self.normalize <<-TABLE +----+---+ | foo | +----+---+ +----+---+ | foo | +----+---+ +----+---+ | foo | +----+---+ TABLE end def test_column_style : Nil table = ACON::Helper::Table.new output = self.io_output table .headers(["ISBN", "Title", "Author", "Price"]) .rows([ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri", "9.95"], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens", "139.25"], ]) style = ACON::Helper::Table::Style.new .align(:right) table.column_style 3, style table.column_style(3).should eq style table.render self.output_content(output).should eq self.normalize <<-TABLE +---------------+----------------------+-----------------+--------+ | ISBN | Title | Author | Price | +---------------+----------------------+-----------------+--------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | 9.95 | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | 139.25 | +---------------+----------------------+-----------------+--------+ TABLE end def test_column_width : Nil table = ACON::Helper::Table.new output = self.io_output table .headers(["ISBN", "Title", "Author", "Price"]) .rows([ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri", "9.95"], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens", "139.25"], ]) .column_width(0, 15) .column_width(3, 10) style = ACON::Helper::Table::Style.new .align(:right) table.column_style 3, style table.render self.output_content(output).should eq self.normalize <<-TABLE +-----------------+----------------------+-----------------+------------+ | ISBN | Title | Author | Price | +-----------------+----------------------+-----------------+------------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | 9.95 | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | 139.25 | +-----------------+----------------------+-----------------+------------+ TABLE end def test_column_widths : Nil table = ACON::Helper::Table.new output = self.io_output table .headers(["ISBN", "Title", "Author", "Price"]) .rows([ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri", "9.95"], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens", "139.25"], ]) .column_widths(15, 0, -1, 10) style = ACON::Helper::Table::Style.new .align(:right) table.column_style 3, style table.render self.output_content(output).should eq self.normalize <<-TABLE +-----------------+----------------------+-----------------+------------+ | ISBN | Title | Author | Price | +-----------------+----------------------+-----------------+------------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | 9.95 | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | 139.25 | +-----------------+----------------------+-----------------+------------+ TABLE end def test_column_widths_enumerable : Nil table = ACON::Helper::Table.new output = self.io_output table .headers(["ISBN", "Title", "Author", "Price"]) .rows([ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri", "9.95"], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens", "139.25"], ]) .column_widths({15, 0, -1, 10}) style = ACON::Helper::Table::Style.new .align(:right) table.column_style 3, style table.render self.output_content(output).should eq self.normalize <<-TABLE +-----------------+----------------------+-----------------+------------+ | ISBN | Title | Author | Price | +-----------------+----------------------+-----------------+------------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | 9.95 | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | 139.25 | +-----------------+----------------------+-----------------+------------+ TABLE end def test_column_max_width : Nil table = ACON::Helper::Table.new output = self.io_output table .rows([ ["Divine Comedy", "A Tale of Two Cities", "The Lord of the Rings", "And Then There Were None"], ]) .column_max_width(1, 5) .column_max_width(2, 10) .column_max_width(3, 15) .render self.output_content(output).should eq self.normalize <<-TABLE +---------------+-------+------------+-----------------+ | Divine Comedy | A Tal | The Lord o | And Then There | | | e of | f the Ring | Were None | | | Two C | s | | | | ities | | | +---------------+-------+------------+-----------------+ TABLE end def test_column_max_width_with_headers : Nil table = ACON::Helper::Table.new output = self.io_output table .headers([ [ "Publication", "Very long header with a lot of information", ], ]) .rows([ [ "1954", "The Lord of the Rings, by J.R.R. Tolkien", ], ]) .column_max_width(1, 30) .render self.output_content(output).should eq self.normalize <<-TABLE +-------------+--------------------------------+ | Publication | Very long header with a lot of | | | information | +-------------+--------------------------------+ | 1954 | The Lord of the Rings, by J.R. | | | R. Tolkien | +-------------+--------------------------------+ TABLE end def test_column_max_width_trailing_backslash : Nil table = ACON::Helper::Table.new output = self.io_output table .rows([ ["1234\\6"], ]) .column_max_width(0, 5) .render self.output_content(output).should eq self.normalize <<-'TABLE' +-------+ | 1234\ | | 6 | +-------+ TABLE end def test_render_max_width_colspan : Nil ACON::Helper::Table.new(output = self.io_output) .rows([ [ACON::Helper::Table::Cell.new("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor", colspan: 3)], ACON::Helper::Table::Separator.new, [ACON::Helper::Table::Cell.new("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor", colspan: 3)], ACON::Helper::Table::Separator.new, [ACON::Helper::Table::Cell.new("Lorem ipsum dolor sit amet, consectetur ", colspan: 2), "hello world"], ACON::Helper::Table::Separator.new, ["hello world", ACON::Helper::Table::Cell.new("Lorem ipsum dolor sit amet, consectetur adipiscing elit", colspan: 2)], ACON::Helper::Table::Separator.new, ["hello ", ACON::Helper::Table::Cell.new("world", colspan: 1), "Lorem ipsum dolor sit amet, consectetur"], ACON::Helper::Table::Separator.new, ["Athena ", ACON::Helper::Table::Cell.new("Test", colspan: 1), "Lorem ipsum dolor sit amet, consectetur"], ]) .column_max_width(0, 15) .column_max_width(1, 15) .column_max_width(2, 15) .render self.output_content(output).should eq self.normalize <<-'TABLE' +-----------------+-----------------+-----------------+ | Lorem ipsum dolor sit amet, consectetur adipi | | scing elit, sed do eiusmod tempor | +-----------------+-----------------+-----------------+ | Lorem ipsum dolor sit amet, consectetur adipi | | scing elit, sed do eiusmod tempor | +-----------------+-----------------+-----------------+ | Lorem ipsum dolor sit amet, co | hello world | | nsectetur | | +-----------------+-----------------+-----------------+ | hello world | Lorem ipsum dolor sit amet, co | | | nsectetur adipiscing elit | +-----------------+-----------------+-----------------+ | hello | world | Lorem ipsum dol | | | | or sit amet, co | | | | nsectetur | +-----------------+-----------------+-----------------+ | Athena | Test | Lorem ipsum dol | | | | or sit amet, co | | | | nsectetur | +-----------------+-----------------+-----------------+ TABLE end def test_hyperlink_and_max_width : Nil table = ACON::Helper::Table.new output = self.io_output true table .rows([ ["Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor"], ]) .column_max_width(0, 17) .render self.output_content(output).should eq <<-TABLE +-------------------+ | \e]8;;Lorem\e\\Lorem ipsum dolor\e]8;;\e\\ | | \e]8;;Lorem\e\\sit amet, consect\e]8;;\e\\ | | \e]8;;Lorem\e\\etur adipiscing e\e]8;;\e\\ | | \e]8;;Lorem\e\\lit, sed do eiusm\e]8;;\e\\ | | \e]8;;Lorem\e\\od tempor\e]8;;\e\\ | +-------------------+ TABLE end def test_append_row : Nil sections = [] of ACON::Output::Section output = self.io_output true table = ACON::Helper::Table.new ACON::Output::Section.new output.io, sections, output.verbosity, output.decorated?, ACON::Formatter::Output.new table .headers(["ISBN", "Title", "Author", "Price"]) .rows([ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri", "9.95"], ]) .render table.append_row ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens", "139.25"] table.append_row "9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens", "139.25" self.output_content(output).should eq self.normalize <<-TABLE +---------------+---------------+-----------------+-------+ | ISBN | Title | Author | Price | +---------------+---------------+-----------------+-------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | 9.95 | +---------------+---------------+-----------------+-------+ +---------------+----------------------+-----------------+--------+ | ISBN | Title | Author | Price | +---------------+----------------------+-----------------+--------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | 9.95 | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | 139.25 | +---------------+----------------------+-----------------+--------+ +---------------+----------------------+-----------------+--------+ | ISBN | Title | Author | Price | +---------------+----------------------+-----------------+--------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | 9.95 | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | 139.25 | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | 139.25 | +---------------+----------------------+-----------------+--------+ TABLE end def test_append_row_doesnt_clear_if_not_rendered : Nil sections = [] of ACON::Output::Section output = self.io_output true table = ACON::Helper::Table.new ACON::Output::Section.new output.io, sections, output.verbosity, output.decorated?, ACON::Formatter::Output.new table .headers(["ISBN", "Title", "Author", "Price"]) .rows([ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri", "9.95"], ]) table.append_row "9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens", "139.25" self.output_content(output).should eq self.normalize <<-TABLE +---------------+----------------------+-----------------+--------+ | ISBN | Title | Author | Price | +---------------+----------------------+-----------------+--------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | 9.95 | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | 139.25 | +---------------+----------------------+-----------------+--------+ TABLE end def test_append_row_without_decoration : Nil sections = [] of ACON::Output::Section output = self.io_output table = ACON::Helper::Table.new ACON::Output::Section.new output.io, sections, output.verbosity, output.decorated?, ACON::Formatter::Output.new table .headers(["ISBN", "Title", "Author", "Price"]) .rows([ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri", "9.95"], ]) .render table.append_row "9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens", "139.25" self.output_content(output).should eq self.normalize <<-TABLE +---------------+---------------+-----------------+-------+ | ISBN | Title | Author | Price | +---------------+---------------+-----------------+-------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | 9.95 | +---------------+---------------+-----------------+-------+ +---------------+----------------------+-----------------+--------+ | ISBN | Title | Author | Price | +---------------+----------------------+-----------------+--------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | 9.95 | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | 139.25 | +---------------+----------------------+-----------------+--------+ TABLE end def test_append_row_first_row : Nil sections = [] of ACON::Output::Section output = self.io_output true table = ACON::Helper::Table.new ACON::Output::Section.new output.io, sections, output.verbosity, output.decorated?, ACON::Formatter::Output.new table .headers(["ISBN", "Title", "Author", "Price"]) .render table.append_row "9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens", "139.25" self.output_content(output).should eq self.normalize <<-TABLE +------+-------+--------+-------+ | ISBN | Title | Author | Price | +------+-------+--------+-------+ +---------------+----------------------+-----------------+--------+ | ISBN | Title | Author | Price | +---------------+----------------------+-----------------+--------+ | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | 139.25 | +---------------+----------------------+-----------------+--------+ TABLE end def test_append_row_no_section_output : Nil table = ACON::Helper::Table.new self.io_output expect_raises ACON::Exception::Logic, "Appending a row is only supported when using a Athena::Console::Output::Section output, got Athena::Console::Output::IO." do table.append_row "9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens", "139.25" end end def test_missing_table_definition : Nil table = ACON::Helper::Table.new self.io_output expect_raises ACON::Exception::InvalidArgument, "The table style 'absent' is not defined." do table.style "absent" end end def test_style_definition_missing : Nil expect_raises ACON::Exception::InvalidArgument, "The table style 'absent' is not defined." do ACON::Helper::Table.style_definition "absent" end end @[DataProvider("title_provider")] def test_render_titles(header_title : String, footer_title : String, style : String, expected : String) : Nil ACON::Helper::Table.new(output = self.io_output) .header_title(header_title) .footer_title(footer_title) .headers(["ISBN", "Title", "Author"]) .rows([ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens"], ["960-425-059-0", "The Lord of the Rings", "J. R. R. Tolkien"], ["80-902734-1-6", "And Then There Were None", "Agatha Christie"], ]) .style(style) .render self.output_content(output).should eq expected.gsub EOL, "\n" end def title_provider : Tuple { { "Books", "Page 1/2", "default", <<-'TABLE' +---------------+----------- Books --------+------------------+ | ISBN | Title | Author | +---------------+--------------------------+------------------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | | 80-902734-1-6 | And Then There Were None | Agatha Christie | +---------------+--------- Page 1/2 -------+------------------+ TABLE }, { "Multiline\nheader\nhere", "footer", "default", <<-'TABLE' +---------------+--- Multiline header here +------------------+ | ISBN | Title | Author | +---------------+--------------------------+------------------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | | 80-902734-1-6 | And Then There Were None | Agatha Christie | +---------------+---------- footer --------+------------------+ TABLE }, { "Books", "Page 1/2", "box", <<-'TABLE' ┌───────────────┬─────────── Books ────────┬──────────────────┐ │ ISBN │ Title │ Author │ ├───────────────┼──────────────────────────┼──────────────────┤ │ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri │ │ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens │ │ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien │ │ 80-902734-1-6 │ And Then There Were None │ Agatha Christie │ └───────────────┴───────── Page 1/2 ───────┴──────────────────┘ TABLE }, { "Boooooooooooooooooooooooooooooooooooooooooooooooooooooooks", "Page 1/999999999999999999999999999999999999999999999999999", "default", <<-'TABLE' +- Booooooooooooooooooooooooooooooooooooooooooooooooooooo... -+ | ISBN | Title | Author | +---------------+--------------------------+------------------+ | 99921-58-10-7 | Divine Comedy | Dante Alighieri | | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | | 80-902734-1-6 | And Then There Were None | Agatha Christie | +- Page 1/99999999999999999999999999999999999999999999999... -+ TABLE }, } end def test_render_titles_no_headers : Nil ACON::Helper::Table.new(output = self.io_output) .header_title("Reproducer") .rows([ ["Value", "123-456"], ["Some other value", "789-0"], ]) .render self.output_content(output).should eq self.normalize <<-TABLE +-------- Reproducer --------+ | Value | 123-456 | | Some other value | 789-0 | +------------------+---------+ TABLE end def test_box_style_with_colspan : Nil boxed = ACON::Helper::Table::Style.new .horizontal_border_chars('─') .vertical_border_chars('│') .crossing_chars('┼', '┌', '┬', '┐', '┤', '┘', '┴', '└', '├') ACON::Helper::Table.new(output = self.io_output) .style(boxed) .headers("ISBN", "Title", "Author") .rows([ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], ACON::Helper::Table::Separator.new, [ACON::Helper::Table::Cell.new("This value spans 3 columns.", colspan: 3)], ]) .render self.output_content(output).should eq self.normalize <<-TABLE ┌───────────────┬───────────────┬─────────────────┐ │ ISBN │ Title │ Author │ ├───────────────┼───────────────┼─────────────────┤ │ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri │ ├───────────────┼───────────────┼─────────────────┤ │ This value spans 3 columns. │ └───────────────┴───────────────┴─────────────────┘ TABLE end @[DataProvider("horizontal_provider")] def test_render_horizontal(headers, rows, expected) ACON::Helper::Table.new(output = self.io_output) .headers(headers) .rows(rows) .horizontal .render self.output_content(output).should eq expected.gsub EOL, "\n" end def horizontal_provider : Tuple { { %w(foo bar baz), [ %w(one two tree), %w(1 2 3), ], <<-'TABLE' +-----+------+---+ | foo | one | 1 | | bar | two | 2 | | baz | tree | 3 | +-----+------+---+ TABLE }, { %w(foo bar baz), [ %w(one two), %w(1), ], <<-'TABLE' +-----+-----+---+ | foo | one | 1 | | bar | two | | | baz | | | +-----+-----+---+ TABLE }, { %w(foo bar baz), [ %w(one two tree), ACON::Helper::Table::Separator.new, %w(1 2 3), ], <<-'TABLE' +-----+------+---+ | foo | one | 1 | | bar | two | 2 | | baz | tree | 3 | +-----+------+---+ TABLE }, } end @[DataProvider("vertical_provider")] def test_render_vertical(headers, rows, expected, style : String, header_title, footer_title) ACON::Helper::Table.new(output = self.io_output) .headers(headers) .rows(rows) .style(style) .header_title(header_title) .footer_title(footer_title) .vertical .render self.output_content(output).should eq expected.gsub EOL, "\n" end def vertical_provider : Hash books = [ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri", "9.95"], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens", "139.25"], ] { "With header for all" => { %w(ISBN Title Author Price), books, <<-'TABLE', +------------------------------+ | ISBN: 99921-58-10-7 | | Title: Divine Comedy | | Author: Dante Alighieri | | Price: 9.95 | |------------------------------| | ISBN: 9971-5-0210-0 | | Title: A Tale of Two Cities | | Author: Charles Dickens | | Price: 139.25 | +------------------------------+ TABLE "default", nil, nil, }, "With header for none" => { %w(), books, <<-'TABLE', +----------------------+ | 99921-58-10-7 | | Divine Comedy | | Dante Alighieri | | 9.95 | |----------------------| | 9971-5-0210-0 | | A Tale of Two Cities | | Charles Dickens | | 139.25 | +----------------------+ TABLE "default", nil, nil, }, "With header for some" => { %w(ISBN Title Author), books, <<-'TABLE', +------------------------------+ | ISBN: 99921-58-10-7 | | Title: Divine Comedy | | Author: Dante Alighieri | | : 9.95 | |------------------------------| | ISBN: 9971-5-0210-0 | | Title: A Tale of Two Cities | | Author: Charles Dickens | | : 139.25 | +------------------------------+ TABLE "default", nil, nil, }, "With row for some headers" => { %w(foo bar baz), [ %w(one two), %w(1), ], <<-'TABLE', +----------+ | foo: one | | bar: two | | baz: | |----------| | foo: 1 | | bar: | | baz: | +----------+ TABLE "default", nil, nil, }, "With table separator" => { %w(foo bar baz), [ %w(one two tree), ACON::Helper::Table::Separator.new, %w(1 2 3), ], <<-'TABLE', +-----------+ | foo: one | | bar: two | | baz: tree | |-----------| | foo: 1 | | bar: 2 | | baz: 3 | +-----------+ TABLE "default", nil, nil, }, "With line breaks" => { %w(ISBN Title Author Price), [ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri", "9.95"], ["9971-5-0210-0", "A Tale\nof Two Cities", "Charles Dickens", "139.25"], ], <<-'TABLE', +-------------------------+ | ISBN: 99921-58-10-7 | | Title: Divine Comedy | | Author: Dante Alighieri | | Price: 9.95 | |-------------------------| | ISBN: 9971-5-0210-0 | | Title: A Tale | | of Two Cities | | Author: Charles Dickens | | Price: 139.25 | +-------------------------+ TABLE "default", nil, nil, }, "With formatting tags" => { %w(ISBN Title Author), [ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens"], ], <<-'TABLE', +------------------------------+ | ISBN: 99921-58-10-7 | | Title: Divine Comedy | | Author: Dante Alighieri | |------------------------------| | ISBN: 9971-5-0210-0 | | Title: A Tale of Two Cities | | Author: Charles Dickens | +------------------------------+ TABLE "default", nil, nil, }, "With colspan" => { %w(ISBN Title Author), [ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], [ACON::Helper::Table::Cell.new("Cupiditate dicta atque porro, tempora exercitationem modi animi nulla nemo vel nihil!", colspan: 3)], ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens"], ], <<-'TABLE', +---------------------------------------------------------------------------------------+ | ISBN: 99921-58-10-7 | | Title: Divine Comedy | | Author: Dante Alighieri | |---------------------------------------------------------------------------------------| | Cupiditate dicta atque porro, tempora exercitationem modi animi nulla nemo vel nihil! | |---------------------------------------------------------------------------------------| | ISBN: 9971-5-0210-0 | | Title: A Tale of Two Cities | | Author: Charles Dickens | +---------------------------------------------------------------------------------------+ TABLE "default", nil, nil, }, "With colspans but no header" => { %w(), [ [ACON::Helper::Table::Cell.new("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor", colspan: 3)], ACON::Helper::Table::Separator.new, [ACON::Helper::Table::Cell.new("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor", colspan: 3)], ACON::Helper::Table::Separator.new, [ACON::Helper::Table::Cell.new("Lorem ipsum dolor sit amet, consectetur ", colspan: 2), "hello world"], ACON::Helper::Table::Separator.new, ["hello world", ACON::Helper::Table::Cell.new("Lorem ipsum dolor sit amet, consectetur adipiscing elit", colspan: 2)], ACON::Helper::Table::Separator.new, ["hello ", ACON::Helper::Table::Cell.new("world", colspan: 1), "Lorem ipsum dolor sit amet, consectetur"], ACON::Helper::Table::Separator.new, ["Symfony ", ACON::Helper::Table::Cell.new("Test", colspan: 1), "Lorem ipsum dolor sit amet, consectetur"], ], <<-'TABLE', +--------------------------------------------------------------------------------+ | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor | |--------------------------------------------------------------------------------| | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor | |--------------------------------------------------------------------------------| | Lorem ipsum dolor sit amet, consectetur | | hello world | |--------------------------------------------------------------------------------| | hello world | | Lorem ipsum dolor sit amet, consectetur adipiscing elit | |--------------------------------------------------------------------------------| | hello | | world | | Lorem ipsum dolor sit amet, consectetur | |--------------------------------------------------------------------------------| | Symfony | | Test | | Lorem ipsum dolor sit amet, consectetur | +--------------------------------------------------------------------------------+ TABLE "default", nil, nil, }, "Borderless style" => { %w(ISBN Title Author Price), books, self.get_table_contents("borderless_vertical"), "borderless", nil, nil, }, "Compact style" => { %w(ISBN Title Author Price), books, self.get_table_contents("compact_vertical"), "compact", nil, nil, }, "Suggested style" => { %w(ISBN Title Author Price), books, self.get_table_contents("suggested_vertical"), "suggested", nil, nil, }, "Box style" => { %w(ISBN Title Author Price), books, <<-'TABLE', ┌──────────────────────────────┐ │ ISBN: 99921-58-10-7 │ │ Title: Divine Comedy │ │ Author: Dante Alighieri │ │ Price: 9.95 │ │──────────────────────────────│ │ ISBN: 9971-5-0210-0 │ │ Title: A Tale of Two Cities │ │ Author: Charles Dickens │ │ Price: 139.25 │ └──────────────────────────────┘ TABLE "box", nil, nil, }, "Double box style" => { %w(ISBN Title Author Price), books, <<-'TABLE', ╔══════════════════════════════╗ ║ ISBN: 99921-58-10-7 ║ ║ Title: Divine Comedy ║ ║ Author: Dante Alighieri ║ ║ Price: 9.95 ║ ║──────────────────────────────║ ║ ISBN: 9971-5-0210-0 ║ ║ Title: A Tale of Two Cities ║ ║ Author: Charles Dickens ║ ║ Price: 139.25 ║ ╚══════════════════════════════╝ TABLE "double-box", nil, nil, }, "With titles" => { %w(ISBN Title Author Price), books, <<-'TABLE', +----------- Books ------------+ | ISBN: 99921-58-10-7 | | Title: Divine Comedy | | Author: Dante Alighieri | | Price: 9.95 | |------------------------------| | ISBN: 9971-5-0210-0 | | Title: A Tale of Two Cities | | Author: Charles Dickens | | Price: 139.25 | +---------- Page 1/2 ----------+ TABLE "default", "Books", "Page 1/2", }, } end private def output_content(output : ACON::Output::IO) : String self.normalize output.to_s end private def io_output(decorated : Bool = false) : ACON::Output::IO ACON::Output::IO.new @output, decorated: decorated end private def normalize(input : String) : String input.gsub EOL, "\n" end end ================================================ FILE: src/components/console/spec/helper/table_style_spec.cr ================================================ require "../spec_helper" struct TableStyleSpec < ASPEC::TestCase def test_getter_setters : Nil style = ACON::Helper::Table::Style .new .align(:right) .border_format("BF") .padding_char('c') .header_title_format("HTF") .footer_title_format("FTF") .cell_header_format("CHF") .cell_row_format("CRF") .cell_row_content_format("CRCF") .horizontal_border_chars('o', 'i') .vertical_border_chars('v', 'u') .default_crossing_char('x') style.align.should eq ACON::Helper::Table::Alignment::RIGHT style.border_format.should eq "BF" style.padding_char.should eq 'c' style.header_title_format.should eq "HTF" style.footer_title_format.should eq "FTF" style.cell_header_format.should eq "CHF" style.cell_row_format.should eq "CRF" style.cell_row_content_format.should eq "CRCF" style.border_chars.should eq({"o", "v", "i", "u"}) style.crossing_chars.should eq({"x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x"}) style.crossing_chars("c", "tl", "tm", "tr", "mr", "br", "bm", "bl", "ml", "tlb", "tmb", "trb") style.crossing_chars.should eq({"c", "tl", "tm", "tr", "mr", "br", "bm", "bl", "ml", "tlb", "tmb", "trb"}) end end ================================================ FILE: src/components/console/spec/input/argument_spec.cr ================================================ require "../spec_helper" describe ACON::Input::Argument do describe ".new" do it "disallows blank names" do expect_raises ACON::Exception::InvalidArgument, "An argument name cannot be blank." do ACON::Input::Argument.new "" end expect_raises ACON::Exception::InvalidArgument, "An argument name cannot be blank." do ACON::Input::Argument.new " " end end end describe "#default=" do describe "when the argument is required" do it "raises if not nil" do argument = ACON::Input::Argument.new "foo", :required expect_raises ACON::Exception::Logic, "Cannot set a default value when the argument is required." do argument.default = "bar" end end it "allows nil" do ACON::Input::Argument.new("foo", :required).default = nil end end describe "array" do it "nil value" do argument = ACON::Input::Argument.new "foo", ACON::Input::Argument::Mode[:optional, :is_array] argument.default = nil argument.default.should eq [] of String end it "non array" do argument = ACON::Input::Argument.new "foo", ACON::Input::Argument::Mode[:optional, :is_array] expect_raises ACON::Exception::Logic, "Default value for an array argument must be an array." do argument.default = "bar" end end end end describe "#complete" do it "with an array" do values = ["foo", "bar"] suggestions = ACON::Completion::Suggestions.new argument = ACON::Input::Argument.new "foo", suggested_values: values argument.has_completion?.should be_true argument.complete ACON::Completion::Input.new, suggestions suggestions.suggested_values.map(&.value).should eq ["foo", "bar"] end it "with an block" do values = ["foo", "bar"] suggestions = ACON::Completion::Suggestions.new callback = Proc(ACON::Completion::Input, Array(String)).new { values } argument = ACON::Input::Argument.new "foo", suggested_values: callback argument.has_completion?.should be_true argument.complete ACON::Completion::Input.new, suggestions suggestions.suggested_values.map(&.value).should eq ["foo", "bar"] end end end ================================================ FILE: src/components/console/spec/input/argv_spec.cr ================================================ require "../spec_helper" struct ARGVTest < ASPEC::TestCase def test_parse : Nil input = ACON::Input::ARGV.new ["foo"] input.bind ACON::Input::Definition.new ACON::Input::Argument.new "name" input.arguments.should eq({"name" => "foo"}) input.bind ACON::Input::Definition.new ACON::Input::Argument.new "name" input.arguments.should eq({"name" => "foo"}) end def test_array_argument : Nil input = ACON::Input::ARGV.new ["foo", "bar", "baz", "bat"] input.bind ACON::Input::Definition.new ACON::Input::Argument.new "name", :is_array input.arguments.should eq({"name" => ["foo", "bar", "baz", "bat"]}) end def test_array_option : Nil input = ACON::Input::ARGV.new ["--name=foo", "--name=bar", "--name=baz"] input.bind ACON::Input::Definition.new ACON::Input::Option.new "name", value_mode: ACON::Input::Option::Value[:optional, :is_array] input.options.should eq({"name" => ["foo", "bar", "baz"]}) input = ACON::Input::ARGV.new ["--name", "foo", "--name", "bar", "--name", "baz"] input.bind ACON::Input::Definition.new ACON::Input::Option.new "name", value_mode: ACON::Input::Option::Value[:optional, :is_array] input.options.should eq({"name" => ["foo", "bar", "baz"]}) input = ACON::Input::ARGV.new ["--name=foo", "--name=bar", "--name="] input.bind ACON::Input::Definition.new ACON::Input::Option.new "name", value_mode: ACON::Input::Option::Value[:optional, :is_array] input.options.should eq({"name" => ["foo", "bar", ""]}) input = ACON::Input::ARGV.new ["--name=foo", "--name=bar", "--name", "--anotherOption"] input.bind ACON::Input::Definition.new( ACON::Input::Option.new("name", value_mode: ACON::Input::Option::Value[:optional, :is_array]), ACON::Input::Option.new("anotherOption", value_mode: :none), ) input.options.should eq({"name" => ["foo", "bar", nil], "anotherOption" => true}) end def test_parse_negative_number_after_double_dash : Nil input = ACON::Input::ARGV.new ["--", "-1"] input.bind ACON::Input::Definition.new ACON::Input::Argument.new "number" input.arguments.should eq({"number" => "-1"}) input = ACON::Input::ARGV.new ["-f", "bar", "--", "-1"] input.bind ACON::Input::Definition.new( ACON::Input::Argument.new("number"), ACON::Input::Option.new("foo", "f", :optional), ) input.options.should eq({"foo" => "bar"}) input.arguments.should eq({"number" => "-1"}) end def test_parse_empty_string_argument : Nil input = ACON::Input::ARGV.new ["-f", "bar", ""] input.bind ACON::Input::Definition.new( ACON::Input::Argument.new("empty"), ACON::Input::Option.new("foo", "f", :optional), ) input.options.should eq({"foo" => "bar"}) input.arguments.should eq({"empty" => ""}) end @[DataProvider("parse_options_provider")] def test_parse_options(input_args : Array(String), options : Array(ACON::Input::Option | ACON::Input::Argument), expected : Hash) : Nil input = ACON::Input::ARGV.new input_args input.bind ACON::Input::Definition.new options input.options.should eq expected end def parse_options_provider : Hash { "long options without a value" => { ["--foo"], [ACON::Input::Option.new("foo")], {"foo" => true}, }, "long options with a required value (with a = separator)" => { ["--foo=bar"], [ACON::Input::Option.new("foo", "f", :required)], {"foo" => "bar"}, }, "long options with a required value (with a space separator)" => { ["--foo", "bar"], [ACON::Input::Option.new("foo", "f", :required)], {"foo" => "bar"}, }, "long options with optional value which is empty (with a = separator) as empty string" => { ["--foo="], [ACON::Input::Option.new("foo", "f", :optional)], {"foo" => ""}, }, "long options with optional value without value specified or an empty string (with a = separator) followed by an argument as empty string" => { ["--foo=", "bar"], [ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Argument.new("name", :required)], {"foo" => ""}, }, "long options with optional value which is empty (with a = separator) preceded by an argument" => { ["bar", "--foo"], [ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Argument.new("name", :required)], {"foo" => nil}, }, "long options with optional value which is empty as empty string even followed by an argument" => { ["--foo", "", "bar"], [ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Argument.new("name", :required)], {"foo" => ""}, }, "long options with optional value specified with no separator and no value as nil" => { ["--foo"], [ACON::Input::Option.new("foo", "f", :optional)], {"foo" => nil}, }, "short options without a value" => { ["-f"], [ACON::Input::Option.new("foo", "f")], {"foo" => true}, }, "short options with a required value (with no separator)" => { ["-fbar"], [ACON::Input::Option.new("foo", "f", :required)], {"foo" => "bar"}, }, "short options with a required value (with a space separator)" => { ["-f", "bar"], [ACON::Input::Option.new("foo", "f", :required)], {"foo" => "bar"}, }, "short options with an optional empty value" => { ["-f", ""], [ACON::Input::Option.new("foo", "f", :optional)], {"foo" => ""}, }, "short options with an optional empty value followed by an argument" => { ["-f", "", "foo"], [ACON::Input::Argument.new("name"), ACON::Input::Option.new("foo", "f", :optional)], {"foo" => ""}, }, "short options with an optional empty value followed by an option" => { ["-f", "", "-b"], [ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Option.new("bar", "b")], {"foo" => "", "bar" => true}, }, "short options with an optional value which is not present" => { ["-f", "-b", "foo"], [ACON::Input::Argument.new("name"), ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Option.new("bar", "b")], {"foo" => nil, "bar" => true}, }, "short options when they are aggregated as a single one" => { ["-fb"], [ACON::Input::Option.new("foo", "f"), ACON::Input::Option.new("bar", "b")], {"foo" => true, "bar" => true}, }, "short options when they are aggregated as a single one and the last one has a required value" => { ["-fb", "bar"], [ACON::Input::Option.new("foo", "f"), ACON::Input::Option.new("bar", "b", :required)], {"foo" => true, "bar" => "bar"}, }, "short options when they are aggregated as a single one and the last one has an optional value" => { ["-fb", "bar"], [ACON::Input::Option.new("foo", "f"), ACON::Input::Option.new("bar", "b", :optional)], {"foo" => true, "bar" => "bar"}, }, "short options when they are aggregated as a single one and the last one has an optional value with no separator" => { ["-fbbar"], [ACON::Input::Option.new("foo", "f"), ACON::Input::Option.new("bar", "b", :optional)], {"foo" => true, "bar" => "bar"}, }, "short options when they are aggregated as a single one and one of them takes a value" => { ["-fbbar"], [ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Option.new("bar", "b", :optional)], {"foo" => "bbar", "bar" => nil}, }, } end @[DataProvider("parse_options_negatable_provider")] def test_parse_options_negatble(input_args : Array(String), options : Array(ACON::Input::Option | ACON::Input::Argument), expected : Hash) : Nil input = ACON::Input::ARGV.new input_args input.bind ACON::Input::Definition.new options input.options.should eq expected end def parse_options_negatable_provider : Hash { "long options without a value - negatable" => { ["--foo"], [ACON::Input::Option.new("foo", value_mode: :negatable)], {"foo" => true}, }, "long options without a value - no value negatable" => { ["--foo"], [ACON::Input::Option.new("foo", value_mode: ACON::Input::Option::Value[:none, :negatable])], {"foo" => true}, }, "negated long options without a value - negatable" => { ["--no-foo"], [ACON::Input::Option.new("foo", value_mode: :negatable)], {"foo" => false}, }, "negated long options without a value - no value negatable" => { ["--no-foo"], [ACON::Input::Option.new("foo", value_mode: ACON::Input::Option::Value[:none, :negatable])], {"foo" => false}, }, "missing negated option uses default - negatable" => { [] of String, [ACON::Input::Option.new("foo", value_mode: :negatable)], {"foo" => nil}, }, "missing negated option uses default - no value negatable" => { [] of String, [ACON::Input::Option.new("foo", value_mode: ACON::Input::Option::Value[:none, :negatable])], {"foo" => nil}, }, "missing negated option uses default - bool default" => { [] of String, [ACON::Input::Option.new("foo", value_mode: :negatable, default: false)], {"foo" => false}, }, } end def test_to_s : Nil input = ACON::Input::ARGV.new "-b", "bar" input.to_s.should eq "-b bar" end def test_to_s_complex : Nil input = ACON::Input::ARGV.new "-f", "--bar=foo", "a b c d", "A\nB'C" {% if flag? :windows %} input.to_s.should eq "-f --bar=foo \"a b c d\" A\nB'C" {% else %} input.to_s.should eq "-f --bar=foo 'a b c d' 'A\nB'\"'\"'C'" {% end %} end end ================================================ FILE: src/components/console/spec/input/definition_spec.cr ================================================ require "../spec_helper" struct InputDefinitionTest < ASPEC::TestCase getter arg_foo : ACON::Input::Argument { ACON::Input::Argument.new "foo" } getter arg_foo1 : ACON::Input::Argument { ACON::Input::Argument.new "foo" } getter arg_foo2 : ACON::Input::Argument { ACON::Input::Argument.new "foo2", :required } getter arg_bar : ACON::Input::Argument { ACON::Input::Argument.new "bar" } getter opt_foo : ACON::Input::Option { ACON::Input::Option.new "foo", "f" } getter opt_foo1 : ACON::Input::Option { ACON::Input::Option.new "foobar", "f" } getter opt_foo2 : ACON::Input::Option { ACON::Input::Option.new "foo", "p" } getter opt_bar : ACON::Input::Option { ACON::Input::Option.new "bar", "b" } getter opt_multi : ACON::Input::Option { ACON::Input::Option.new "multi", "m|mm|mmm" } def test_new_arguments : Nil definition = ACON::Input::Definition.new definition.arguments.should be_empty # Splat definition = ACON::Input::Definition.new self.arg_foo, self.arg_bar definition.arguments.should eq({"foo" => self.arg_foo, "bar" => self.arg_bar}) # Array definition = ACON::Input::Definition.new [self.arg_foo, self.arg_bar] definition.arguments.should eq({"foo" => self.arg_foo, "bar" => self.arg_bar}) # Hash definition = ACON::Input::Definition.new({"foo" => self.arg_foo, "bar" => self.arg_bar}) definition.arguments.should eq({"foo" => self.arg_foo, "bar" => self.arg_bar}) end def test_new_options : Nil definition = ACON::Input::Definition.new definition.options.should be_empty # Splat definition = ACON::Input::Definition.new self.opt_foo, self.opt_bar definition.options.should eq({"foo" => self.opt_foo, "bar" => self.opt_bar}) # Array definition = ACON::Input::Definition.new [self.opt_foo, self.opt_bar] definition.options.should eq({"foo" => self.opt_foo, "bar" => self.opt_bar}) # Hash definition = ACON::Input::Definition.new({"foo" => self.opt_foo, "bar" => self.opt_bar}) definition.options.should eq({"foo" => self.opt_foo, "bar" => self.opt_bar}) end def test_set_arguments : Nil definition = ACON::Input::Definition.new definition.arguments = [self.arg_foo] definition.arguments.should eq({"foo" => self.arg_foo}) definition.arguments = [self.arg_bar] definition.arguments.should eq({"bar" => self.arg_bar}) end def test_add_arguments : Nil definition = ACON::Input::Definition.new definition << [self.arg_foo] definition.arguments.should eq({"foo" => self.arg_foo}) definition << [self.arg_bar] definition.arguments.should eq({"foo" => self.arg_foo, "bar" => self.arg_bar}) end def test_add_argument : Nil definition = ACON::Input::Definition.new definition << self.arg_foo definition.arguments.should eq({"foo" => self.arg_foo}) definition << self.arg_bar definition.arguments.should eq({"foo" => self.arg_foo, "bar" => self.arg_bar}) end def test_add_argument_must_have_unique_names : Nil definition = ACON::Input::Definition.new self.arg_foo expect_raises ACON::Exception::Logic, "An argument with the name 'foo' already exists." do definition << self.arg_foo end end def test_add_argument_array_argument_must_be_last : Nil definition = ACON::Input::Definition.new ACON::Input::Argument.new "foo_array", :is_array expect_raises ACON::Exception::Logic, "Cannot add a required argument 'foo' after Array argument 'foo_array'." do definition << ACON::Input::Argument.new "foo" end end def test_add_argument_required_argument_cannot_follow_optional : Nil definition = ACON::Input::Definition.new self.arg_foo expect_raises ACON::Exception::Logic, "Cannot add required argument 'foo2' after the optional argument 'foo'." do definition << self.arg_foo2 end end def test_argument : Nil definition = ACON::Input::Definition.new self.arg_foo definition.argument("foo").should be self.arg_foo definition.argument(0).should be self.arg_foo end def test_argument_missing : Nil definition = ACON::Input::Definition.new self.arg_foo expect_raises ACON::Exception::InvalidArgument, "The argument 'bar' does not exist." do definition.argument "bar" end end def test_has_argument : Nil definition = ACON::Input::Definition.new self.arg_foo definition.has_argument?("foo").should be_true definition.has_argument?(0).should be_true definition.has_argument?("bar").should be_false definition.has_argument?(1).should be_false end def test_required_argument_count : Nil definition = ACON::Input::Definition.new definition << self.arg_foo2 definition.required_argument_count.should eq 1 definition << self.arg_foo definition.required_argument_count.should eq 1 end def test_argument_count : Nil definition = ACON::Input::Definition.new definition << self.arg_foo2 definition.argument_count.should eq 1 definition << self.arg_foo definition.argument_count.should eq 2 definition << ACON::Input::Argument.new "foo_array", :is_array definition.argument_count.should eq Int32::MAX end def test_argument_defaults : Nil definition = ACON::Input::Definition.new( ACON::Input::Argument.new("foo1", :optional), ACON::Input::Argument.new("foo2", :optional, "", "default"), ACON::Input::Argument.new("foo3", ACON::Input::Argument::Mode[:optional, :is_array]), ) definition.argument_defaults.should eq({"foo1" => nil, "foo2" => "default", "foo3" => [] of String}) definition = ACON::Input::Definition.new( ACON::Input::Argument.new("foo4", ACON::Input::Argument::Mode[:optional, :is_array], default: ["1", "2"]), ) definition.argument_defaults.should eq({"foo4" => ["1", "2"]}) end def test_set_options : Nil definition = ACON::Input::Definition.new definition.options = [self.opt_foo] definition.options.should eq({"foo" => self.opt_foo}) definition.options = [self.opt_bar] definition.options.should eq({"bar" => self.opt_bar}) end def test_set_options_clears_options : Nil definition = ACON::Input::Definition.new [self.opt_foo] definition.options = [self.opt_bar] expect_raises ACON::Exception::InvalidArgument, "The '-f' option does not exist." do definition.option_for_shortcut "f" end end def test_add_options : Nil definition = ACON::Input::Definition.new definition << [self.opt_foo] definition.options.should eq({"foo" => self.opt_foo}) definition << [self.opt_bar] definition.options.should eq({"foo" => self.opt_foo, "bar" => self.opt_bar}) end def test_add_option : Nil definition = ACON::Input::Definition.new definition << self.opt_foo definition.options.should eq({"foo" => self.opt_foo}) definition << self.opt_bar definition.options.should eq({"foo" => self.opt_foo, "bar" => self.opt_bar}) end def test_add_option_must_have_unique_names : Nil definition = ACON::Input::Definition.new self.opt_foo expect_raises ACON::Exception::Logic, "An option named 'foo' already exists." do definition << self.opt_foo2 end end def test_add_option_duplicate_negated : Nil definition = ACON::Input::Definition.new ACON::Input::Option.new "no-foo" expect_raises ACON::Exception::Logic, "An option named 'no-foo' already exists." do definition << ACON::Input::Option.new "foo", value_mode: :negatable end end def test_add_option_duplicate_negated_reverse_option : Nil definition = ACON::Input::Definition.new ACON::Input::Option.new "foo", value_mode: :negatable expect_raises ACON::Exception::Logic, "An option named 'no-foo' already exists." do definition << ACON::Input::Option.new "no-foo" end end def test_add_option_duplicate_shortcut : Nil definition = ACON::Input::Definition.new self.opt_foo expect_raises ACON::Exception::Logic, "An option with shortcut 'f' already exists." do definition << self.opt_foo1 end end def test_option : Nil definition = ACON::Input::Definition.new self.opt_foo definition.option("foo").should be self.opt_foo definition.option(0).should be self.opt_foo end def test_option_missing : Nil definition = ACON::Input::Definition.new self.opt_foo expect_raises ACON::Exception::InvalidArgument, "The '--bar' option does not exist." do definition.option "bar" end end def test_has_option : Nil definition = ACON::Input::Definition.new self.opt_foo definition.has_option?("foo").should be_true definition.has_option?(0).should be_true definition.has_option?("bar").should be_false definition.has_option?(1).should be_false end def test_has_shortcut : Nil definition = ACON::Input::Definition.new self.opt_foo definition.has_shortcut?("f").should be_true definition.has_shortcut?("p").should be_false end def test_option_for_shortcut : Nil definition = ACON::Input::Definition.new self.opt_foo definition.option_for_shortcut("f").should be self.opt_foo end def test_option_for_shortcut_multi : Nil definition = ACON::Input::Definition.new self.opt_multi definition.option_for_shortcut("m").should be self.opt_multi definition.option_for_shortcut("mmm").should be self.opt_multi end def test_option_for_shortcut_invalid : Nil definition = ACON::Input::Definition.new self.opt_foo expect_raises ACON::Exception::InvalidArgument, "The '-l' option does not exist." do definition.option_for_shortcut "l" end end def test_option_defaults : Nil definition = ACON::Input::Definition.new( ACON::Input::Option.new("foo1", value_mode: :none), ACON::Input::Option.new("foo2", value_mode: :required), ACON::Input::Option.new("foo3", value_mode: :required, default: "default"), ACON::Input::Option.new("foo4", value_mode: :optional), ACON::Input::Option.new("foo5", value_mode: :optional, default: "default"), ACON::Input::Option.new("foo6", value_mode: ACON::Input::Option::Value[:optional, :is_array]), ACON::Input::Option.new("foo7", value_mode: ACON::Input::Option::Value[:optional, :is_array], default: ["1", "2"]), ) definition.option_defaults.should eq({ "foo1" => false, "foo2" => nil, "foo3" => "default", "foo4" => nil, "foo5" => "default", "foo6" => [] of String, "foo7" => ["1", "2"], }) end def test_negation_to_name : Nil definition = ACON::Input::Definition.new ACON::Input::Option.new "foo", value_mode: :negatable definition.negation_to_name("no-foo").should eq "foo" end def test_negation_to_name_invalid : Nil definition = ACON::Input::Definition.new ACON::Input::Option.new "foo", value_mode: :negatable expect_raises ACON::Exception::InvalidArgument, "The '--no-bar' option does not exist." do definition.negation_to_name "no-bar" end end @[DataProvider("synopsis_provider")] def test_synopsis(definition : ACON::Input::Definition, expected : String) : Nil definition.synopsis.should eq expected end def synopsis_provider : Hash { "puts optional options in square brackets" => {ACON::Input::Definition.new(ACON::Input::Option.new("foo")), "[--foo]"}, "separates shortcuts with a pipe" => {ACON::Input::Definition.new(ACON::Input::Option.new("foo", "f")), "[-f|--foo]"}, "uses shortcut as value placeholder" => {ACON::Input::Definition.new(ACON::Input::Option.new("foo", "f", :required)), "[-f|--foo FOO]"}, "puts optional values in square brackets" => {ACON::Input::Definition.new(ACON::Input::Option.new("foo", "f", :optional)), "[-f|--foo [FOO]]"}, "puts arguments in angle brackets" => {ACON::Input::Definition.new(ACON::Input::Argument.new("foo", :required)), ""}, "puts optional arguments square brackets" => {ACON::Input::Definition.new(ACON::Input::Argument.new("foo", :optional)), "[]"}, "chains optional arguments inside brackets" => {ACON::Input::Definition.new(ACON::Input::Argument.new("foo"), ACON::Input::Argument.new("bar")), "[ []]"}, "uses an ellipsis for array arguments" => {ACON::Input::Definition.new(ACON::Input::Argument.new("foo", :is_array)), "[...]"}, "uses an ellipsis for required array arguments" => {ACON::Input::Definition.new(ACON::Input::Argument.new("foo", ACON::Input::Argument::Mode[:required, :is_array])), "..."}, "puts [--] between options and arguments" => {ACON::Input::Definition.new(ACON::Input::Option.new("foo"), ACON::Input::Argument.new("foo", :required)), "[--foo] [--] "}, } end def test_synopsis_short : Nil definition = ACON::Input::Definition.new( ACON::Input::Option.new("foo"), ACON::Input::Option.new("bar"), ACON::Input::Argument.new("baz"), ) definition.synopsis(true).should eq "[options] [--] []" end end ================================================ FILE: src/components/console/spec/input/hash_spec.cr ================================================ require "../spec_helper" struct HashTest < ASPEC::TestCase def test_first_argument : Nil ACON::Input::Hash.new.first_argument.should be_nil ACON::Input::Hash.new(name: "George").first_argument.should eq "George" ACON::Input::Hash.new("--foo": "bar", name: "George").first_argument.should eq "George" end def test_has_parameter : Nil input = ACON::Input::Hash.new(name: "George", "--foo": "bar") input.has_parameter?("--foo").should be_true input.has_parameter?("--bar").should be_false ACON::Input::Hash.new("--foo").has_parameter?("--foo").should be_true input = ACON::Input::Hash.new "--foo", "--", "--bar" input.has_parameter?("--bar").should be_true input.has_parameter?("--bar", only_params: true).should be_false end def test_get_parameter : Nil input = ACON::Input::Hash.new(name: "George", "--foo": "bar") input.parameter("--foo").should eq "bar" input.parameter("--bar", "default").should eq "default" ACON::Input::Hash.new("George": nil, "--foo": "bar").parameter("--foo").should eq "bar" input = ACON::Input::Hash.new("--foo": nil, "--": nil, "--bar": "baz") input.parameter("--bar").should eq "baz" input.parameter("--bar", "default", true).should eq "default" end def test_parse_arguments : Nil input = ACON::Input::Hash.new( {"name" => "foo"}, ACON::Input::Definition.new ACON::Input::Argument.new "name" ) input.arguments.should eq({"name" => "foo"}) end @[DataProvider("option_provider")] def test_parse_options(args : Hash(String, _), options : Array(ACON::Input::Option), expected_options : ::Hash) : Nil input = ACON::Input::Hash.new args, ACON::Input::Definition.new options input.options.should eq expected_options end def option_provider : Hash { "long option" => { { "--foo" => "bar", }, [ACON::Input::Option.new("foo")], {"foo" => "bar"}, }, "long option with default" => { { "--foo" => "bar", }, [ACON::Input::Option.new("foo", "f", :optional, "", "default")], {"foo" => "bar"}, }, "uses default value if not passed" => { Hash(String, String).new, [ACON::Input::Option.new("foo", "f", :optional, "", "default")], {"foo" => "default"}, }, "uses passed value even with default" => { {"--foo" => nil}, [ACON::Input::Option.new("foo", "f", :optional, "", "default")], {"foo" => nil}, }, "short option" => { {"-f" => "bar"}, [ACON::Input::Option.new("foo", "f", :optional, "", "default")], {"foo" => "bar"}, }, "does not parse args after --" => { {"--" => nil, "-f" => "bar"}, [ACON::Input::Option.new("foo", "f", :optional, "", "default")], {"foo" => "default"}, }, "handles only --" => { {"--" => nil}, Array(ACON::Input::Option).new, Hash(String, String).new, }, } end @[DataProvider("invalid_input_provider")] def test_parse_invalid_input(args : Hash(String, _), definition : ACON::Input::Definition, error_class : ::Exception.class, error_message : String) : Nil expect_raises error_class, error_message do ACON::Input::Hash.new args, definition end end def invalid_input_provider : Tuple { { {"foo" => "foo"}, ACON::Input::Definition.new(ACON::Input::Argument.new("name")), ACON::Exception::InvalidArgument, "The 'foo' argument does not exist.", }, { {"--foo" => nil}, ACON::Input::Definition.new(ACON::Input::Option.new("foo", "f", :required)), ACON::Exception::InvalidOption, "The '--foo' option requires a value.", }, { {"--foo" => "foo"}, ACON::Input::Definition.new, ACON::Exception::InvalidOption, "The '--foo' option does not exist.", }, { {"-o" => "foo"}, ACON::Input::Definition.new, ACON::Exception::InvalidOption, "The '-o' option does not exist.", }, } end def test_to_s_complex_mix : Nil input = ACON::Input::Hash.new "-f": nil, "-b": "bar", "--foo": "b a z", "--lala": nil, "test": "Foo", "test2": "A\nB'C" {% if flag? :windows %} input.to_s.should eq "-f -b bar --foo=\"b a z\" --lala Foo A\nB'C" {% else %} input.to_s.should eq "-f -b bar --foo='b a z' --lala Foo 'A\nB'\"'\"'C'" {% end %} end def test_to_s_array_options : Nil input = ACON::Input::Hash.new "-b": ["bval_1", "bval_2"], "--f": ["fval_1", "fval_2"] input.to_s.should eq "-b bval_1 -b bval_2 --f=fval_1 --f=fval_2" end def test_to_s_array_argument : Nil input = ACON::Input::Hash.new "array_arg": ["val_1", "val_2"] input.to_s.should eq "val_1 val_2" end end ================================================ FILE: src/components/console/spec/input/input_spec.cr ================================================ require "../spec_helper" describe ACON::Input do describe "options" do it "parses long option" do input = ACON::Input::Hash.new( {"--name" => "foo"}, ACON::Input::Definition.new(ACON::Input::Option.new("name")) ) input.option("name").should eq "foo" input.set_option "name", "bar" input.option("name").should eq "bar" input.options.should eq({"name" => "bar"}) end it "parses short option" do input = ACON::Input::Hash.new( {"-n" => "foo"}, ACON::Input::Definition.new(ACON::Input::Option.new("name", shortcut: "n")) ) input.option("name").should eq "foo" input.set_option "name", "bar" input.option("name").should eq "bar" input.options.should eq({"name" => "bar"}) end it "uses default when not provided" do input = ACON::Input::Hash.new( {"--name" => "foo"}, ACON::Input::Definition.new( ACON::Input::Option.new("name"), ACON::Input::Option.new("bar", nil, :optional, "", "default") ) ) input.option("bar").should eq "default" input.options.should eq({"name" => "foo", "bar" => "default"}) end it "should parse explicit empty string value" do input = ACON::Input::Hash.new( {"--name" => "foo", "--bar" => ""}, ACON::Input::Definition.new( ACON::Input::Option.new("name"), ACON::Input::Option.new("bar", nil, :optional, "", "default") ) ) input.option("bar").should eq "" input.options.should eq({"name" => "foo", "bar" => ""}) end it "should parse explicit nil value" do input = ACON::Input::Hash.new( {"--name" => "foo", "--bar" => nil}, ACON::Input::Definition.new( ACON::Input::Option.new("name"), ACON::Input::Option.new("bar", nil, :optional, "", "default") ) ) input.option("bar").should be_nil input.options.should eq({"name" => "foo", "bar" => nil}) end describe "negatable option" do it "non negated" do input = ACON::Input::Hash.new( {"--name" => nil}, ACON::Input::Definition.new( ACON::Input::Option.new("name", value_mode: :negatable) ) ) input.has_option?("name").should be_true input.has_option?("no-name").should be_true input.option("name").should eq "true" input.option("no-name").should eq "false" end it "negated" do input = ACON::Input::Hash.new( {"--no-name" => nil}, ACON::Input::Definition.new( ACON::Input::Option.new("name", value_mode: :negatable) ) ) input.option("name").should eq "false" input.option("no-name").should eq "true" end it "with default" do input = ACON::Input::Hash.new( Hash(String, String).new, ACON::Input::Definition.new( ACON::Input::Option.new("name", value_mode: :negatable, default: nil) ) ) input.option("name").should be_nil input.option("no-name").should be_nil end end it "set invalid option" do input = ACON::Input::Hash.new( {"--name" => "foo"}, ACON::Input::Definition.new( ACON::Input::Option.new("name"), ACON::Input::Option.new("bar", nil, :optional, "", "default") ) ) expect_raises ACON::Exception::InvalidArgument, "The 'foo' option does not exist." do input.set_option "foo", "foo" end end it "get invalid option" do input = ACON::Input::Hash.new( {"--name" => "foo"}, ACON::Input::Definition.new( ACON::Input::Option.new("name"), ACON::Input::Option.new("bar", nil, :optional, "", "default") ) ) expect_raises ACON::Exception::InvalidArgument, "The 'foo' option does not exist." do input.option "foo" end end describe "#option(T)" do it "optional option with default accessed via non nilable type" do input = ACON::Input::Hash.new( Hash(String, String).new, ACON::Input::Definition.new( ACON::Input::Option.new("name", nil, :optional, default: "bar"), ) ) option = input.option "name", String typeof(option).should eq String option.should eq "bar" end it "optional option without default accessed via nilable type" do input = ACON::Input::Hash.new( {"--name2" => "foo"}, ACON::Input::Definition.new( ACON::Input::Option.new("name"), ACON::Input::Option.new("name2"), ) ) option = input.option "name2", String? typeof(option).should eq String? option.should eq "foo" end it "required option with default accessed via non nilable type" do input = ACON::Input::Hash.new( {"--name" => "foo"}, ACON::Input::Definition.new( ACON::Input::Option.new("name", nil, :required), ) ) option = input.option "name", String typeof(option).should eq String option.should eq "foo" end it "negatable option accessed via non bool type" do input = ACON::Input::Hash.new( {"--name" => "true"}, ACON::Input::Definition.new( ACON::Input::Option.new("name", nil, :negatable), ) ) expect_raises ACON::Exception::Logic, "Cannot cast negatable option 'name' to non 'Bool?' type." do input.option "name", Int32 end end it "negatable option with default accessed via non nilable type" do input = ACON::Input::Hash.new( {"--name" => "true"}, ACON::Input::Definition.new( ACON::Input::Option.new("name", nil, :negatable), ) ) option = input.option "name", Bool typeof(option).should eq Bool option.should be_true option = input.option "no-name", Bool typeof(option).should eq Bool option.should be_false end it "option that doesnt exist" do input = ACON::Input::Hash.new( {"--name" => "foo"}, ACON::Input::Definition.new( ACON::Input::Option.new("name"), ) ) expect_raises ACON::Exception::InvalidArgument, "The 'foo' option does not exist." do input.option "foo" end end end end describe "arguments" do it do input = ACON::Input::Hash.new( {"name" => "foo"}, ACON::Input::Definition.new( ACON::Input::Argument.new("name"), ) ) input.argument("name").should eq "foo" input.set_argument "name", "bar" input.argument("name").should eq "bar" input.arguments.should eq({"name" => "bar"}) end it do input = ACON::Input::Hash.new( {"name" => "foo"}, ACON::Input::Definition.new( ACON::Input::Argument.new("name"), ACON::Input::Argument.new("bar", :optional, "", "default") ) ) input.argument("bar").should eq "default" typeof(input.argument("bar")).should eq String? input.arguments.should eq({"name" => "foo", "bar" => "default"}) end it "set invalid option" do input = ACON::Input::Hash.new( {"name" => "foo"}, ACON::Input::Definition.new( ACON::Input::Argument.new("name"), ACON::Input::Argument.new("bar", :optional, "", "default") ) ) expect_raises ACON::Exception::InvalidArgument, "The 'foo' argument does not exist." do input.set_argument "foo", "foo" end end it "get invalid option" do input = ACON::Input::Hash.new( {"name" => "foo"}, ACON::Input::Definition.new( ACON::Input::Argument.new("name"), ACON::Input::Argument.new("bar", :optional, "", "default") ) ) expect_raises ACON::Exception::InvalidArgument, "The 'foo' argument does not exist." do input.argument "foo" end end describe "#argument(T)" do it "optional arg without default raises when accessed via non nilable type" do input = ACON::Input::Hash.new( {"name" => "foo"}, ACON::Input::Definition.new( ACON::Input::Argument.new("name"), ) ) expect_raises ACON::Exception::Logic, "Cannot cast optional argument 'name' to non-nilable type 'String' without a default." do input.argument "name", String end end it "optional arg with default accessed via non nilable type" do input = ACON::Input::Hash.new( Hash(String, String).new, ACON::Input::Definition.new( ACON::Input::Argument.new("name", default: "bar"), ) ) arg = input.argument "name", String typeof(arg).should eq String arg.should eq "bar" end it "optional arg without default accessed via nilable type" do input = ACON::Input::Hash.new( {"name2" => "foo"}, ACON::Input::Definition.new( ACON::Input::Argument.new("name"), ACON::Input::Argument.new("name2"), ) ) arg = input.argument "name2", String? typeof(arg).should eq String? arg.should eq "foo" end it "arg that doesnt exist" do input = ACON::Input::Hash.new( {"name" => "foo"}, ACON::Input::Definition.new( ACON::Input::Argument.new("name"), ) ) expect_raises ACON::Exception::InvalidArgument, "The 'foo' argument does not exist." do input.argument "foo" end end end end describe "#validate" do it "missing arguments" do input = ACON::Input::Hash.new input.bind ACON::Input::Definition.new ACON::Input::Argument.new("name", :required) expect_raises ACON::Exception::Runtime, "Not enough arguments (missing: 'name')." do input.validate end end it "missing required argument" do input = ACON::Input::Hash.new bar: "baz" input.bind ACON::Input::Definition.new( ACON::Input::Argument.new("name", :required), ACON::Input::Argument.new("bar", :optional) ) expect_raises ACON::Exception::Runtime, "Not enough arguments (missing: 'name')." do input.validate end end end end ================================================ FILE: src/components/console/spec/input/option_spec.cr ================================================ require "../spec_helper" describe ACON::Input::Option do describe ".new" do it "normalizes the name" do ACON::Input::Option.new("--foo").name.should eq "foo" end it "disallows blank names" do expect_raises ACON::Exception::InvalidArgument, "An option name cannot be blank." do ACON::Input::Option.new "" end expect_raises ACON::Exception::InvalidArgument, "An option name cannot be blank." do ACON::Input::Option.new " " end end describe "shortcut" do it "array" do ACON::Input::Option.new("foo", ["a", "b"]).shortcut.should eq "a|b" end it "string" do ACON::Input::Option.new("foo", "-a|b").shortcut.should eq "a|b" end it "string with whitespace" do ACON::Input::Option.new("foo", "a| -b").shortcut.should eq "a|b" end it "string with different characters" do expect_raises ACON::Exception::InvalidArgument, "An option shortcut must consist of the same character, got 'ab'." do ACON::Input::Option.new "foo", "ab" end expect_raises ACON::Exception::InvalidArgument, "An option shortcut must consist of the same character, got 'aab'." do ACON::Input::Option.new "foo", "a|aa|aab" end end it "array with different characters" do expect_raises ACON::Exception::InvalidArgument, "An option shortcut must consist of the same character, got 'ab'." do ACON::Input::Option.new "foo", ["a", "ab"] end end it "blank" do expect_raises ACON::Exception::InvalidArgument, "An option shortcut cannot be blank." do ACON::Input::Option.new "foo", [] of String end expect_raises ACON::Exception::InvalidArgument, "An option shortcut cannot be blank." do ACON::Input::Option.new "foo", "" end expect_raises ACON::Exception::InvalidArgument, "An option shortcut cannot be blank." do ACON::Input::Option.new "foo", " " end end end describe "value_mode" do it "NONE | IS_ARRAY" do expect_raises ACON::Exception::InvalidArgument, "Cannot have VALUE::IS_ARRAY option mode when the option does not accept a value." do ACON::Input::Option.new "foo", value_mode: ACON::Input::Option::Value::NONE | ACON::Input::Option::Value::IS_ARRAY end end it "NEGATABLE with value" do expect_raises ACON::Exception::InvalidArgument, "Cannot have VALUE::NEGATABLE option mode if the option also accepts a value." do ACON::Input::Option.new "foo", value_mode: ACON::Input::Option::Value::REQUIRED | ACON::Input::Option::Value::NEGATABLE end end end end describe "#default=" do it "does not allow a default if using Value::NONE" do expect_raises ACON::Exception::Logic, "Cannot set a default value when using Value::NONE mode." do ACON::Input::Option.new "foo", default: "bar" end end describe "array" do it "nil value" do option = ACON::Input::Option.new "foo", value_mode: ACON::Input::Option::Value::OPTIONAL | ACON::Input::Option::Value::IS_ARRAY option.default = nil option.default.should eq [] of String end it "non array" do option = ACON::Input::Option.new "foo", value_mode: ACON::Input::Option::Value::OPTIONAL | ACON::Input::Option::Value::IS_ARRAY expect_raises ACON::Exception::Logic, "Default value for an array option must be an array." do option.default = "bar" end end end end describe "#complete" do it "with an array" do values = ["foo", "bar"] suggestions = ACON::Completion::Suggestions.new argument = ACON::Input::Option.new "foo", value_mode: :required, suggested_values: values argument.has_completion?.should be_true argument.complete ACON::Completion::Input.new, suggestions suggestions.suggested_values.map(&.value).should eq ["foo", "bar"] end it "with an block" do values = ["foo", "bar"] suggestions = ACON::Completion::Suggestions.new callback = Proc(ACON::Completion::Input, Array(String)).new { values } argument = ACON::Input::Option.new "foo", value_mode: :required, suggested_values: callback argument.has_completion?.should be_true argument.complete ACON::Completion::Input.new, suggestions suggestions.suggested_values.map(&.value).should eq ["foo", "bar"] end it "when option accepts no value" do expect_raises ACON::Exception::Logic, "Cannot set suggested values if the option does not accept a value." do ACON::Input::Option.new "foo", suggested_values: ["foo"] end end end end ================================================ FILE: src/components/console/spec/input/string_line_spec.cr ================================================ require "../spec_helper" struct StringLineTest < ASPEC::TestCase @[DataProvider("tokenize_data")] def test_tokenize(input : String, tokens : Array(String)) : Nil input = ACON::Input::StringLine.new input input.@tokens.should eq tokens end def tokenize_data : Hash { "empty string" => {"", [] of String}, "arguments" => {"foo", ["foo"]}, "ignores whitespace between arguments" => {" foo ", ["foo"]}, "single quoted arguments" => {"'foo'", ["foo"]}, "double quoted arguments" => {"\"foo\"", ["foo"]}, "whitespace characters within string" => {"'a\rb\nc\td'", ["a\rb\nc\td"]}, "whitespace characters between args as spaces" => {"'a'\r'b'\n'c'\t'd'", ["a", "b", "c", "d"]}, "escaped double quoted arguments" => { %(\\"foo\\"), ["\"foo\""] }, "escaped single quoted arguments" => { %(\\'foo\\'), ["'foo'"] }, "short option" => {"-a", ["-a"]}, "aggregated short options" => {"-azc", ["-azc"]}, "short option with value" => {"-awithavalue", ["-awithavalue"]}, "short option with double quoted value" => { %(-a"foo bar"), ["-afoo bar"] }, "short option with multiple double quoted values" => { %(-a"foo bar""foo bar"), ["-afoo barfoo bar"] }, "short option with single quoted value" => { %(-a'foo bar'), ["-afoo bar"] }, "short option with multiple single quoted values" => { %(-a'foo bar''foo bar'), ["-afoo barfoo bar"] }, "long option" => {"--long-option", ["--long-option"]}, "long option with value" => {"--long-option=foo", ["--long-option=foo"]}, "long option with double quoted value" => { %(--long-option="foo bar"), ["--long-option=foo bar"] }, "long option with multiple double quoted values" => { %(--long-option="foo bar""another"), ["--long-option=foo baranother"] }, "long option with single quoted value" => { %(--long-option='foo bar'), ["--long-option=foo bar"] }, "long option with multiple single quoted values" => { %(--long-option='foo bar''another'), ["--long-option=foo baranother"] }, "several arguments and options" => {"foo -a -ffoo --long bar", ["foo", "-a", "-ffoo", "--long", "bar"]}, "quoted quotes" => {"--arg=\\\"'Jenny'\\''s'\\\"", ["--arg=\"Jenny's\""]}, "quoted single quote with escaped quote" => {"'A\nB\\'C'", ["A\nB'C"]}, } end def test_to_s : Nil input = ACON::Input::StringLine.new "-f foo" input.to_s.should eq "-f foo" {% if flag? :windows %} input = ACON::Input::StringLine.new %(-f --bar=foo "a b c d") input.to_s.should eq "-f --bar=foo \"a b c d\"" input = ACON::Input::StringLine.new %(-f --bar=foo 'a b c d' 'A\nB\\'C') input.to_s.should eq "-f --bar=foo \"a b c d\" A\nB'C" {% else %} input = ACON::Input::StringLine.new %(-f --bar=foo "a b c d") input.to_s.should eq "-f --bar=foo 'a b c d'" input = ACON::Input::StringLine.new %(-f --bar=foo 'a b c d' 'A\nB\\'C') input.to_s.should eq "-f --bar=foo 'a b c d' 'A\nB'\"'\"'C'" {% end %} end end ================================================ FILE: src/components/console/spec/input/value/array_spec.cr ================================================ require "../../spec_helper" describe ACON::Input::Value::Array do describe ".new" do it "without args" do array = ACON::Input::Value::Array.new array.value.should be_empty array.should be_empty end it "with args" do array = ACON::Input::Value::Array.from_array [1, "foo", false] array.value.size.should eq 3 array << 10 array.value.size.should eq 4 end end it "#to_s" do ACON::Input::Value::Array .from_array([1, "foo", false]) .to_s .should eq %(1,foo,false) end describe "#get" do it "non-nilable" do ACON::Input::Value::Array .from_array(arr = [1, 2, 3]) .get(Array(Int32)) .should eq arr end it "nilable" do ACON::Input::Value::Array .from_array(arr = ["foo", "bar"]) .get(Array(String)?) .should eq arr end end end ================================================ FILE: src/components/console/spec/input/value/bool_spec.cr ================================================ require "../../spec_helper" describe ACON::Input::Value::Bool do describe "#get" do describe Bool do it "non-nilable" do val = ACON::Input::Value::Bool.new(false).get Bool typeof(val).should eq Bool val.should be_false end it "nilable" do val = ACON::Input::Value::Bool.new(true).get Bool? typeof(val).should eq Bool? val.should be_true end end describe String do it "non-nilable" do val = ACON::Input::Value::Bool.new(false).get String typeof(val).should eq String val.should eq "false" end it "nilable" do val = ACON::Input::Value::Bool.new(true).get String? typeof(val).should eq String? val.should eq "true" end end describe Int do it "non-nilable" do expect_raises ACON::Exception::Logic, "'false' is not a valid 'Int32'." do ACON::Input::Value::Bool.new(false).get Int32 end expect_raises ACON::Exception::Logic, "'true' is not a valid 'UInt8'." do ACON::Input::Value::Bool.new(true).get UInt8 end end it "nilable" do expect_raises ACON::Exception::Logic, "'false' is not a valid '(Int32 | Nil)'." do ACON::Input::Value::Bool.new(false).get Int32? end expect_raises ACON::Exception::Logic, "'true' is not a valid '(UInt8 | Nil)'." do ACON::Input::Value::Bool.new(true).get UInt8? end end end describe Float do it "non-nilable" do expect_raises ACON::Exception::Logic, "'false' is not a valid 'Float32'." do ACON::Input::Value::Bool.new(false).get Float32 end expect_raises ACON::Exception::Logic, "'true' is not a valid 'Float64'." do ACON::Input::Value::Bool.new(true).get Float64 end end it "nilable" do expect_raises ACON::Exception::Logic, "'false' is not a valid '(Float32 | Nil)'." do ACON::Input::Value::Bool.new(false).get Float32? end expect_raises ACON::Exception::Logic, "'true' is not a valid '(Float64 | Nil)'." do ACON::Input::Value::Bool.new(true).get Float64? end end end describe Array do describe String do it "non-nilable" do expect_raises ACON::Exception::Logic, "'false' is not a valid 'Array(String)'." do ACON::Input::Value::Bool.new(false).get Array(String) end end it "nilable" do expect_raises ACON::Exception::Logic, "'false' is not a valid '(Array(String) | Nil)'." do ACON::Input::Value::Bool.new(false).get Array(String)? end end it "nilable generic value" do expect_raises ACON::Exception::Logic, "'true' is not a valid '(Array(String | Nil) | Nil)'." do ACON::Input::Value::Bool.new(true).get Array(String?)? end end end describe Int32 do it "non-nilable" do expect_raises ACON::Exception::Logic, "'false' is not a valid 'Array(Int32)'." do ACON::Input::Value::Bool.new(false).get Array(Int32) end end it "nilable" do expect_raises ACON::Exception::Logic, "'false' is not a valid '(Array(Int32) | Nil)'." do ACON::Input::Value::Bool.new(false).get Array(Int32)? end end it "nilable generic value" do expect_raises ACON::Exception::Logic, "'true' is not a valid '(Array(Int32 | Nil) | Nil)'." do ACON::Input::Value::Bool.new(true).get Array(Int32?)? end end end describe Bool do it "non-nilable" do expect_raises ACON::Exception::Logic, "'false' is not a valid 'Array(Bool)'." do ACON::Input::Value::Bool.new(false).get Array(Bool) end end it "nilable" do expect_raises ACON::Exception::Logic, "'false' is not a valid '(Array(Bool) | Nil)'." do ACON::Input::Value::Bool.new(false).get Array(Bool)? end end it "nilable generic value" do expect_raises ACON::Exception::Logic, "'true' is not a valid '(Array(Bool | Nil) | Nil)'." do ACON::Input::Value::Bool.new(true).get Array(Bool?)? end end end end end end ================================================ FILE: src/components/console/spec/input/value/nil_spec.cr ================================================ require "../../spec_helper" describe ACON::Input::Value::Number do describe "#get" do it Bool do expect_raises ACON::Exception::Logic, "'123' is not a valid 'Bool'." do ACON::Input::Value::Number.new(123).get Bool end end it String do val = ACON::Input::Value::Number.new(123).get String typeof(val).should eq String val.should eq "123" end it Int do val = ACON::Input::Value::Number.new(123).get Int32 typeof(val).should eq Int32 val.should eq 123 val = ACON::Input::Value::Number.new(123_u8).get UInt8 typeof(val).should eq UInt8 val.should eq 123_u8 end it Float do val = ACON::Input::Value::Number.new(4.69).get Float32 typeof(val).should eq Float32 val.should eq 4.69_f32 val = ACON::Input::Value::Number.new(4.69).get Float64 typeof(val).should eq Float64 val.should eq 4.69 end describe Array do it String do expect_raises ACON::Exception::Logic, "'123' is not a valid 'Array(String)'." do ACON::Input::Value::Number.new(123).get Array(String) end end it Int32 do expect_raises ACON::Exception::Logic, "'123' is not a valid '(Array(Int32) | Nil)'." do ACON::Input::Value::Number.new(123).get Array(Int32)? end end it Bool do expect_raises ACON::Exception::Logic, "'123' is not a valid 'Array(Bool)'." do ACON::Input::Value::Number.new(123).get Array(Bool) end end end end end ================================================ FILE: src/components/console/spec/input/value/number_spec.cr ================================================ require "../../spec_helper" describe ACON::Input::Value::Number do describe "#get" do describe Bool do it "non-nilable" do expect_raises ACON::Exception::Logic, "'123' is not a valid 'Bool'." do ACON::Input::Value::Number.new(123).get Bool end end it "nilable" do expect_raises ACON::Exception::Logic, "'123' is not a valid '(Bool | Nil)'." do ACON::Input::Value::Number.new(123).get Bool? end end end describe String do it "non-nilable" do val = ACON::Input::Value::Number.new(123).get String typeof(val).should eq String val.should eq "123" end it "nilable" do val = ACON::Input::Value::Number.new(123).get String? typeof(val).should eq String? val.should eq "123" end end describe Int do it "non-nilable" do val = ACON::Input::Value::Number.new(123).get Int32 typeof(val).should eq Int32 val.should eq 123 val = ACON::Input::Value::Number.new(123_u8).get UInt8 typeof(val).should eq UInt8 val.should eq 123_u8 end it "non-nilable" do val = ACON::Input::Value::Number.new(123).get Int32? typeof(val).should eq Int32? val.should eq 123 val = ACON::Input::Value::Number.new(123_u8).get UInt8? typeof(val).should eq UInt8? val.should eq 123_u8 end end describe Float do it "non-nilable" do val = ACON::Input::Value::Number.new(4.69).get Float32 typeof(val).should eq Float32 val.should eq 4.69_f32 val = ACON::Input::Value::Number.new(4.69).get Float64 typeof(val).should eq Float64 val.should eq 4.69 end it "non-nilable" do val = ACON::Input::Value::Number.new(4.69).get Float32? typeof(val).should eq Float32? val.should eq 4.69_f32 val = ACON::Input::Value::Number.new(4.69).get Float64? typeof(val).should eq Float64? val.should eq 4.69 end end describe Array do describe String do it "non-nilable" do expect_raises ACON::Exception::Logic, "'123' is not a valid 'Array(String)'." do ACON::Input::Value::Number.new(123).get Array(String) end end it "nilable" do expect_raises ACON::Exception::Logic, "'123' is not a valid '(Array(String) | Nil)'." do ACON::Input::Value::Number.new(123).get Array(String)? end end it "nilable generic value" do expect_raises ACON::Exception::Logic, "'123' is not a valid '(Array(String | Nil) | Nil)'." do ACON::Input::Value::Number.new(123).get Array(String?)? end end end describe Int32 do it "non-nilable" do expect_raises ACON::Exception::Logic, "'123' is not a valid 'Array(Int32)'." do ACON::Input::Value::Number.new(123).get Array(Int32) end end it "nilable" do expect_raises ACON::Exception::Logic, "'123' is not a valid '(Array(Int32) | Nil)'." do ACON::Input::Value::Number.new(123).get Array(Int32)? end end it "nilable generic value" do expect_raises ACON::Exception::Logic, "'123' is not a valid '(Array(Int32 | Nil) | Nil)'." do ACON::Input::Value::Number.new(123).get Array(Int32?)? end end end describe Bool do it "non-nilable" do expect_raises ACON::Exception::Logic, "'123' is not a valid 'Array(Bool)'." do ACON::Input::Value::Number.new(123).get Array(Bool) end end it "nilable" do expect_raises ACON::Exception::Logic, "'123' is not a valid '(Array(Bool) | Nil)'." do ACON::Input::Value::Number.new(123).get Array(Bool)? end end it "nilable generic value" do expect_raises ACON::Exception::Logic, "'123' is not a valid '(Array(Bool | Nil) | Nil)'." do ACON::Input::Value::Number.new(123).get Array(Bool?)? end end end end end end ================================================ FILE: src/components/console/spec/input/value/string_spec.cr ================================================ require "../../spec_helper" describe ACON::Input::Value::String do describe "#get" do describe Bool do describe "non-nilable" do it "true" do val = ACON::Input::Value::String.new("true").get Bool typeof(val).should eq Bool val.should be_true end it "false" do val = ACON::Input::Value::String.new("false").get Bool typeof(val).should eq Bool val.should be_false end it "invalid" do expect_raises ACON::Exception::Logic, "'123' is not a valid 'Bool'." do ACON::Input::Value::String.new("123").get Bool end end end describe "nilable" do it "valid" do val = ACON::Input::Value::String.new("true").get Bool? typeof(val).should eq Bool? val.should be_true end it "invalid" do expect_raises ACON::Exception::Logic, "'123' is not a valid 'Bool?'." do ACON::Input::Value::String.new("123").get Bool? end end end end describe String do it "non-nilable" do val = ACON::Input::Value::String.new("foo").get String typeof(val).should eq String val.should eq "foo" end it "nilable" do val = ACON::Input::Value::String.new("foo").get String? typeof(val).should eq String? val.should eq "foo" end end describe Int do it "non-nilable" do string = ACON::Input::Value::String.new "123" val = string.get Int32 typeof(val).should eq Int32 val.should eq 123 val = string.get(UInt8) typeof(val).should eq UInt8 val.should eq 123_u8 end it "nilable" do string = ACON::Input::Value::String.new "123" val = string.get Int32? typeof(val).should eq Int32? val.should eq 123 val = string.get UInt8? typeof(val).should eq UInt8? val.should eq 123_u8 end it "non number" do expect_raises ACON::Exception::Logic, "'foo' is not a valid 'Int32'." do ACON::Input::Value::String.new("foo").get Int32 end expect_raises ACON::Exception::Logic, "'foo' is not a valid 'Int32'." do ACON::Input::Value::String.new("foo").get Int32? end end end describe Float do it "non-nilable" do string = ACON::Input::Value::String.new "4.57" val = string.get Float64 typeof(val).should eq Float64 val.should eq 4.57 val = string.get Float32 typeof(val).should eq Float32 val.should eq 4.57_f32 end it "nilable" do string = ACON::Input::Value::String.new "4.57" val = string.get Float64? typeof(val).should eq Float64? val.should eq 4.57 val = string.get Float32? typeof(val).should eq Float32? val.should eq 4.57_f32 end it "non number" do expect_raises ACON::Exception::Logic, "'foo' is not a valid 'Float64'." do ACON::Input::Value::String.new("foo").get Float64 end expect_raises ACON::Exception::Logic, "'foo' is not a valid 'Float64'." do ACON::Input::Value::String.new("foo").get Float64? end end end describe Array do describe String do it "non-nilable" do val = ACON::Input::Value::String.new("foo,bar,baz").get Array(String) typeof(val).should eq Array(String) val.should eq ["foo", "bar", "baz"] end it "nilable" do val = ACON::Input::Value::String.new("foo,bar,baz").get Array(String)? typeof(val).should eq Array(String)? val.should eq ["foo", "bar", "baz"] end it "nilable generic value" do val = ACON::Input::Value::String.new("foo,bar,baz").get Array(String?)? typeof(val).should eq Array(String?)? val.should eq ["foo", "bar", "baz"] end end describe Int32 do it "non-nilable" do val = ACON::Input::Value::String.new("1,2,3").get Array(Int32) typeof(val).should eq Array(Int32) val.should eq [1, 2, 3] end it "nilable" do val = ACON::Input::Value::String.new("1,2,3").get Array(Int32)? typeof(val).should eq Array(Int32)? val.should eq [1, 2, 3] end it "nilable generic value" do val = ACON::Input::Value::String.new("1,2,3").get Array(Int32?)? typeof(val).should eq Array(Int32?)? val.should eq [1, 2, 3] end end describe Bool do it "non-nilable" do val = ACON::Input::Value::String.new("false,true,true").get Array(Bool) typeof(val).should eq Array(Bool) val.should eq [false, true, true] end it "nilable" do val = ACON::Input::Value::String.new("false,true,true").get Array(Bool)? typeof(val).should eq Array(Bool)? val.should eq [false, true, true] end it "nilable generic value" do val = ACON::Input::Value::String.new("false,true,true").get Array(Bool?)? typeof(val).should eq Array(Bool?)? val.should eq [false, true, true] end end end end end ================================================ FILE: src/components/console/spec/output/console_section_output_spec.cr ================================================ require "../spec_helper" struct ConsoleSectionOutputTest < ASPEC::TestCase @io : IO::Memory def initialize @io = IO::Memory.new end def test_adding_multiple_sections : Nil sections = Array(ACON::Output::Section).new ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new sections.size.should eq 2 end def test_clear_all : Nil sections = Array(ACON::Output::Section).new output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output.puts "Foo#{EOL}Bar" output.clear @io.to_s.should eq "Foo#{EOL}Bar#{EOL}\e[2A\e[0J" end def test_clear_number_of_lines : Nil sections = Array(ACON::Output::Section).new output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output.puts "Foo\nBar\nBaz\nFooBar" output.clear 2 @io.to_s.should eq "Foo\nBar\nBaz\nFooBar#{EOL}\e[2A\e[0J" end def test_clear_number_more_than_current_size : Nil sections = Array(ACON::Output::Section).new output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output.puts "Foo" output.clear 2 @io.to_s.should eq "Foo#{EOL}\e[2A\e[0J" end def test_clear_number_of_lines_multiple_sections : Nil sections = Array(ACON::Output::Section).new output1 = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output2 = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output2.puts "Foo" output2.puts "Bar" output2.clear 1 output1.puts "Baz" @io.to_s.should eq "Foo#{EOL}Bar#{EOL}\e[1A\e[0J\e[1A\e[0JBaz#{EOL}Foo#{EOL}" end def test_clear_number_of_lines_multiple_sections_preserves_empty_lines : Nil sections = Array(ACON::Output::Section).new output1 = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output2 = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output2.puts "#{EOL}foo" output2.clear 1 output1.puts "bar" @io.to_s.should eq "#{EOL}foo#{EOL}\e[1A\e[0J\e[1A\e[0Jbar#{EOL}#{EOL}" end def test_clear_with_question : Nil input = ACON::Input::Hash.new input.stream = IO::Memory.new "Batman & Robin\n" input.interactive = true sections = Array(ACON::Output::Section).new output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new ACON::Helper::Question.new.ask input, output, ACON::Question(String?).new("What's your favorite superhero?", nil) output.clear @io.to_s.should eq "What's your favorite superhero?#{EOL}\e[2A\e[0J" end def test_clear_after_overwrite_clear_correct_number_of_lines : Nil expected = IO::Memory.new sections = Array(ACON::Output::Section).new output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output.overwrite "foo" expected << "foo" << EOL output.clear expected << "\e[1A\e[0J" output.overwrite "biz", "baz" expected << "biz" << EOL << "baz" << EOL @io.to_s.should eq expected.to_s end def test_overwrite : Nil sections = Array(ACON::Output::Section).new output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output.puts "Foo" output.overwrite "Bar" @io.to_s.should eq "Foo#{EOL}\e[1A\e[0JBar#{EOL}" end def test_overwrite_multiple_lines : Nil sections = Array(ACON::Output::Section).new output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output.puts "Foo#{EOL}Bar#{EOL}Baz" output.overwrite "Bar" @io.to_s.should eq "Foo#{EOL}Bar#{EOL}Baz#{EOL}\e[3A\e[0JBar#{EOL}" end def test_overwrite_multiple_section_output : Nil sections = Array(ACON::Output::Section).new output1 = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output2 = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output1.puts "Foo" output2.puts "Bar" output1.overwrite "Baz" output2.overwrite "Foobar" @io.to_s.should eq "Foo#{EOL}Bar#{EOL}\e[2A\e[0JBar#{EOL}\e[1A\e[0JBaz#{EOL}Bar#{EOL}\e[1A\e[0JFoobar#{EOL}" end def test_max_height : Nil expected = IO::Memory.new sections = Array(ACON::Output::Section).new output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output.max_height = 3 # Fill the section output.puts({"One", "Two", "Three"}) expected << "One" << EOL << "Two" << EOL << "Three" << EOL # Cause overflow that'll redraw whole section, without the first line output.puts "Four" expected << "\e[3A\e[0J" expected << "Two" << EOL << "Three" << EOL << "Four" << EOL # Cause overflow with multiple new lines at once output.puts "Five#{EOL}Six" expected << "\e[3A\e[0J" expected << "Four" << EOL << "Five" << EOL << "Six" << EOL # Reset line height that'll redraw whole section, displaying all lines output.max_height = nil expected << "\e[3A\e[0J" expected << "One" << EOL << "Two" << EOL << "Three" << EOL expected << "Four" << EOL << "Five" << EOL << "Six" << EOL @io.to_s.should eq expected.to_s end def test_max_height_multiple_sections : Nil expected = IO::Memory.new sections = Array(ACON::Output::Section).new output1 = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output1.max_height = 3 output2 = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output2.max_height = 3 # Fill the first section output1.puts({"One", "Two", "Three"}) expected << "One" << EOL << "Two" << EOL << "Three" << EOL # Fill the second section output2.puts({"One", "Two", "Three"}) expected << "One" << EOL << "Two" << EOL << "Three" << EOL # Cause overflow on second section that'll redraw whole section, without the first line output2.puts "Four" expected << "\e[3A\e[0J" expected << "Two" << EOL << "Three" << EOL << "Four" << EOL # Cause overflow on first section that'll redraw whole section, without the first line output1.puts "Four#{EOL}Five#{EOL}Six" expected << "\e[6A\e[0J" expected << "Four" << EOL << "Five" << EOL << "Six" << EOL expected << "Two" << EOL << "Three" << EOL << "Four" << EOL @io.to_s.should eq expected.to_s end def test_max_height_without_new_line : Nil expected = IO::Memory.new sections = Array(ACON::Output::Section).new output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output.max_height = 3 # Fill the section output.puts({"One", "Two"}) output.print "Three" expected << "One" << EOL << "Two" << EOL << "Three" << EOL # Append text to the last line output.print " and Four" expected << "\e[1A\e[0J" << "Three and Four" << EOL @io.to_s.should eq expected.to_s end def test_write_without_new_line : Nil sections = Array(ACON::Output::Section).new output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output.print "Foo#{EOL}" output.print "Bar" @io.to_s.should eq "Foo#{EOL}Bar#{EOL}" end def test_write_multiple_sections_output_without_new_lines : Nil expected = IO::Memory.new sections = Array(ACON::Output::Section).new output1 = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output2 = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new output1.print "Foo" expected << "Foo" << EOL output2.puts "Bar" expected << "Bar" << EOL output1.puts " is not foo." expected << "\e[2A\e[0JFoo is not foo." << EOL << "Bar" << EOL output2.print "Baz" expected << "Baz" << EOL output2.print "bar" expected << "\e[1A\e[0JBazbar" << EOL output2.puts "" expected << "\e[1A\e[0JBazbar" << EOL output2.puts "" expected << EOL output2.puts "Done." expected << "Done." << EOL @io.to_s.should eq expected.to_s end end ================================================ FILE: src/components/console/spec/output/io_spec.cr ================================================ require "../spec_helper" struct IOTest < ASPEC::TestCase @io : IO::Memory def initialize @io = IO::Memory.new end def tear_down : Nil @io.clear end def test_do_write : Nil output = ACON::Output::IO.new @io output.puts "foo" output.print "bar" output.to_s.should eq "foo#{EOL}bar" end def test_do_write_var_args : Nil output = ACON::Output::IO.new @io output.puts "foo", "bar" output.print "biz", "baz" output.to_s.should eq "foo#{EOL}bar#{EOL}bizbaz" end def test_decorated_dumb_term : Nil with_isolated_env do ENV["TERM"] = "dumb" ACON::Output::IO.new(@io).decorated?.should be_false end end def test_decorated_no_color : Nil with_isolated_env do ENV["NO_COLOR"] = "true" ENV["COLORTERM"] = "truecolor" ACON::Output::IO.new(@io).decorated?.should be_false end end def test_decorated_no_color_empty : Nil with_isolated_env do ENV["NO_COLOR"] = "" ENV["COLORTERM"] = "truecolor" ACON::Output::IO.new(@io).decorated?.should be_true end end def test_decorated_force_color : Nil with_isolated_env do ENV["FORCE_COLOR"] = "true" ACON::Output::IO.new(@io).decorated?.should be_true end end def test_decorated_force_color_empty : Nil with_isolated_env do ENV["FORCE_COLOR"] = "" ACON::Output::IO.new(@io).decorated?.should be_false end end def test_decorated_supported_term : Nil with_isolated_env do ENV["TERM"] = "xterm-256color" ACON::Output::IO.new(@io).decorated?.should be_true end end def test_decorated_colorterm : Nil with_isolated_env do ENV["COLORTERM"] = "truecolor" ACON::Output::IO.new(@io).decorated?.should be_true end end def test_decorated_ansicon : Nil with_isolated_env do ENV["ANSICON"] = "1" ACON::Output::IO.new(@io).decorated?.should be_true end end def test_decorated_conemuansi : Nil with_isolated_env do ENV["ConEmuANSI"] = "ON" ACON::Output::IO.new(@io).decorated?.should be_true end end def test_decorated_term_program_hyper : Nil with_isolated_env do ENV["TERM_PROGRAM"] = "Hyper" ACON::Output::IO.new(@io).decorated?.should be_true end end def test_decorated_term_program_non_hyper : Nil with_isolated_env do ENV["TERM_PROGRAM"] = "WezTerm" ACON::Output::IO.new(@io).decorated?.should be_false end end end ================================================ FILE: src/components/console/spec/output/null_spec.cr ================================================ require "../spec_helper" struct NullSpec < ASPEC::TestCase def test_verbosity : Nil ACON::Output::Null.new.verbosity.silent?.should be_true end def test_formatter : Nil output = ACON::Output::Null.new output.formatter.should be_a ACON::Formatter::Null output.formatter = ACON::Formatter::Output.new output.formatter.should be_a ACON::Formatter::Null end end ================================================ FILE: src/components/console/spec/output/output_spec.cr ================================================ require "../spec_helper" private class MockOutput < ACON::Output getter output : String = "" def clear : Nil @output = "" end protected def do_write(message : String, new_line : Bool) : Nil @output += message @output += "\n" if new_line end end struct OutputTest < ASPEC::TestCase def test_write_verbosity_quiet : Nil output = MockOutput.new :quiet output.puts "foo" output.output.should be_empty end def test_write_array_messages : Nil output = MockOutput.new output.puts ["foo", "bar"] output.output.should eq "foo\nbar\n" end @[DataProvider("message_provider")] def test_write_raw_message(message : String, output_type : ACON::Output::Type, expected : String) : Nil output = MockOutput.new output.puts message, output_type: output_type output.output.should eq expected end def message_provider : Tuple { {"foo", ACON::Output::Type::RAW, "foo\n"}, {"foo", ACON::Output::Type::PLAIN, "foo\n"}, } end def test_write_non_decorated : Nil output = MockOutput.new output.decorated = false output.puts "foo" output.output.should eq "foo\n" end def test_write_decorated : Nil foo_style = ACON::Formatter::OutputStyle.new :yellow, :red, :blink output = MockOutput.new output.formatter.has_style?("FOO").should be_false output.formatter.set_style "FOO", foo_style output.formatter.has_style?("FOO").should be_true output.decorated = true output.puts "foo" output.output.should eq "\e[33;41;5mfoo\e[39;49;25m\n" end def test_write_decorated_invalid_style : Nil output = MockOutput.new output.puts "foo" output.output.should eq "foo\n" end @[DataProvider("verbosity_provider")] def test_write_with_verbosity(verbosity : ACON::Output::Verbosity, expected : String) : Nil output = MockOutput.new output.verbosity = verbosity output.print "1" output.print "2", :quiet output.print "3", :normal output.print "4", :verbose output.print "5", :very_verbose output.print "6", :debug output.output.should eq expected end def verbosity_provider : Tuple { {ACON::Output::Verbosity::SILENT, ""}, {ACON::Output::Verbosity::QUIET, "2"}, {ACON::Output::Verbosity::NORMAL, "123"}, {ACON::Output::Verbosity::VERBOSE, "1234"}, {ACON::Output::Verbosity::VERY_VERBOSE, "12345"}, {ACON::Output::Verbosity::DEBUG, "123456"}, } end end ================================================ FILE: src/components/console/spec/question/choice_spec.cr ================================================ require "../spec_helper" struct ChoiceQuestionTest < ASPEC::TestCase def test_new_empty_choices : Nil expect_raises ACON::Exception::Logic, "Choice questions must have at least 1 choice available." do ACON::Question::Choice.new "A question", Array(String).new end end def test_custom_validator : Nil question = ACON::Question::Choice.new( "A question", [ "First response", "Second response", "Third response", "Fourth response", ] ) question.validator do "FOO" end {"First response", "First response ", " First response", " First response "}.each do |answer| if validator = question.validator actual = validator.call answer actual.should eq "FOO" end end end def test_validator_exact_match : Nil question = ACON::Question::Choice.new( "A question", [ "First response", "Second response", "Third response", "Fourth response", ] ) {"First response", "First response ", " First response", " First response "}.each do |answer| if validator = question.validator validator.call(answer).should eq "First response" end end end def test_validator_index_match : Nil question = ACON::Question::Choice.new( "A question", [ "First response", "Second response", "Third response", "Fourth response", ] ) {"0"}.each do |answer| if validator = question.validator validator.call(answer).should eq "First response" end end end def test_non_trimmable : Nil question = ACON::Question::Choice.new( "A question", [ "First response ", " Second response", " Third response ", ] ) question.trimmable = false if validator = question.validator validator.not_nil!.call(" Third response ").should eq " Third response " end end @[DataProvider("hash_choice_provider")] def test_validator_hash_choices(answer : String, expected : String) : Nil question = ACON::Question::Choice.new( "A question", { "0" => "First choice", "foo" => "Foo", "99" => "N°99", } ) if validator = question.validator validator.call(answer).should eq expected end end def hash_choice_provider : Hash { "'0' choice by key" => {"0", "First choice"}, "'0' choice by value" => {"First choice", "First choice"}, "select by key" => {"foo", "Foo"}, "select by value" => {"Foo", "Foo"}, "select by key, numeric key" => {"99", "N°99"}, "select by value, numeric key" => {"N°99", "N°99"}, } end end ================================================ FILE: src/components/console/spec/question/confirmation_spec.cr ================================================ require "../spec_helper" struct ConfirmationQuestionTest < ASPEC::TestCase @[DataProvider("normalizer_provider")] def test_default_regex(default : Bool, answers : Array, expected : Bool) : Nil question = ACON::Question::Confirmation.new "A question", default answers.each do |answer| normalizer = question.normalizer.not_nil! actual = normalizer.call answer actual.should eq expected end end def normalizer_provider : Tuple { { true, ["y", "Y", "yes", "YES", "yEs", ""], true, }, { true, ["n", "N", "no", "NO", "nO", "foo", "1", "0"], false, }, { false, ["y", "Y", "yes", "YES", "yEs"], true, }, { false, ["n", "N", "no", "NO", "nO", "foo", "1", "0", ""], false, }, } end end ================================================ FILE: src/components/console/spec/question/multiple_choice_spec.cr ================================================ require "../spec_helper" struct MultipleChoiceQuestionTest < ASPEC::TestCase def test_new_empty_choices : Nil expect_raises ACON::Exception::Logic, "Choice questions must have at least 1 choice available." do ACON::Question::MultipleChoice.new "A question", Array(String).new end end def test_non_trimmable : Nil question = ACON::Question::MultipleChoice(String).new( "A question", [ "First response ", " Second response", " Third response ", ] ) question.trimmable = false question.validator.not_nil!.call("First response , Second response").should eq ["First response ", " Second response"] end @[DataProvider("hash_choice_provider")] def test_validator_hash_choices(answer : String, expected : Array) : Nil question = ACON::Question::MultipleChoice.new( "A question", { "0" => "First choice", "foo" => "Foo", "99" => "N°99", } ) question.validator.not_nil!.call(answer).should eq expected end def hash_choice_provider : Hash { "'0' choice by key - multiple" => {"0,Foo", ["First choice", "Foo"]}, "'0' choice by key- single" => {"foo", ["Foo"]}, "select by value, numeric key" => {"N°99,foo,First choice", ["N°99", "Foo", "First choice"]}, } end end ================================================ FILE: src/components/console/spec/question/question_spec.cr ================================================ require "../spec_helper" private class QuestionCommand < ACON::Command protected def configure : Nil self .name("question:command") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status name = self.helper(ACON::Helper::Question).ask input, output, ACON::Question(String?).new "What is your name?", nil output.puts "Your name is: #{name}" ACON::Command::Status::SUCCESS end end struct QuestionTest < ASPEC::TestCase @question : ACON::Question(String?) def initialize @question = ACON::Question(String?).new "Test Question", nil end @[Tags("compiled")] def test_nil_generic_arg : Nil ASPEC::Methods.assert_compile_time_error "An ACON::Question generic argument cannot be 'Nil'. Use 'String?' instead.", <<-CR require "../spec_helper.cr" ACON::Question(Nil).new "Nil Question", nil CR end def test_default : Nil @question.default.should be_nil default = ACON::Question(String).new("Test Question", "FOO").default default.should eq "FOO" typeof(default).should eq String end def test_hidden_autocompleter_callback : Nil @question.autocompleter_callback do [] of String end expect_raises ACON::Exception::Logic, "A hidden question cannot use the autocompleter" do @question.hidden = true end end @[DataProvider("autocompleter_values_provider")] def test_get_set_autocompleter_values(values : Indexable | Hash, expected : Array(String)) : Nil @question.autocompleter_values = values @question.autocompleter_values.should eq expected end def autocompleter_values_provider : Hash { "tuple" => { {"a", "b", "c"}, ["a", "b", "c"], }, "array" => { ["a", "b", "c"], ["a", "b", "c"], }, "string key hash" => { {"a" => "b", "c" => "d"}, ["a", "c", "b", "d"], }, "int key hash" => { {0 => "b", 1 => "d"}, ["b", "d"], }, } end def test_custom_normalizer : Nil question = ACON::Question(String).new "A question", "" question.normalizer do |val| val.upcase end if normalizer = question.normalizer normalizer.call("foo").should eq "FOO" end end def test_with_inputs : Nil command = QuestionCommand.new command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new tester = ACON::Spec::CommandTester.new command tester.inputs "Jim" tester.execute tester.display.should eq "What is your name?Your name is: Jim#{EOL}" end end ================================================ FILE: src/components/console/spec/spec_helper.cr ================================================ require "spec" require "../src/athena-console" require "../src/spec" require "athena-spec" require "athena-clock/spec" require "./fixtures/commands/io" require "./fixtures/**" # Spec by default disables colorize with `TERM=dumb`. # Override that given there are specs based on ansi output. Colorize.enabled = true struct MockCommandLoader include Athena::Console::Loader::Interface def initialize( *, @command_or_exception : ACON::Command | ::Exception? = nil, @has : Bool = true, @names : Array(String) | ::Exception = [] of String, ) end def get(name : String) : ACON::Command case v = @command_or_exception in ::Exception then raise v in ACON::Command then v in Nil then raise "BUG: no command or exception was set" end end def has?(name : String) : Bool @has end def names : Array(String) case v = @names in ::Exception then raise v in Array(String) then v end end end def with_isolated_env(&) : Nil old_values = ENV.to_h begin ENV.clear yield ensure ENV.clear old_values.each do |key, old_value| ENV[key] = old_value end end end ASPEC.run_all ================================================ FILE: src/components/console/spec/style/athena_style_spec.cr ================================================ require "../spec_helper" struct AthenaStyleTest < ASPEC::TestCase def initialize ENV["COLUMNS"] = "121" end def tear_down : Nil ENV.delete "COLUMNS" end private def assert_file_equals_string(filepath : String, string : String, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil normalized_path = File.join __DIR__, "..", "fixtures", filepath string.should match(Regex.new(File.read(normalized_path).gsub EOL, "\n")), file: file, line: line end def test_error_style : Nil error_output = ACON::Output::IO.new io = IO::Memory.new output = ACON::Output::ConsoleOutput.new output.stderr = error_output style = ACON::Style::Athena.new ACON::Input::Hash.new({} of String => String), output style.error_style.puts "foo" io.to_s.should eq "foo#{EOL}" end def test_error_style_non_console_output : Nil output = ACON::Output::IO.new io = IO::Memory.new style = ACON::Style::Athena.new ACON::Input::Hash.new({} of String => String), output style.error_style.puts "foo" io.to_s.should eq "foo#{EOL}" end @[DataProvider("output_provider")] def test_outputs(command_proc : ACON::Commands::Generic::Proc, file_path : String) : Nil command = ACON::Commands::Generic.new "foo", &command_proc tester = ACON::Spec::CommandTester.new command tester.execute interactive: false, decorated: false self.assert_file_equals_string file_path, tester.display true end def output_provider : Hash { "Single blank line at start with block element" => { (ACON::Commands::Generic::Proc.new do |input, output| ACON::Style::Athena.new(input, output).caution "Lorem ipsum dolor sit amet" ACON::Command::Status::SUCCESS end), "style/block.txt", }, "Single blank line between titles and blocks" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style.title "Title" style.warning "Lorem ipsum dolor sit amet" style.title "Title" ACON::Command::Status::SUCCESS end), "style/title_block.txt", }, "Single blank line between blocks" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style.warning "Warning" style.caution "Caution" style.error "Error" style.success "Success" style.note "Note" style.info "Info" style.block "Custom block", "CUSTOM", style: "fg=white;bg=green", prefix: "X ", padding: true ACON::Command::Status::SUCCESS end), "style/blocks.txt", }, "Single blank line between titles" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style.title "First title" style.title "Second title" ACON::Command::Status::SUCCESS end), "style/titles.txt", }, "Single blank line after any text and a title" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style.print "Lorem ipsum dolor sit amet" style.title "First title" style.puts "Lorem ipsum dolor sit amet" style.title "Second title" style.print "Lorem ipsum dolor sit amet" style.print "" style.title "Third title" # Handle edge case by appending empty strings to history style.print "Lorem ipsum dolor sit amet" style.print({"", "", ""}) style.title "Fourth title" # Ensure manual control over number of blank lines style.puts "Lorem ipsum dolor sit amet" style.puts({"", ""}) # Should print 1 extra newline style.title "Fifth title" style.puts "Lorem ipsum dolor sit amet" style.new_line 2 # Should print 1 extra newline style.title "Sixth title" ACON::Command::Status::SUCCESS end), "style/titles_text.txt", }, "Proper line endings before outputting a text block" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style.puts "Lorem ipsum dolor sit amet" style.listing "Lorem ipsum dolor sit amet", "consectetur adipiscing elit" # When using print style.print "Lorem ipsum dolor sit amet" style.listing "Lorem ipsum dolor sit amet", "consectetur adipiscing elit" style.print "Lorem ipsum dolor sit amet" style.text({"Lorem ipsum dolor sit amet", "consectetur adipiscing elit"}) style.new_line style.print "Lorem ipsum dolor sit amet" style.comment({"Lorem ipsum dolor sit amet", "consectetur adipiscing elit"}) ACON::Command::Status::SUCCESS end), "style/block_line_endings.txt", }, "Proper blank line after text block with block" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style.listing "Lorem ipsum dolor sit amet", "consectetur adipiscing elit" style.success "Lorem ipsum dolor sit amet" ACON::Command::Status::SUCCESS end), "style/text_block_blank_line.txt", }, "Questions do not output anything when input is non-interactive" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style.title "Title" style.ask_hidden "Hidden question" style.choice "Choice question with default", {"choice1", "choice2"}, "choice1" style.confirm "Confirmation with yes default", true style.text "Duis aute irure dolor in reprehenderit in voluptate velit esse" ACON::Command::Status::SUCCESS end), "style/non_interactive_question.txt", }, "non-hidden questions do not output anything when input is non-interactive" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style.title "Title" style.ask "Hidden question", nil style.choice "Choice question with default", {"choice1", "choice2"}, "choice1" style.confirm "Confirmation with yes default", true style.text "Duis aute irure dolor in reprehenderit in voluptate velit esse" ACON::Command::Status::SUCCESS end), "style/non_interactive_question.txt", }, # TODO: Test table formatting with multiple headers + TableCell "Lines are aligned to the beginning of the first line in a multi-line block" => { (ACON::Commands::Generic::Proc.new do |input, output| ACON::Style::Athena.new(input, output).block({"Custom block", "Second custom block line"}, "CUSTOM", style: "fg=white;bg=green", prefix: "X ", padding: true) ACON::Command::Status::SUCCESS end), "style/multi_line_block.txt", }, "Lines are aligned to the beginning of the first line in a very long line block" => { (ACON::Commands::Generic::Proc.new do |input, output| ACON::Style::Athena.new(input, output).block( "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", "CUSTOM", style: "fg=white;bg=green", prefix: "X ", padding: true ) ACON::Command::Status::SUCCESS end), "style/long_line_block.txt", }, "Long lines are wrapped within a block" => { (ACON::Commands::Generic::Proc.new do |input, output| ACON::Style::Athena.new(input, output).block( "Lopadotemachoselachogaleokranioleipsanodrimhypotrimmatosilphioparaomelitokatakechymenokichlepikossyphophattoperisteralektryonoptekephalliokigklopeleiolagoiosiraiobaphetraganopterygon", "CUSTOM", style: "fg=white;bg=green", prefix: " § ", ) ACON::Command::Status::SUCCESS end), "style/long_line_block_wrapping.txt", }, "Lines are aligned to the first line and start with '//' in a very long line comment" => { (ACON::Commands::Generic::Proc.new do |input, output| ACON::Style::Athena.new(input, output).comment( "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum" ) ACON::Command::Status::SUCCESS end), "style/long_line_comment.txt", }, "Nested tags have no effect on the color of the '//' prefix" => { (ACON::Commands::Generic::Proc.new do |input, output| output.decorated = true ACON::Style::Athena.new(input, output).comment( "Árvíztűrőtükörfúrógép 🎼 Lorem ipsum dolor sit 💕 amet, consectetur adipisicing elit, sed do eiusmod tempor incididu labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum" ) ACON::Command::Status::SUCCESS end), "style/long_line_comment_decorated.txt", }, "Block behaves properly with a prefix and without type" => { (ACON::Commands::Generic::Proc.new do |input, output| ACON::Style::Athena.new(input, output).block( "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", prefix: "$ " ) ACON::Command::Status::SUCCESS end), "style/block_prefix_no_type.txt", }, "Block behaves properly with a type and without prefix" => { (ACON::Commands::Generic::Proc.new do |input, output| ACON::Style::Athena.new(input, output).block( "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", type: "TEST" ) ACON::Command::Status::SUCCESS end), "style/block_no_prefix_type.txt", }, "Block output is properly formatted with even padding lines" => { (ACON::Commands::Generic::Proc.new do |input, output| output.decorated = true ACON::Style::Athena.new(input, output).success( "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", ) ACON::Command::Status::SUCCESS end), "style/block_padding.txt", }, "Handles trailing backslashes" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style.title "Title ending with \\" style.section "Section ending with \\" ACON::Command::Status::SUCCESS end), "style/backslashes.txt", }, "definition list" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style .definition_list( {"foo" => "bar"}, ACON::Helper::Table::Separator.new, "this is a title", ACON::Helper::Table::Separator.new, {"foo2" => "bar2"} ) ACON::Command::Status::SUCCESS end), "style/definition_list.txt", }, "horizontal table" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style.horizontal_table(["a", "b", "c", "d"], [[1, 2, 3], [4, 5], [7, 8, 9]]) ACON::Command::Status::SUCCESS end), "style/horizontal_table.txt", }, "Closing tag is only applied once" => { (ACON::Commands::Generic::Proc.new do |input, output| output.decorated = true style = ACON::Style::Athena.new input, output style.print "do you want something" style.puts "?" ACON::Command::Status::SUCCESS end), "style/closing_tag.txt", }, # TODO: Enable this test case when multi width char support is added. # "Emojis don't make the line longer than expected" => { # (ACON::Commands::Generic::Proc.new do |input, output| # style = ACON::Style::Athena.new input, output # style.success "Lorem ipsum dolor sit amet" # style.success "Lorem ipsum dolor sit amet with one emoji 🎉" # style.success "Lorem ipsum dolor sit amet with so many of them 👩‍🌾👩‍🌾👩‍🌾👩‍🌾👩‍🌾" # ACON::Command::Status::SUCCESS # end), # "style/emojis.txt", # }, "Nested tags have no effect on color of the '//' prefix" => { (ACON::Commands::Generic::Proc.new do |input, output| output.decorated = true ACON::Style::Athena.new(input, output).block( "Árvíztűrőtükörfúrógép Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", type: "★", prefix: " ║ ", escape: false ) ACON::Command::Status::SUCCESS end), "style/nested_tag_prefix.txt", }, "Do not prepend empty line if the buffer is empty" => { (ACON::Commands::Generic::Proc.new do |input, output| style = ACON::Style::Athena.new input, output style.text "Hello" ACON::Command::Status::SUCCESS end), "style/empty_buffer.txt", }, } end def test_create_table : Nil command = ACON::Commands::Generic.new "foo" do |input, output| output.decorated = true style = ACON::Style::Athena.new input, output style .create_table .headers(["Foo", "Bar"]) .rows([["Biz", "Baz"], [12, false]]) .render style.new_line ACON::Command::Status::SUCCESS end tester = ACON::Spec::CommandTester.new command tester.execute interactive: false, decorated: false self.assert_file_equals_string "style/table.txt", tester.display true end def test_table : Nil command = ACON::Commands::Generic.new "foo" do |input, output| output.decorated = true style = ACON::Style::Athena.new input, output style.table ["Foo", "Bar"], [["Biz", "Baz"], [12, false]] ACON::Command::Status::SUCCESS end tester = ACON::Spec::CommandTester.new command tester.execute interactive: false, decorated: false self.assert_file_equals_string "style/table.txt", tester.display true end def test_horizontal_table : Nil command = ACON::Commands::Generic.new "foo" do |input, output| output.decorated = true style = ACON::Style::Athena.new input, output style.horizontal_table ["Foo", "Bar"], [["Biz", "Baz"], [12, false]] ACON::Command::Status::SUCCESS end tester = ACON::Spec::CommandTester.new command tester.execute interactive: false, decorated: false self.assert_file_equals_string "style/table_horizontal.txt", tester.display true end def test_vertical_table : Nil command = ACON::Commands::Generic.new "foo" do |input, output| output.decorated = true style = ACON::Style::Athena.new input, output style.vertical_table ["Foo", "Bar"], [["Biz", "Baz"], [12, false]] ACON::Command::Status::SUCCESS end tester = ACON::Spec::CommandTester.new command tester.execute interactive: false, decorated: false self.assert_file_equals_string "style/table_vertical.txt", tester.display true end def test_customize_formatter : Nil output = ACON::Output::IO.new IO::Memory.new style = ACON::Style::Athena.new ACON::Input::Hash.new({} of String => String), output style.formatter = ACON::Formatter::Null.new end def test_progress_bar : Nil output = ACON::Output::IO.new IO::Memory.new style = ACON::Style::Athena.new ACON::Input::Hash.new({} of String => String), output style.progress_start 123 bar = style.progress_bar.not_nil! bar.max_steps.should eq 123 bar.progress.should eq 0 style.progress_advance 4 bar.progress.should eq 4 style.progress_finish style.progress_iterate([1, 2, 3]) { } output = output.to_s output.should contain "0/123" output.should contain "123/123" output.should contain "0/3" output.should contain "3/3" end end ================================================ FILE: src/components/console/spec/terminal_spec.cr ================================================ require "./spec_helper" struct TerminalTest < ASPEC::TestCase @col_size : Int32? @line_size : Int32? def initialize @col_size = ENV["COLUMNS"]?.try &.to_i? @line_size = ENV["LINES"]?.try &.to_i? end def tear_down : Nil ENV.delete "COLUMNS" ENV.delete "LINES" end def test_height_width : Nil ENV["COLUMNS"] = "100" ENV["LINES"] = "50" terminal = ACON::Terminal.new terminal.width.should eq 100 terminal.height.should eq 50 ENV["COLUMNS"] = "120" ENV["LINES"] = "60" terminal = ACON::Terminal.new terminal.width.should eq 120 terminal.height.should eq 60 terminal.size.should eq({120, 60}) end def test_zero_values : Nil ENV["COLUMNS"] = "0" ENV["LINES"] = "0" terminal = ACON::Terminal.new terminal.width.should eq 0 terminal.height.should eq 0 terminal.size.should eq({0, 0}) end end ================================================ FILE: src/components/console/src/annotations.cr ================================================ module Athena::Console::Annotations # Annotation containing metadata related to an `ACON::Command`. # This is the preferred way of configuring a command. # # ``` # @[ACONA::AsCommand("add", description: "Sums two numbers, optionally making making the sum negative")] # class AddCommand < ACON::Command # # ... # end # ``` # # ## Configuration # # Various fields can be used within this annotation to control various aspects of the command. # All fields are optional unless otherwise noted. # # ### name # # **Type:** `String` - **required** # # The name of the command. # May be provided as either an explicit named argument, or the first positional argument. # See `ACON::Command#name`. # # ### description # # **Type:** `String` # # A short sentence describing the function of the command. # See `ACON::Command#description`. # # ### hidden # # **Type:** `Bool` # # If this command should be hidden from the command list. # See `ACON::Command#hidden?`. # # ### aliases # # **Type:** `Enumerable(String)` # # Alternate names this command may be invoked by. # See `ACON::Command#aliases`. annotation AsCommand; end end ================================================ FILE: src/components/console/src/application.cr ================================================ require "levenshtein" # A container for a collection of multiple `ACON::Command`, and serves as the entry point of a CLI application. # This class is optimized for a standard CLI environment; but it may be subclassed to provide a more specialized/customized entry point. # # ## Default Command # # The default command represents which command should be executed when no command name is provided; by default this is `ACON::Commands::List`. # For example, running `./console` would result in all the available commands being listed. # The default command can be customized via `#default_command`. # # ## Single Command Applications # # In some cases a CLI may only have one supported command in which passing the command's name each time is tedious. # In such a case an application may be declared as a single command application via the optional second argument to `#default_command`. # Passing `true` makes it so that any supplied arguments or options are passed to the default command. # # WARNING: Arguments and options passed to the default command are ignored when `#single_command?` is `false`. # # ## Custom Applications # # `ACON::Application` may also be extended in order to better fit a given application. # For example, it could define some [global custom styles][Athena::Console::Formatter::OutputStyleInterface--global-custom-styles], # override the array of default commands, or customize the default input options, etc. class Athena::Console::Application # Returns the version of this CLI application. getter version : String # Returns the name of this CLI application. getter name : String # By default, the application will auto [exit](https://crystal-lang.org/api/toplevel.html#exit(status=0):NoReturn-class-method) after executing a command. # This method can be used to disable that functionality. # # If set to `false`, the `ACON::Command::Status` of the executed command is returned from `#run`. # Otherwise the `#run` method never returns. # # ``` # application = ACON::Application.new "My CLI" # application.auto_exit = false # exit_status = application.run # exit_status # => ACON::Command::Status::SUCCESS # # application.auto_exit = true # exit_status = application.run # # # This line is never reached. # exit_status # ``` setter auto_exit : Bool = true # By default, the application will gracefully handle exceptions raised as part of the execution of a command # by formatting and outputting it; including varying levels of information depending on the `ACON::Output::Verbosity` level used. # # If set to `false`, that logic is bypassed and the exception is bubbled up to where `#run` was invoked from. # # ``` # application = ACON::Application.new "My CLI" # # application.register "foo" do |input, output, command| # output.puts %(Hello #{input.argument "name"}!) # # # Denote that this command has finished successfully. # ACON::Command::Status::SUCCESS # end.argument("name", :required) # # application.default_command "foo", true # application.catch_exceptions = false # # application.run # => Not enough arguments (missing: 'name'). (Athena::Console::Exception::Runtime) # ``` setter catch_exceptions : Bool = true # Allows setting the `ACON::Loader::Interface` that should be used by `self`. # See the related interface for more information. setter command_loader : ACON::Loader::Interface? = nil # Returns `true` if `self` only supports a single command. # See [Single Command Applications](/Console/Application/#Athena::Console::Application--single-command-applications) for more information. getter? single_command : Bool = false # When set to `true`, the application will check if `PROGRAM_NAME`'s basename matches a registered command. # If it does, that command will be used and any arguments will be passed to it. # # This enables symlink-based command invocation, where `./command-name` (symlinked to the main binary) will automatically execute the `command-name` command. # Any arguments are passed to the matched command rather than being interpreted as a command name. property? use_program_name_as_command : Bool = false # Returns/sets the `ACON::Helper::HelperSet` associated with `self`. # # The default helper set includes: # # * `ACON::Helper::Formatter` # * `ACON::Helper::Question` property helper_set : ACON::Helper::HelperSet { self.default_helper_set } @commands = Hash(String, ACON::Command).new @default_command : String = "list" @definition : ACON::Input::Definition? = nil @initialized : Bool = false @running_command : ACON::Command? = nil @terminal : ACON::Terminal @wants_help : Bool = false def initialize(@name : String, @version : String = "UNKNOWN") @terminal = ACON::Terminal.new # TODO: Emit events when certain signals are triggered. # This will require the ability to optional set an event dispatcher on this type. end # Adds the provided *command* instance to `self`, allowing it be executed. def add(command : ACON::Command) : ACON::Command? self.init command.application = self unless command.enabled? command.application = nil return nil end if !command.is_a? ACON::Commands::Lazy command.definition end @commands[command.name] = command command.aliases.each do |a| @commands[a] = command end command end # Returns if application should exit automatically after executing a command. # See `#auto_exit=`. def auto_exit? : Bool @auto_exit end # Returns if the application should handle exceptions raised within the execution of a command. # See `#catch_exceptions=`. def catch_exceptions? : Bool @catch_exceptions end # Returns all commands within `self`, optionally only including the ones within the provided *namespace*. # The keys of the returned hash represent the full command names, while the values are the command instances. def commands(namespace : String? = nil) : Hash(String, ACON::Command) self.init if namespace.nil? unless command_loader = @command_loader return @commands end commands = @commands.dup command_loader.names.each do |name| if !commands.has_key?(name) && self.has?(name) commands[name] = self.get name end end return commands end commands = Hash(String, ACON::Command).new @commands.each do |name, command| if namespace == self.extract_namespace(name, namespace.count(':') + 1) commands[name] = command end end if command_loader = @command_loader command_loader.names.each do |name| if !commands.has_key?(name) && namespace == self.extract_namespace(name, namespace.count(':') + 1) && self.has?(name) commands[name] = self.get name end end end commands end # Sets the [default command][Athena::Console::Application--default-command] to the command with the provided *name*. # # For example, executing the following console script via `./console` # would result in `Hello world!` being printed instead of the default list output. # # ``` # application = ACON::Application.new "My CLI" # # application.register "foo" do |_, output| # output.puts "Hello world!" # ACON::Command::Status::SUCCESS # end # # application.default_command "foo" # # application.run # # ./console # => Hello world! # ``` # # For example, executing the following console script via `./console George` # would result in `Hello George!` being printed. If we tried this again without setting *single_command* # to `true`, it would error saying `Command 'George' is not defined. # # ``` # application = ACON::Application.new "My CLI" # # application.register "foo" do |input, output, command| # output.puts %(Hello #{input.argument "name"}!) # ACON::Command::Status::SUCCESS # end.argument("name", :required) # # application.default_command "foo", true # # application.run # ``` def default_command(name : String, single_command : Bool = false) : self @default_command = name if single_command self.find name @single_command = true end self end # Returns the `ACON::Input::Definition` associated with `self`. # See the related type for more information. def definition : ACON::Input::Definition @definition ||= self.default_input_definition if self.single_command? input_definition = @definition.not_nil! input_definition.arguments = Array(ACON::Input::Argument).new return input_definition end @definition.not_nil! end # Sets the *definition* that should be used by `self`. # See the related type for more information. def definition=(@definition : ACON::Input::Definition) end # Determines what values should be added to the possible *suggestions* based on the provided *input*. # # By default this handles completing commands and options, but can be overridden if needed. def complete(input : ACON::Completion::Input, suggestions : ACON::Completion::Suggestions) : Nil if input.completion_type.argument_value? && "command" == input.completion_name self.commands.each do |name, command| next if command.hidden? || command.name != name suggestions.suggest_value name, command.description command.aliases.each do |a| suggestions.suggest_value a, command.description end end return end if input.completion_type.option_name? suggestions.suggest_options self.definition.options.values return end end # Yields each command within `self`, optionally only yields those within the provided *namespace*. def each_command(namespace : String? = nil, & : ACON::Command -> Nil) : Nil self.commands(namespace).each_value { |c| yield c } end # Returns the `ACON::Command` with the provided *name*, which can either be the full name, an abbreviation, or an alias. # This method will attempt to find the best match given an abbreviation of a name or alias. # # Raises an `ACON::Exception::CommandNotFound` exception when the provided *name* is incorrect or ambiguous. # # ameba:disable Metrics/CyclomaticComplexity def find(name : String) : ACON::Command self.init aliases = Hash(String, String).new @commands.each_value do |command| command.aliases.each do |a| @commands[a] = command unless self.has? a end end return self.get name if self.has? name all_command_names = if command_loader = @command_loader command_loader.names + @commands.keys else @commands.keys end expression = "#{name.split(':').join("[^:]*:", &->Regex.escape(String))}[^:]*" commands = all_command_names.select(/^#{expression}/) if commands.empty? commands = all_command_names.select(/^#{expression}/i) end if commands.empty? || commands.select(/^#{expression}$/i).size < 1 if pos = name.index ':' # Check if a namespace exists and contains commands self.find_namespace name[0...pos] end message = "Command '#{name}' is not defined." if (alternatives = self.find_alternatives name, all_command_names) && (!alternatives.empty?) alternatives.select! do |n| !self.get(n).hidden? end case alternatives.size when 1 then message += "\n\nDid you mean this?\n " else message += "\n\nDid you mean one of these?\n " end message += alternatives.join("\n ") end raise ACON::Exception::CommandNotFound.new message, alternatives end # Filter out aliases for commands which are already on the list. if commands.size > 1 command_list = @commands.dup commands.select! do |name_or_alias| command = if !command_list.has_key?(name_or_alias) command_list[name_or_alias] = @command_loader.not_nil!.get name_or_alias else command_list[name_or_alias] end command_name = command.name aliases[name_or_alias] = command_name command_name == name_or_alias || !commands.includes? command_name end.uniq! usable_width = @terminal.width - 10 max_len = commands.max_of &->ACON::Helper.width(String) abbreviations = commands.map do |n| if command_list[n].hidden? commands.delete n next nil end abbreviation = "#{n.rjust max_len, ' '} #{command_list[n].description}" ACON::Helper.width(abbreviation) > usable_width ? "#{abbreviation[0, usable_width - 3]}..." : abbreviation end if commands.size > 1 suggestions = self.abbreviation_suggestions abbreviations.compact raise ACON::Exception::CommandNotFound.new "Command '#{name}' is ambiguous.\nDid you mean one of these?\n#{suggestions}", commands end end command = self.get commands.first raise ACON::Exception::CommandNotFound.new "The command '#{name}' does not exist." if command.hidden? command end # Returns the full name of a registered namespace with the provided *name*, which can either be the full name or an abbreviation. # # Raises an `ACON::Exception::NamespaceNotFound` exception when the provided *name* is incorrect or ambiguous. def find_namespace(name : String) : String all_namespace_names = self.namespaces expression = "#{name.split(':').join("[^:]*:", &->Regex.escape(String))}[^:]*" namespaces = all_namespace_names.select(/^#{expression}/) if namespaces.empty? message = "There are no commands defined in the '#{name}' namespace." if (alternatives = self.find_alternatives name, all_namespace_names) && (!alternatives.empty?) case alternatives.size when 1 then message += "\n\nDid you mean this?\n " else message += "\n\nDid you mean one of these?\n " end message += alternatives.join("\n ") end raise ACON::Exception::NamespaceNotFound.new message, alternatives end exact = namespaces.includes? name if namespaces.size > 1 && !exact raise ACON::Exception::NamespaceNotFound.new "The namespace '#{name}' is ambiguous.\nDid you mean one of these?\n#{self.abbreviation_suggestions namespaces}", namespaces end exact ? name : namespaces.first end # Returns the `ACON::Command` with the provided *name*. # # Raises an `ACON::Exception::CommandNotFound` exception when a command with the provided *name* does not exist. def get(name : String) : ACON::Command self.init raise ACON::Exception::CommandNotFound.new "The command '#{name}' does not exist." unless self.has? name if !@commands.has_key? name raise ACON::Exception::CommandNotFound.new "The '#{name}' command cannot be found because it is registered under multiple names. Make sure you don't set a different name via constructor or 'name='." end command = @commands[name] if @wants_help @wants_help = false help_command = self.get "help" help_command.as(ACON::Commands::Help).command = command return help_command end command end # Returns `true` if a command with the provided *name* exists, otherwise `false`. def has?(name : String) : Bool self.init return true if @commands.has_key? name if (command_loader = @command_loader) && command_loader.has? name self.add command_loader.get name true else false end end # By default this is the same as `#long_version`, but can be overridden # to provide more in-depth help/usage instructions for `self`. def help : String self.long_version end # Returns all unique namespaces used by currently registered commands, # excluding the global namespace. def namespaces : Array(String) namespaces = [] of String self.commands.each_value do |command| next if command.hidden? namespaces.concat self.extract_all_namespaces command.name.not_nil! command.aliases.each do |a| namespaces.concat self.extract_all_namespaces a end end namespaces.reject!(&.blank?).uniq! end # Runs the current application, optionally with the provided *input* and *output*. # # Returns the `ACON::Command::Status` of the related command execution if `#auto_exit?` is `false`. # Will gracefully handle exceptions raised within the command execution unless `#catch_exceptions?` is `false`. def run(input : ACON::Input::Interface = ACON::Input::ARGV.new, output : ACON::Output::Interface = ACON::Output::ConsoleOutput.new) : ACON::Command::Status | NoReturn ENV["LINES"] = @terminal.height.to_s ENV["COLUMNS"] = @terminal.width.to_s self.configure_io input, output begin exit_code = self.do_run input, output rescue ex : ::Exception raise ex unless @catch_exceptions self.render_exception ex, output exit_code = if ex.is_a? ACON::Exception ACON::Command::Status.new ex.code else ACON::Command::Status::FAILURE end end if @auto_exit exit exit_code.value end exit_code end # Creates and `#add`s an `ACON::Command` with the provided *name*; executing the block when the command is invoked. def register(name : String, &block : ACON::Input::Interface, ACON::Output::Interface, ACON::Command -> ACON::Command::Status) : ACON::Command self.add(ACON::Commands::Generic.new(name, &block)).not_nil! end # Returns the `#name` and `#version` of the application. # Used when the `-V` or `--version` option is passed. def long_version : String "#{@name} #{@version}" end protected def command_name(input : ACON::Input::Interface) : String? return @default_command if @single_command if @use_program_name_as_command prog_name = self.program_name return prog_name if self.has?(prog_name) end input.first_argument end protected def program_name : String Path.new(PROGRAM_NAME).basename end # ameba:disable Metrics/CyclomaticComplexity protected def configure_io(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil if input.has_parameter? "--ansi", only_params: true output.decorated = true elsif input.has_parameter? "--no-ansi", only_params: true output.decorated = false end if input.has_parameter? "--no-interaction", "-n", only_params: true input.interactive = false end shell_verbosity = ENV["SHELL_VERBOSITY"]?.try(&.to_i) || 0 case shell_verbosity when -2 then output.verbosity = :silent when -1 then output.verbosity = :quiet when 1 then output.verbosity = :verbose when 2 then output.verbosity = :very_verbose when 3 then output.verbosity = :debug end if input.has_parameter? "--silent", only_params: true output.verbosity = :silent shell_verbosity = -2 elsif input.has_parameter? "--quiet", "-q", only_params: true output.verbosity = :quiet shell_verbosity = -1 else if input.has_parameter?("-vvv", "--verbose=3", only_params: true) || 3 == input.parameter("--verbose", false, true) output.verbosity = :debug shell_verbosity = 3 elsif input.has_parameter?("-vv", "--verbose=2", only_params: true) || 2 == input.parameter("--verbose", false, true) output.verbosity = :very_verbose shell_verbosity = 2 elsif input.has_parameter?("-v", "--verbose=1", only_params: true) || input.has_parameter?("--verbose") || input.parameter("--verbose", false, true) output.verbosity = :verbose shell_verbosity = 1 end end if 0 > shell_verbosity input.interactive = false end ENV["SHELL_VERBOSITY"] = shell_verbosity.to_s end # ameba:disable Metrics/CyclomaticComplexity protected def do_run(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status if input.has_parameter? "--version", "-V", only_params: true output.puts self.long_version return ACON::Command::Status::SUCCESS end begin input.bind self.definition rescue ex : ::Exception # TODO: Make this part of the `rescue` after Crystal 1.13 # Ignore errors as full binding/validation happens later when command is known raise ex unless ex.is_a? ACON::Exception end command_name = self.command_name input if input.has_parameter? "--help", "-h", only_params: true if command_name.nil? command_name = "help" input = ACON::Input::Hash.new(command_name: @default_command) else @wants_help = true end end if command_name.nil? command_name = @default_command definition = self.definition definition.arguments.merge!({ "command" => ACON::Input::Argument.new("command", :optional, definition.argument("command").description, command_name), }) end begin @running_command = nil command = self.find command_name rescue ex : ::Exception if (ex.is_a?(ACON::Exception::CommandNotFound) && !ex.is_a?(ACON::Exception::NamespaceNotFound)) && 1 == (alternatives = ex.alternatives).size && input.interactive? alternative = alternatives.not_nil!.first style = ACON::Style::Athena.new input, output output.puts "" output.puts ACON::Helper::Formatter.new.format_block "Command '#{command_name}' is not defined.", "error", true unless style.confirm "Do you want to run '#{alternative}' instead?", false # TODO: Handle dispatching return ACON::Command::Status::FAILURE end command = self.find alternative else # TODO: Handle dispatching begin if ex.is_a?(ACON::Exception::CommandNotFound) && (namespace = self.find_namespace command_name) ACON::Helper::Descriptor.new.describe( output.is_a?(ACON::Output::ConsoleOutputInterface) ? output.error_output : output, self, ACON::Descriptor::Context.new( format: "txt", raw_text: false, namespace: namespace, short: false ) ) return ACON::Command::Status::SUCCESS end raise ex rescue ACON::Exception::NamespaceNotFound raise ex end end end @running_command = command exit_status = self.do_run_command command, input, output @running_command = nil exit_status end protected def do_run_command(command : ACON::Command, input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # TODO: Support input aware helpers. # TODO: Handle registering signable command listeners. command.run input, output # TODO: Handle eventing. end protected def default_input_definition : ACON::Input::Definition ACON::Input::Definition.new( ACON::Input::Argument.new("command", :required, "The command to execute"), ACON::Input::Option.new("help", "h", description: "Display help for the given command. When no command is given display help for the #{@default_command} command"), ACON::Input::Option.new("silent", description: "Do not output any message"), ACON::Input::Option.new("quiet", "q", description: "Only errors are displayed. All other output is suppressed"), ACON::Input::Option.new("verbose", "v|vv|vvv", description: "Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug"), ACON::Input::Option.new("version", "V", description: "Display this application version"), ACON::Input::Option.new("ansi", value_mode: :negatable, description: "Force (or disable --no-ansi) ANSI output", default: false), ACON::Input::Option.new("no-interaction", "n", description: "Do not ask any interactive question"), ) end protected def default_commands : Array(ACON::Command) [ Athena::Console::Commands::Help.new, Athena::Console::Commands::List.new, Athena::Console::Commands::DumpCompletion.new, Athena::Console::Commands::Complete.new, ] end protected def default_helper_set : ACON::Helper::HelperSet ACON::Helper::HelperSet.new( ACON::Helper::Formatter.new, ACON::Helper::Question.new ) end # ameba:disable Metrics/CyclomaticComplexity protected def do_render_exception(ex : ::Exception, output : ACON::Output::Interface) : Nil loop do message = (ex.message || "").strip if message.empty? || ACON::Output::Verbosity::VERBOSE <= output.verbosity title = " [#{ex.class}] " len = ACON::Helper.width title else len = 0 title = "" end width = @terminal.width ? @terminal.width - 1 : Int32::MAX lines = [] of Tuple(String, Int32) message.split(/(?:\r?\n)/) do |line| self.split_string_by_width(line, width - 4) do |l| line_length = ACON::Helper.width(l) + 4 lines << {l, line_length} len = Math.max line_length, len end end messages = [] of String if !ex.is_a?(ACON::Exception) || ACON::Output::Verbosity::VERBOSE <= output.verbosity if trace = ex.backtrace?.try &.first filename = nil line = nil if match = trace.match(/(\w+\.cr):(\d+)/) filename = if f = match[1]? File.basename f end line = match[2]? end messages << %(#{ACON::Formatter::Output.escape "In #{filename || "n/a"} line #{line || "n/a"}:"}) end end messages << (empty_line = "#{" "*len}") if messages.empty? || ACON::Output::Verbosity::VERBOSE <= output.verbosity messages << "#{title}#{" "*(Math.max(0, len - ACON::Helper.width(title)))}" end lines.each do |l| messages << " #{ACON::Formatter::Output.escape l[0]} #{" "*(len - l[1])}" end messages << empty_line messages << "" messages.each do |m| output.puts m, :quiet end if (ACON::Output::Verbosity::VERBOSE <= output.verbosity) && (t = ex.backtrace?) output.puts "Exception trace:", :quiet # TODO: Improve backtrace rendering. t.each do |l| output.puts " #{l}" end output.puts "", :quiet end break unless ex = ex.cause end end protected def extract_namespace(name : String, limit : Int32? = nil) : String # Pop off the shortcut name of the command. parts = name.split(':').tap &.pop (limit.nil? ? parts : parts[0...limit]).join ':' end protected def render_exception(ex : ::Exception, output : ACON::Output::ConsoleOutputInterface) : Nil self.render_exception ex, output.error_output end protected def render_exception(ex : ::Exception, output : ACON::Output::Interface) : Nil output.puts "", :quiet self.do_render_exception ex, output if running_command = @running_command output.puts "#{ACON::Formatter::Output.escape running_command.synopsis}", :quiet output.puts "", :quiet end end private def abbreviation_suggestions(abbreviations : Array(String)) : String %( #{abbreviations.join("\n ")}) end private def extract_all_namespaces(name : String) : Array(String) # Pop off the shortcut name of the command. parts = name.split(':').tap &.pop namespaces = [] of String parts.each do |p| namespaces << if namespaces.empty? p else "#{namespaces.last}:#{p}" end end namespaces end # ameba:disable Metrics/CyclomaticComplexity private def find_alternatives(name : String, collection : Enumerable(String)) : Array(String) alternatives = Hash(String, Int32).new threshold = 1_000 collection_parts = Hash(String, Array(String)).new collection.each do |item| collection_parts[item] = item.split ':' end name.split(':').each_with_index do |sub_name, idx| collection_parts.each do |collection_name, parts| exists = alternatives.has_key? collection_name if exists && parts[idx]?.nil? alternatives[collection_name] += threshold next elsif parts[idx]?.nil? next end lev = Levenshtein.distance sub_name, parts[idx] if lev <= sub_name.size / 3 || !sub_name.empty? && parts[idx].includes? sub_name alternatives[collection_name] = exists ? alternatives[collection_name] + lev : lev elsif exists alternatives[collection_name] += threshold end end end collection.each do |item| lev = Levenshtein.distance name, item if lev <= name.size / 3 || item.includes? name alternatives[item] = (current = alternatives[item]?) ? current - lev : lev end end alternatives.select! { |_, lev| lev < 2 * threshold } alternatives.keys.sort! end private def init : Nil return if @initialized @initialized = true self.default_commands.each do |command| self.add command end end private def split_string_by_width(line : String, width : Int32, & : String -> Nil) : Nil if line.empty? return yield line end line.each_char.each_slice(width).map(&.join).each do |set| yield set end end end ================================================ FILE: src/components/console/src/athena-console.cr ================================================ require "ecr" require "semantic_version" require "athena-clock" require "./annotations" require "./application" require "./command" require "./cursor" require "./terminal" require "./commands/*" require "./completion/**" require "./descriptor/*" require "./exception/*" require "./formatter/*" require "./helper/*" require "./input/*" require "./loader/*" require "./output/*" require "./question/*" require "./style/*" # Convenience alias to make referencing `Athena::Console` types easier. alias ACON = Athena::Console # Convenience alias to make referencing `ACON::Annotations` types easier. alias ACONA = ACON::Annotations # Allows the creation of CLI based commands module Athena::Console VERSION = "0.4.3" # Contains all the `Athena::Console` based annotations. module Annotations; end # Includes the commands that come bundled with `Athena::Console`. module Commands; end # Includes types related to Athena's [tab completion][Athena::Console::Input::Interface--argumentoption-value-completion] features. module Completion; end # Both acts as a namespace for exceptions related to the `Athena::Console` component, as well as a way to check for exceptions from the component. # Exposes a `#code` method that represents the exit code of a command invocation. module Exception # Returns the exit code that should be used for this exception. getter code : Int32 end # Contains types related to lazily loading commands. module Loader; end end ================================================ FILE: src/components/console/src/command.cr ================================================ # An `ACON::Command` represents a concrete command that can be invoked via the CLI. # All commands should inherit from this base type, but additional abstract subclasses can be used # to share common logic for related command classes. # # ## Creating a Command # # A command is defined by extending `ACON::Command` and implementing the `#execute` method. # For example: # # ``` # @[ACONA::AsCommand("app:create-user")] # class CreateUserCommand < ACON::Command # protected def configure : Nil # # ... # end # # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # # Implement all the business logic here. # # # Indicates the command executed successfully. # ACON::Command::Status::SUCCESS # end # end # ``` # # ### Command Lifecycle # # Commands have three lifecycle methods that are invoked when running the command: # # 1. `setup` (optional) - Executed before `#interact` and `#execute`. Can be used to setup state based on input data. # 1. `interact` (optional) - Executed after `#setup` but before `#execute`. Can be used to check if any arguments/options are missing # and interactively ask the user for those values. After this method, missing arguments/options will result in an error. # 1. `execute` (required) - Contains the business logic for the command, returning the status of the invocation via `ACON::Command::Status`. # # ``` # @[ACONA::AsCommand("app:create-user")] # class CreateUserCommand < ACON::Command # protected def configure : Nil # # ... # end # # protected def setup(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil # # ... # end # # protected def interact(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil # # ... # end # # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # # Indicates the command executed successfully. # ACON::Command::Status::SUCCESS # end # end # ``` # # ## Configuring the Command # # In most cases, a command is going to need to be configured to better fit its purpose. # The `#configure` method can be used configure various aspects of the command, # such as its name, description, `ACON::Input`s, help message, aliases, etc. # # ``` # protected def configure : Nil # self # .help("Creates a user...") # Shown when running the command with the `--help` option # .aliases("new-user") # Alternate names for the command # .hidden # Hide the command from the list # # ... # end # ``` # # TIP: The suggested way of setting the name and description of the command is via the `ACONA::AsCommand` annotation. # This enables lazy command instantiation when used within the Athena framework. # # The `#configure` command is called automatically at the end of the constructor method. # If your command defines its own, be sure to call `super()` to also run the parent constructor. # `super` may also be called _after_ setting the properties if they should be used to determine how to configure the command. # # ``` # class CreateUserCommand < ACON::Command # def initialize(@require_password : Bool = false) # super() # end # # protected def configure : Nil # self # .argument("password", @require_password ? ACON::Input::Argument::Mode::REQUIRED : ACON::Input::Argument::Mode::OPTIONAL) # end # end # ``` # # ### Output # # The `#execute` method has access to an `ACON::Output::Interface` instance that can be used to write messages to display. # The `output` parameter should be used instead of `#puts` or `#print` to decouple the command from `STDOUT`. # # ``` # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # # outputs multiple lines to the console (adding "\n" at the end of each line) # output.puts([ # "User Creator", # "============", # "", # ]) # # # outputs a message followed by a "\n" # output.puts "Whoa!" # # # outputs a message without adding a "\n" at the end of the line # output.print "You are about to " # output.print "create a user." # # ACON::Command::Status::SUCCESS # end # ``` # # See `ACON::Output::Interface` for more information. # # ### Input # # In most cases, a command is going to have some sort of input arguments/options. # These inputs can be setup in the `#configure` method, and accessed via the *input* parameter within `#execute`. # # ``` # protected def configure : Nil # self # .argument("username", :required, "The username of the user") # end # # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # # Retrieve the username as a String? # output.puts %(Hello #{input.argument "username"}!) # # ACON::Command::Status::SUCCESS # end # ``` # # See `ACON::Input::Interface` for more information. # # ## Testing the Command # # `Athena::Console` also includes a way to test your console commands without needing to build and run a binary. # A single command can be tested via an `ACON::Spec::CommandTester` and a whole application can be tested via an `ACON::Spec::ApplicationTester`. # # See `ACON::Spec` for more information. abstract class Athena::Console::Command # Represents the execution status of an `ACON::Command`. # # The value of each member is used as the exit code of the invocation. # # TIP: The exit code may be customized by manually instantiating the enum with it. E.g. `Status.new 126`. enum Status # Represents a successful invocation with no errors. SUCCESS = 0 # Represents that some error happened during invocation. FAILURE = 1 # Represents the command was not used correctly, such as invalid options or missing arguments. INVALID = 2 end private enum Synopsis SHORT LONG end # Returns the default name of `self`, or `nil` if it was not set. def self.default_name : String? {% begin %} {% if ann = @type.annotation ACONA::AsCommand %} {% name = (ann[0] || ann[:name]) unless name ann.raise "Console command '#{@type}' has an 'ACONA::AsCommand' annotation but is missing the commands's name. It was not provided as the first positional argument nor via the 'name' field." end %} {% if !ann[:hidden] && !ann[:aliases] %} {{name}} {% else %} {% name = name.split '|' name = name + (ann[:aliases] || [] of Nil) if ann[:hidden] && "" != name[0] name.unshift "" end %} {{name.join '|'}} {% end %} {% end %} {% end %} end # Returns the default description of `self`, or `nil` if it was not set. def self.default_description : String? {% if ann = @type.annotation ACONA::AsCommand %} {{ann[:description]}} {% end %} end # Returns the name of `self`. getter! name : String # Returns the `description of `self`. getter description : String = "" # Returns/sets the help template for `self`. # # See `#processed_help`. property help : String = "" # Returns the `ACON::Application` associated with `self`, otherwise `nil`. getter! application : ACON::Application # Returns/sets the list of aliases that may also be used to execute `self` in addition to its `#name`. property aliases : Array(String) = [] of String # Returns/sets an `ACON::Helper::HelperSet` on `self`. property helper_set : ACON::Helper::HelperSet? = nil # Returns `true` if `self` is hidden from the command list, otherwise `false`. getter? hidden : Bool = false # Returns if `self` is enabled in the current environment. # # Can be overridden to return `false` if it cannot run under the current conditions. getter? enabled : Bool = true # Returns the list of usages for `self`. # # See `#usage`. getter usages : Array(String) = [] of String @definition : ACON::Input::Definition = ACON::Input::Definition.new @full_definition : ACON::Input::Definition? = nil @ignore_validation_errors : Bool = false @synopsis = Hash(Synopsis, String).new @process_title : String? = nil def initialize(name : String? = nil) if name.nil? && (n = self.class.default_name) aliases = n.split '|' if (name = aliases.shift).empty? self.hidden true name = aliases.shift? end self.aliases aliases end unless name.nil? self.name name end if (@description.empty?) && (description = self.class.default_description) self.description description end self.configure end # Sets the aliases of `self`. def aliases(*aliases : String) : self self.aliases aliases.to_a end # :ditto: def aliases(aliases : Enumerable(String)) : self aliases.each &->validate_name(String) @aliases = aliases self end def application=(@application : ACON::Application?) : Nil if application = @application @helper_set = application.helper_set else @helper_set = nil end @full_definition = nil end # Adds an `ACON::Input::Argument` to `self` with the provided *name*. # Optionally supports setting its *mode*, *description*, *default* value, and *suggested_values*. # # Also checkout the [value completion][Athena::Console::Input::Interface--argumentoption-value-completion] for how argument values can be auto completed. def argument( name : String, mode : ACON::Input::Argument::Mode = :optional, description : String = "", default = nil, suggested_values : Enumerable(String)? = nil, ) : self @definition << ACON::Input::Argument.new name, mode, description, default, suggested_values.try &.to_a if full_definition = @full_definition full_definition << ACON::Input::Argument.new name, mode, description, default, suggested_values.try &.to_a end self end # Adds an `ACON::Input::Argument` to this command with the provided *name*. # Optionally supports setting its *mode*, *description*, *default* value. # # Accepts a block to use to determine this argument's suggested values. # Also checkout the [value completion][Athena::Console::Input::Interface--argumentoption-value-completion] for how argument values can be auto completed. def argument( name : String, mode : ACON::Input::Argument::Mode = :optional, description : String = "", default = nil, &suggested_values : ACON::Completion::Input -> Array(String) ) : self @definition << ACON::Input::Argument.new name, mode, description, default, suggested_values if full_definition = @full_definition full_definition << ACON::Input::Argument.new name, mode, description, default, suggested_values end self end def definition : ACON::Input::Definition @full_definition || self.native_definition end # Sets the `ACON::Input::Definition` on self. def definition(@definition : ACON::Input::Definition) : self @full_definition = nil self end # :ditto: def definition(*definitions : ACON::Input::Argument | ACON::Input::Option) : self self.definition definitions.to_a end # :ditto: def definition(definition : Array(ACON::Input::Argument | ACON::Input::Option)) : self @definition.definition = definition @full_definition = nil self end # Sets the `#description` of `self`. def description(@description : String) : self self end def name(name : String) : self self.validate_name name @name = name self end # Sets the `#help` of `self`. def help(@help : String) : self self end # Returns an `ACON:Helper::Interface` of the provided *helper_class*. # # ``` # formatter = self.helper ACON::Helper::Formatter # # ... # ``` def helper(helper_class : T.class) : T forall T unless helper_set = @helper_set raise ACON::Exception::Logic.new "Cannot retrieve helper '#{helper_class}' because there is no `ACON::Helper::HelperSet` defined. Did you forget to add your command to the application or to set the application on the command using '#application='? You can also set the HelperSet directly using '#helper_set='." end helper_set[helper_class].as T end # Hides `self` from the command list. def hidden(@hidden : Bool = true) : self self end # Adds an `ACON::Input::Option` to `self` with the provided *name*. # Optionally supports setting its *shortcut*, *value_mode*, *description*, and *default* value. # # Also checkout the [value completion][Athena::Console::Input::Interface--argumentoption-value-completion] for how option values can be auto completed. def option( name : String, shortcut : String? = nil, value_mode : ACON::Input::Option::Value = :none, description : String = "", default = nil, suggested_values : Enumerable(String)? = nil, ) : self @definition << ACON::Input::Option.new name, shortcut, value_mode, description, default, suggested_values.try &.to_a if full_definition = @full_definition full_definition << ACON::Input::Option.new name, shortcut, value_mode, description, default, suggested_values.try &.to_a end self end # Adds an `ACON::Input::Option` to `self` with the provided *name*. # Optionally supports setting its *shortcut*, *value_mode*, *description*, and *default* value. # # Accepts a block to use to determine this argument's suggested values. # Also checkout the [value completion][Athena::Console::Input::Interface--argumentoption-value-completion] for how option values can be auto completed. def option( name : String, shortcut : String? = nil, value_mode : ACON::Input::Option::Value = :none, description : String = "", default = nil, &suggested_values : ACON::Completion::Input -> Array(String) ) : self @definition << ACON::Input::Option.new name, shortcut, value_mode, description, default, suggested_values if full_definition = @full_definition full_definition << ACON::Input::Option.new name, shortcut, value_mode, description, default, suggested_values end self end # Sets the process title of `self`. # # TODO: Implement this. def process_title(title : String) : self @process_title = title self end # The `#help` message can include some template variables for the command: # # * `%command.name%` - Returns the `#name` of `self`. E.g. `app:create-user` # # This method returns the `#help` message with these variables replaced. def processed_help : String is_single_command = (application = @application) && application.single_command? prog_name = Path.new(PROGRAM_NAME).basename full_name = is_single_command ? prog_name : "./#{prog_name} #{@name}" processed_help = self.help.presence || self.description { {"%command.name%", @name}, {"%command.full_name%", full_name} }.each do |(placeholder, replacement)| processed_help = processed_help.gsub placeholder, replacement end processed_help end # Returns a short synopsis of `self`, including its `#name` and expected arguments/options. # For example `app:user-create [--dry-run] [--] `. def synopsis(short : Bool = false) : String key = short ? Synopsis::SHORT : Synopsis::LONG unless @synopsis.has_key? key @synopsis[key] = "#{@name} #{@definition.synopsis short}".strip end @synopsis[key] end # Adds a usage string that will displayed within the `Usage` section after the auto generated entry. def usage(usage : String) : self unless (name = @name) && usage.starts_with? name usage = "#{name} #{usage}" end @usages << usage self end # Makes the command ignore any input validation errors. def ignore_validation_errors : Nil @ignore_validation_errors = true end # Runs the command with the provided *input* and *output*, returning the status of the invocation as an `ACON::Command::Status`. def run(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status self.merge_application_definition begin input.bind self.definition rescue ex : ::Exception # TODO: Make this part of the `rescue` after Crystal 1.13 if ex.is_a?(ACON::Exception) raise ex unless @ignore_validation_errors end end self.setup input, output # TODO: Allow setting process title if input.interactive? self.interact input, output end if input.has_argument?("command") && input.argument("command").nil? input.set_argument "command", self.name end input.validate self.execute input, output end # Determines what values should be added to the possible *suggestions* based on the provided *input*. # # By default this will fall back on completion of the related input argument/option, but can be overridden if needed. def complete(input : ACON::Completion::Input, suggestions : ACON::Completion::Suggestions) : Nil definition = self.definition if input.completion_type.option_value? && (option = definition.options[input.completion_name]?) option.complete input, suggestions elsif input.completion_type.argument_value? && (argument = definition.arguments[input.completion_name]?) argument.complete input, suggestions end end protected def merge_application_definition(merge_args : Bool = true) : Nil return unless application = @application # TODO: Figure out if there is a better way to structure/store # the data to remove the .values call. full_definition = ACON::Input::Definition.new full_definition.options = @definition.options.values full_definition << application.definition.options.values if merge_args full_definition.arguments = application.definition.arguments.values full_definition << @definition.arguments.values else full_definition.arguments = @definition.arguments.values end @full_definition = full_definition end protected def native_definition @definition end # Executes the command with the provided *input* and *output*, returning the status of the invocation via `ACON::Command::Status`. # # This method _MUST_ be defined and implement the business logic for the command. protected abstract def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # Can be overridden to configure the current command, such as setting the name, adding arguments/options, setting help information etc. protected def configure : Nil end # The related `ACON::Input::Definition` is validated _after_ this method is executed. # This method can be used to interactively ask the user for missing required arguments. protected def interact(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil end # Called after the input has been bound, but before it has been validated. # Can be used to setup state of the command based on the provided input data. protected def setup(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil end private def validate_name(name : String) : Nil raise ACON::Exception::InvalidArgument.new "Command name '#{name}' is invalid." if name.blank? || !name.matches? /^[^:]++(:[^:]++)*$/ end end ================================================ FILE: src/components/console/src/commands/complete.cr ================================================ require "semantic_version" @[Athena::Console::Annotations::AsCommand("|_complete", description: "Internal command to provide shell completion suggestions")] # :nodoc: class Athena::Console::Commands::Complete < Athena::Console::Command API_VERSION = 2 @completion_outputs : Hash(String, ACON::Completion::Output::Interface.class) @debug : Bool = false def initialize(completion_outputs : Hash(String, ACON::Completion::Output::Interface.class) = Hash(String, ACON::Completion::Output::Interface.class).new) @completion_outputs = completion_outputs.merge!({ "bash" => ACON::Completion::Output::Bash, "fish" => ACON::Completion::Output::Fish, "zsh" => ACON::Completion::Output::Zsh, } of String => ACON::Completion::Output::Interface.class) super() end protected def configure : Nil self .definition( ACON::Input::Option.new("shell", "s", :required, "The shell type ('#{@completion_outputs.keys.join "', '"}')"), ACON::Input::Option.new("input", "i", ACON::Input::Option::Value[:required, :is_array], "An array of input tokens (e.g. COMP_WORDS or argv)"), ACON::Input::Option.new("current", "c", :required, "The index of the 'input' array that the cursor is in (e.g. COMP_CWORD)"), ACON::Input::Option.new("api-version", "a", :required, "The API version of the completion script") ) end protected def setup(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil @debug = ENV["ATHENA_DEBUG_COMPLETION"]? == "true" end # ameba:disable Metrics/CyclomaticComplexity protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status if major_version = input.option("api-version") version = SemanticVersion.new major_version.to_i, 0, 0 if version < SemanticVersion.new(API_VERSION, 0, 0) message = "Completion script version is not supported ('#{version.major}' given, >=#{API_VERSION} required)." self.log message output.puts "#{message} Install the Athena completion script again by using the 'completion' command." return ACON::Command::Status.new 126 end end unless shell = input.option "shell" raise ACON::Exception::Runtime.new "The '--shell' option must be set." end unless completion_output = @completion_outputs[shell]? raise ACON::Exception::Runtime.new %(Shell completion is not supported for your shell: '#{shell}' (supported: '#{@completion_outputs.keys.join "', '"}').) end completion_input = self.create_completion_input input suggestions = ACON::Completion::Suggestions.new self.log({ "", "#{Time.local}", "Input: (\"|\" indicates the cursor position)", " #{completion_input}", "Command:", " #{ARGV.join " "}", "Messages:", }) command = self.find_command completion_input, output if command.nil? self.log " No command found, completing using the Application class." self.application.complete completion_input, suggestions elsif completion_input.must_suggest_argument_values_for?("command") && command.name != completion_input.completion_value && !command.aliases.includes?(completion_input.completion_value) self.log " Found command, suggesting aliases" # expand shortcut names ("foo:f") into their full name ("foo:foo") suggestions.suggest_values [command.name].concat(command.aliases) else command.merge_application_definition completion_input.bind command.definition if completion_input.completion_type.option_name? self.log " Completing option names for the #{command.is_a?(ACON::Commands::Lazy) ? command.command.class : command.class} command." suggestions.suggest_options command.definition.options.values else self.log({ " Completing using the #{command.is_a?(ACON::Commands::Lazy) ? command.command.class : command.class} class.", " Completing #{completion_input.completion_type} for #{completion_input.completion_name}", }) command.complete completion_input, suggestions end end completion_output = completion_output.new self.log "Suggestions:" if (options = suggestions.suggested_options) && !options.empty? self.log %( --#{options.map(&.name).join(" --")}) elsif (values = suggestions.suggested_values) && !values.empty? self.log %( #{values.join(" ")}) else self.log " No suggestions were provided" end completion_output.write suggestions, output ACON::Command::Status::SUCCESS rescue ex : ::Exception self.log({"Error!", ex.to_s}) raise ex if output.verbosity.debug? ACON::Command::Status::INVALID end private def create_completion_input(input : ACON::Input::Interface) : ACON::Completion::Input current_index = input.option "current" if current_index.nil? || !(index = current_index.to_i?) raise ACON::Exception::Runtime.new "The '--current' option must be set and it must be an integer." end completion_input = ACON::Completion::Input.from_tokens input.option("input", Array(String)), index begin completion_input.bind self.application.definition rescue ex : ::Exception # TODO: Make this part of the `rescue` after Crystal 1.13 raise ex unless ex.is_a? ACON::Exception end completion_input end private def find_command(completion_input : ACON::Completion::Input, output : ACON::Output::Interface) : ACON::Command? begin unless input_name = completion_input.first_argument return nil end return self.application.find input_name rescue ACON::Exception::CommandNotFound # noop end nil end private def log(messages : String | Enumerable(String)) : Nil return unless @debug messages = messages.is_a?(String) ? {messages} : messages command_name = Path.new(PROGRAM_NAME).basename File.write( "#{Dir.tempdir}/athena_#{command_name}.log", "#{messages.join(EOL)}#{EOL}", mode: "a" ) end end ================================================ FILE: src/components/console/src/commands/dump_completion.cr ================================================ @[Athena::Console::Annotations::AsCommand("completion", description: "Dump the shell completion script")] # Can be used to generate the [completion script](/Console#console-completion) to enable [argument/option value completion][Athena::Console::Input::Interface--argumentoption-value-completion]. # # See the related docs for more information. class Athena::Console::Commands::DumpCompletion < Athena::Console::Command private SUPPORTED_SHELLS = {{ Athena::Console::Completion::Output::Interface.subclasses.map(&.name.split("::").last.downcase) }} protected def self.guess_shell : String File.basename ENV["SHELL"]? || "" end protected def configure : Nil # `Process.executable_path` already resolves symlinks full_command = Process.executable_path || "" command_name = File.basename full_command shell = self.class.guess_shell rc_file, completion_file = case shell when "fish" then {"~/.config/fish/config.fish", "/etc/fish/completions/#{command_name}.fish"} when "zsh" then {"~/.zshrc", "$fpath[1]/_#{command_name}"} else {"~/.bashrc", "/etc/bash_completion.d/#{command_name}"} end supported_shells = SUPPORTED_SHELLS.join ", " self .argument("shell", description: "The shell type (e.g. 'bash'), the value of the '$SHELL' env var will be used if not provided", suggested_values: SUPPORTED_SHELLS) .help(<<-TEXT The %command.name% command dumps the shell completion script required to use shell autocompletion (currently, #{supported_shells} completion are supported). Static installation ------------------- Dump the script to a global completion file and restart your shell: %command.full_name% #{shell} | sudo tee #{completion_file} Or dump the script to a local file and source it: %command.full_name% #{shell} > completion.sh # source the file whenever you use the project source completion.sh # or add this line at the end of your "#{rc_file}" file: source /path/to/completion.sh Dynamic installation -------------------- Add this to the end of your shell configuration file (e.g. "#{rc_file}"): eval "$(#{full_command} completion #{shell})" TEXT ) end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status command_name = File.basename Process.executable_path || "" # Prevent generating the file for *.tmp files. # It'll not only run slow, but probably not even valid bash syntax. if command_name.ends_with? ".tmp" raise ACON::Exception::Runtime.new "The shell completion file may only be generated non-temporary binaries.\n\nTry to `crystal build` your application first and try again." end shell = input.argument("shell") || self.class.guess_shell completion_script = case shell when "bash" then ACON::Completion::Output::Bash::Script.new command_name, ACON::Commands::Complete::API_VERSION when "fish" then ACON::Completion::Output::Fish::Script.new command_name, ACON::Commands::Complete::API_VERSION when "zsh" then ACON::Completion::Output::Zsh::Script.new command_name, ACON::Commands::Complete::API_VERSION else if output.is_a? ACON::Output::ConsoleOutputInterface output = output.error_output end if shell output.puts %(Detected shell '#{shell}', which is not supported by Athena shell completion (supported shells: '#{SUPPORTED_SHELLS.join("', '")}'.)) else output.puts %(Shell not detected, Athena shell completion only supports '#{SUPPORTED_SHELLS.join("', '")}'.) end return ACON::Command::Status::INVALID end output.print completion_script ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/src/commands/generic.cr ================================================ # A generic implementation of `ACON::Command` that is instantiated with a block that will be executed as part of the `#execute` method. # # This is the command class used as part of `ACON::Application#register`. class Athena::Console::Commands::Generic < Athena::Console::Command alias Proc = ::Proc(ACON::Input::Interface, ACON::Output::Interface, ACON::Command, ACON::Command::Status) def initialize(name : String, &@callback : ACON::Commands::Generic::Proc) super name end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status @callback.call input, output, self end end ================================================ FILE: src/components/console/src/commands/help.cr ================================================ # Displays information for a given command. @[Athena::Console::Annotations::AsCommand("help", description: "Display help for a command")] class Athena::Console::Commands::Help < Athena::Console::Command # :nodoc: setter command : ACON::Command? = nil protected def configure : Nil self.ignore_validation_errors self .name("help") .argument("command_name", description: "The command name", default: "help") { ACON::Descriptor::Application.new(self.application).commands.keys } .option("format", value_mode: :required, description: "The output format (txt)", default: "txt") { ACON::Helper::Descriptor.new.formats } .option("raw", value_mode: :none, description: "To output raw command help") .help( <<-HELP The %command.name% command displays help for a given command: %command.full_name% list To display the list of available commands, please use the list command. HELP ) end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status if @command.nil? @command = self.application.find input.argument("command_name", String) end ACON::Helper::Descriptor.new.describe( output, @command.not_nil!, ACON::Descriptor::Context.new( format: input.option("format", String), raw_text: input.option("raw", Bool), ) ) @command = nil ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/src/commands/lazy.cr ================================================ # :nodoc: class Athena::Console::Commands::Lazy < Athena::Console::Command @command : Proc(ACON::Command) | ACON::Command @enabled : Bool delegate :run, :merge_application_definition, :definition, :native_definition, :argument, :option, :process_title, :help, :processed_help, :synopsis, :usage, :usages, :helper, to: self.command def initialize( name : String, aliases : Enumerable(String), description : String, hidden : Bool, @command : Proc(ACON::Command), @enabled : Bool = true, ) self .name(name) .aliases(aliases) .hidden(hidden) .description(description) end # :inherit: def application=(application : ACON::Application?) : Nil if (cmd = @command).is_a? ACON::Command cmd.application = application end super end # :inherit: def helper_set=(helper_set : ACON::Helper::HelperSet) : Nil if (cmd = @command).is_a? ACON::Command cmd.helper_set = helper_set end super end # :inherit: def enabled? : Bool @enabled || self.command.enabled? end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status raise NotImplementedError.new "Use #run instead." end def command : ACON::Command if (cmd = @command).is_a? ACON::Command return cmd end command = @command = cmd.call command.application = self.application? if hs = self.helper_set command.helper_set = hs end command .name(self.name) .aliases(self.aliases) .hidden(self.hidden?) .description(self.description) command.definition command end def complete(input : ACON::Completion::Input, suggestions : ACON::Completion::Suggestions) : Nil self.command.complete input, suggestions end end ================================================ FILE: src/components/console/src/commands/list.cr ================================================ # Lists the available commands, optionally only including those in a specific namespace. @[Athena::Console::Annotations::AsCommand("list", description: "List available commands")] class Athena::Console::Commands::List < Athena::Console::Command protected def configure : Nil self .argument("namespace", description: "Only list commands in this namespace") { ACON::Descriptor::Application.new(self.application).namespaces.keys } .option("raw", value_mode: :none, description: "To output raw command list") .option("format", value_mode: :required, description: "The output format (txt)", default: "txt") { ACON::Helper::Descriptor.new.formats } .option("short", value_mode: :none, description: "To skip describing command's arguments") .help( <<-HELP The %command.name% command lists all commands: %command.full_name% You can also display the commands for a specific namespace: %command.full_name% test It's also possible to get raw list of commands (useful for embedding command runner): %command.full_name% --raw HELP ) end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Helper::Descriptor.new.describe( output, self.application, ACON::Descriptor::Context.new( format: input.option("format", String), raw_text: input.option("raw", Bool), namespace: input.argument("namespace", String?), short: input.option("short", Bool) ) ) ACON::Command::Status::SUCCESS end end ================================================ FILE: src/components/console/src/completion/input.cr ================================================ abstract class Athena::Console::Input; end require "../input/argv" # A specialization of `ACON::Input::ARGV` that allows for unfinished name/values. # Exposes information about the name, type, and value of the value/name being completed. class Athena::Console::Completion::Input < Athena::Console::Input::ARGV enum Type # Nothing should be completed. NONE # Completing the value of an argument. ARGUMENT_VALUE # Completing the value of an option. OPTION_VALUE # Completing the name of an option. OPTION_NAME end def self.from_string(input : String, current_index : Int32) : self tokens = input.scan(/(?<=^|\s)(['"]?)(.+?)(?= @tokens.size if argument_name && (!@arguments.has_key?(argument_name) || @definition.argument(argument_name).is_array?) @completion_name = argument_name @completion_value = "" else # Reached end of data @completion_type = :none @completion_name = nil @completion_value = "" end end end # Returns `true` if this input is able to suggest values for the provided *option_name*. def must_suggest_option_values_for?(option_name : String) : Bool @completion_type.option_value? && option_name == @completion_name end # Returns `true` if this input is able to suggest values for the provided *argument_name*. def must_suggest_argument_values_for?(argument_name : String) : Bool @completion_type.argument_value? && argument_name == @completion_name end # Returns the current token of the cursor, or last token if the cursor is at the end of the input. def relevant_token : String @tokens[self.free_cursor? ? @current_index - 1 : @current_index]? || "" end # :nodoc: def to_s(io : IO) : Nil i = 0 @tokens.each_with_index do |token, idx| io << token io << '|' if idx == @current_index io << ' ' unless @tokens.size == (idx + 1) i = idx end if @current_index > i io << '|' end end protected def parse_token(token : String, parse_options : Bool) : Bool begin return super rescue ex : ACON::Exception::Runtime # noop, completed input is almost never valid end parse_options end private def option_from_token(option_token : String) : ACON::Input::Option? option_name = option_token.lstrip '-' return nil if option_name.empty? if '-' == (option_token[1]? || " ") # Long option name return @definition.options[option_name]? end # Short option name @definition.has_shortcut?(option_name[0]) ? @definition.option_for_shortcut(option_name[0]) : nil end private def free_cursor? : Bool number_of_tokens = @tokens.size if @current_index > number_of_tokens raise RuntimeError.new "Current index is invalid, it must be the number of input tokens." end @current_index >= number_of_tokens end end ================================================ FILE: src/components/console/src/completion/output/bash.cr ================================================ require "./interface" # :nodoc: struct Athena::Console::Completion::Output::Bash < Athena::Console::Completion::Output::Interface # :nodoc: record Script, command_name : String, version : Int32 do ECR.def_to_s "#{__DIR__}/completion.bash" end def write(suggestions : ACON::Completion::Suggestions, output : ACON::Output::Interface) : Nil values = suggestions.suggested_values.map &.to_s suggestions.suggested_options.each do |option| values << "--#{option.name}" if option.negatable? values << "--no-#{option.name}" end end output.puts values.join "\n" end end ================================================ FILE: src/components/console/src/completion/output/completion.bash ================================================ # Adapted from https://github.com/symfony/symfony/blob/503a7b3cb62fb6de70176b07bd1c4242e3addc5b/src/Symfony/Component/Console/Resources/completion.bash _athena_<%= @command_name %>() { # Use the default completion for shell redirect operators. for w in '>' '>>' '&>' '<'; do if [[ $w = "${COMP_WORDS[COMP_CWORD-1]}" ]]; then compopt -o filenames COMPREPLY=($(compgen -f -- "${COMP_WORDS[COMP_CWORD]}")) return 0 fi done # Use newline as only separator to allow space in completion values IFS=$'\n' local completion_cmd="${COMP_WORDS[0]}" # for an alias, get the real script behind it completion_cmd_type=$(type -t $completion_cmd) if [[ $completion_cmd_type == "alias" ]]; then completion_cmd=$(alias $completion_cmd | sed -E "s/alias $completion_cmd='(.*)'/\1/") elif [[ $completion_cmd_type == "file" ]]; then completion_cmd=$(type -p $completion_cmd) fi if [[ $completion_cmd_type != "function" && ! -x $completion_cmd ]]; then return 1 fi local cur prev words cword _get_comp_words_by_ref -n := cur prev words cword # Crystal doesn\'t get the script as the first arg, so remove it and decrement cword by 1 to compensate cword=$(expr $cword - 1) words=("${words[@]:1}") local completecmd=("$completion_cmd" "_complete" "--no-interaction" "-sbash" "-c$cword" "-a<%= @version %>") for w in ${words[@]}; do w=$(printf -- '%b' "$w") # remove quotes from typed values quote="${w:0:1}" if [ "$quote" == \' ]; then w="${w%\'}" w="${w#\'}" elif [ "$quote" == \" ]; then w="${w%\"}" w="${w#\"}" fi # empty values are ignored if [ ! -z "$w" ]; then completecmd+=("-i$w") fi done local sfcomplete if sfcomplete=$(${completecmd[@]} 2>&1); then local quote suggestions quote=${cur:0:1} # Use single quotes by default if suggestions contains backslash (FQCN) if [ "$quote" == '' ] && [[ "$sfcomplete" =~ \\ ]]; then quote=\' fi if [ "$quote" == \' ]; then # single quotes: no additional escaping (does not accept ' in values) suggestions=$(for s in $sfcomplete; do printf $'%q%q%q\n' "$quote" "$s" "$quote"; done) elif [ "$quote" == \" ]; then # double quotes: double escaping for \ $ ` " suggestions=$(for s in $sfcomplete; do s=${s//\\/\\\\} s=${s//\$/\\\$} s=${s//\`/\\\`} s=${s//\"/\\\"} printf $'%q%q%q\n' "$quote" "$s" "$quote"; done) else # no quotes: double escaping suggestions=$(for s in $sfcomplete; do printf $'%q\n' $(printf '%q' "$s"); done) fi COMPREPLY=($(IFS=$'\n' compgen -W "$suggestions" -- $(printf -- "%q" "$cur"))) __ltrim_colon_completions "$cur" else if [[ "$sfcomplete" != *"Command \"_complete\" is not defined."* ]]; then >&2 echo >&2 echo $sfcomplete fi return 1 fi } complete -F _athena_<%= @command_name %> <%= @command_name %> ./<%= @command_name %> ./bin/<%= @command_name %> ================================================ FILE: src/components/console/src/completion/output/completion.fish ================================================ # Adapted from https://github.com/symfony/symfony/blob/503a7b3cb62fb6de70176b07bd1c4242e3addc5b/src/Symfony/Component/Console/Resources/completion.fish # Crystal doesn\'t get the script as the first arg, so remove it and decrement c by 1 to compensate function _athena_<%= @command_name %> set athena_cmd (commandline -o) set c (math (count (commandline -oc)) - 1) set completecmd "$athena_cmd[1]" "_complete" "--no-interaction" "-sfish" "-a<%= @version %>" for i in $athena_cmd[2..] if [ $i != "" ] set completecmd $completecmd "-i$i" end end set completecmd $completecmd "-c$c" set sfcomplete ($completecmd) for i in $sfcomplete echo $i end end complete -c '<%= @command_name %>' -a '(_athena_<%= @command_name %>)' -f ================================================ FILE: src/components/console/src/completion/output/completion.zsh ================================================ #compdef <%= @command_name %> # # zsh completions for <%= @command_name %> # # References: # - https://github.com/symfony/symfony/blob/503a7b3cb62fb6de70176b07bd1c4242e3addc5b/src/Symfony/Component/Console/Resources/completion.zsh _athena_<%= @command_name %>() { local lastParam flagPrefix requestComp out comp local -a completions # The user could have moved the cursor backwards on the command-line. # We need to trigger completion from the $CURRENT location, so we need # to truncate the command-line ($words) up to the $CURRENT location. # (We cannot use $CURSOR as its value does not work when a command is an alias.) words=("${=words[1,CURRENT]}") lastParam=${words[-1]} # For zsh, when completing a flag with an = (e.g., <%= @command_name %> -n=) # completions must be prefixed with the flag setopt local_options BASH_REMATCH if [[ "${lastParam}" =~ '-.*=' ]]; then # We are dealing with a flag with an = flagPrefix="-P ${BASH_REMATCH}" fi # Prepare the command to obtain completions # Crystal doesn\'t get the script as the first arg, so skip it when iterating over `words` and decrement CURRENT by 2 instead of 1 to compensate requestComp="${words[0]} ${words[1]} _complete --no-interaction -szsh -a<%= @version %> -c$((CURRENT-2))" i="" for w in ${words[@]:1}; do w=$(printf -- '%b' "$w") # remove quotes from typed values quote="${w:0:1}" if [ "$quote" = \' ]; then w="${w%\'}" w="${w#\'}" elif [ "$quote" = \" ]; then w="${w%\"}" w="${w#\"}" fi # empty values are ignored if [ ! -z "$w" ]; then i="${i}-i${w} " fi done # Ensure at least 1 input if [ "${i}" = "" ]; then requestComp="${requestComp} -i\" \"" else requestComp="${requestComp} ${i}" fi # Use eval to handle any environment variables and such out=$(eval ${requestComp} 2>/dev/null) while IFS='\n' read -r comp; do if [ -n "$comp" ]; then # If requested, completions are returned with a description. # The description is preceded by a TAB character. # For zsh\'s _describe, we need to use a : instead of a TAB. # We first need to escape any : as part of the completion itself. comp=${comp//:/\\:} local tab=$(printf '\t') comp=${comp//$tab/:} completions+=${comp} fi done < <(printf "%s\n" "${out[@]}") # Let inbuilt _describe handle completions eval _describe "completions" completions $flagPrefix return $? } compdef _athena_<%= @command_name %> <%= @command_name %> ./<%= @command_name %> ================================================ FILE: src/components/console/src/completion/output/fish.cr ================================================ require "./interface" # :nodoc: struct Athena::Console::Completion::Output::Fish < Athena::Console::Completion::Output::Interface # :nodoc: record Script, command_name : String, version : Int32 do ECR.def_to_s "#{__DIR__}/completion.fish" end def write(suggestions : ACON::Completion::Suggestions, output : ACON::Output::Interface) : Nil values = suggestions.suggested_values.map &.to_s suggestions.suggested_options.each do |option| values << "--#{option.name}" if option.negatable? values << "--no-#{option.name}" end end output.print values.join "\n" end end ================================================ FILE: src/components/console/src/completion/output/interface.cr ================================================ require "../suggestions" # :nodoc: module Athena::Console::Completion::Output abstract struct Interface # Returns a string representation of the args passed to the command. abstract def write(suggestions : ACON::Completion::Suggestions, output : ACON::Output::Interface) : Nil end end ================================================ FILE: src/components/console/src/completion/output/zsh.cr ================================================ # :nodoc: struct Athena::Console::Completion::Output::Zsh < Athena::Console::Completion::Output::Interface # :nodoc: record Script, command_name : String, version : Int32 do ECR.def_to_s "#{__DIR__}/completion.zsh" end def write(suggestions : ACON::Completion::Suggestions, output : ACON::Output::Interface) : Nil values = suggestions.suggested_values.map do |v| "#{v.value}#{(desc = v.description.presence) ? "\t#{desc}" : ""}" end suggestions.suggested_options.each do |option| values << "--#{option.name}#{(desc = option.description.presence) ? "\t#{desc}" : ""}" if option.negatable? values << "--no-#{option.name}#{(desc = option.description.presence) ? "\t#{desc}" : ""}" end end output.print "#{values.join "\n"}\n" end end ================================================ FILE: src/components/console/src/completion/suggestions.cr ================================================ # Stores all the suggested values/options for the current `ACON::Completion::Input`. class Athena::Console::Completion::Suggestions # Represents a single suggested values, plus optional description. record SuggestedValue, value : String, description : String = "" do def to_s(io : IO) : Nil @value.to_s io end end # Returns an array of the suggested `ACON::Input::Option`s. getter suggested_options = [] of ACON::Input::Option # Returns an array of the `ACON::Completion::Suggestions::SuggestedValue`s. getter suggested_values = [] of ACON::Completion::Suggestions::SuggestedValue # Adds each of the provided *values* to `#suggested_values`. def suggest_values(*values : String) : self self.suggest_values values end # Adds each of the provided *values* to `#suggested_values`. def suggest_values(values : Enumerable(String)) : self values.each do |option| self.suggest_value option end self end # Adds the provided *value*, and optional *description* to `#suggested_values`. def suggest_value(value : String, description : String = "") : self self.suggest_value SuggestedValue.new value, description end # Adds the provided *value* to `#suggested_values`. def suggest_value(value : ACON::Completion::Suggestions::SuggestedValue) : self @suggested_values << value self end # Adds each of the provided *options* to `#suggested_options`. def suggest_options(options : ::Enumerable(ACON::Input::Option)) : self options.each do |option| self.suggest_option option end self end # Adds the provided *option* to `#suggested_options`. def suggest_option(option : ACON::Input::Option) : self @suggested_options << option self end end ================================================ FILE: src/components/console/src/cursor.cr ================================================ # Provides an OO way to interact with the console window, # allows writing on any position of the output. # # ``` # @[ACONA::AsCommand("cursor")] # class CursorCommand < ACON::Command # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # cursor = ACON::Cursor.new output # # # Move the cursor to a specific column, row position. # cursor.move_to_position 50, 3 # # # Write text at that location. # output.puts "Hello!" # # # Clear the current line. # cursor.clear_line # # ACON::Command::Status::SUCCESS # end # end # ``` struct Athena::Console::Cursor @output : ACON::Output::Interface @input : IO def initialize(@output : ACON::Output::Interface, @input : IO = STDIN); end # Moves the cursor up *lines* lines. def move_up(lines : Int32 = 1) : self @output.print "\x1b[#{lines}A" self end # Moves the cursor down *lines* lines. def move_down(lines : Int32 = 1) : self @output.print "\x1b[#{lines}B" self end # Moves the cursor right *lines* lines. def move_right(lines : Int32 = 1) : self @output.print "\x1b[#{lines}C" self end # Moves the cursor left *lines* lines. def move_left(lines : Int32 = 1) : self @output.print "\x1b[#{lines}D" self end # Moves the cursor to the provided *column*. def move_to_column(column : Int32) : self @output.print "\x1b[#{column}G" self end # Moves the cursor to the provided *column*, *row* position. def move_to_position(column : Int32, row : Int32) : self @output.print "\x1b[#{row + 1};#{column}H" self end # Saves the current position such that it could be restored via `#restore_position`. def save_position : self @output.print "\x1b7" self end # Restores the position set via `#save_position`. def restore_position : self @output.print "\x1b8" self end # Hides the cursor. def hide : self @output.print "\x1b[?25l" self end # Shows the cursor. def show : self @output.print "\x1b[?25h\x1b[?0c" self end # Clears the current line. def clear_line : self @output.print "\x1b[2K" self end # Clears the current line after the cursor's current position. def clear_line_after : self @output.print "\x1b[K" self end # Clears the output from the cursors' current position to the end of the screen. def clear_output : self @output.print "\x1b[0J" self end # Clears the entire screen. def clear_screen : self @output.print "\x1b[2J" self end # Returns the current column, row position of the cursor. def current_position : {Int32, Int32} {% if flag? :win32 %} return {1, 1} unless @input.tty? LibC.GetConsoleScreenBufferInfo(LibC.GetStdHandle(LibC::STDOUT_HANDLE), out csbi) {csbi.dwCursorPosition.x.to_i32, csbi.dwCursorPosition.y.to_i32} {% else %} return {1, 1} unless @input.tty? stty_mode = `stty -g` system "stty -icanon -echo" @input.print "\033[6n" bytes = @input.peek system "stty #{stty_mode}" String.new(bytes.not_nil!).match /\e\[(\d+);(\d+)R/ {$2.to_i, $1.to_i} {% end %} end end ================================================ FILE: src/components/console/src/descriptor/application.cr ================================================ abstract class Athena::Console::Descriptor; end # :nodoc: record Athena::Console::Descriptor::Application, application : ACON::Application, namespace : String? = nil, show_hidden : Bool = false do GLOBAL_NAMESPACE = "_global" @commands : Hash(String, ACON::Command)? = nil @namespaces : Hash(String, NamedTuple(id: String, commands: Array(String)))? = nil @aliases : Hash(String, ACON::Command)? = nil def commands : Hash(String, ACON::Command) if @commands.nil? self.inspect_application end @commands.not_nil! end def command(name : String) : ACON::Command if !@commands.not_nil!.has_key?(name) && !@aliases.not_nil!.has_key?(name) raise ACON::Exception::CommandNotFound.new "Command '#{name}' does not exist." end @commands.not_nil![name]? || @aliases.not_nil![name] end def namespaces : Hash(String, NamedTuple(id: String, commands: Array(String))) if @namespaces.nil? self.inspect_application end @namespaces.not_nil! end private def inspect_application : Nil commands = Hash(String, ACON::Command).new namespaces = Hash(String, NamedTuple(id: String, commands: Array(String))).new aliases = Hash(String, ACON::Command).new all_commands = @application.commands((namespace = @namespace) ? @application.find_namespace(namespace) : nil) self.sort_commands(all_commands).each do |namespace, command_hash| names = Array(String).new command_hash.each do |name, command| next if command.name.nil? || (!@show_hidden && command.hidden?) if name == command.name commands[name] = command else aliases[name] = command end names << name end namespaces[namespace] = {id: namespace, commands: names} end @commands = commands @namespaces = namespaces @aliases = aliases end private def sort_commands(commands : Hash(String, ACON::Command)) : Hash(String, Hash(String, ACON::Command)) namespaced_commands = Hash(String, Hash(String, ACON::Command)).new global_commands = Hash(String, ACON::Command).new sorted_commands = Hash(String, Hash(String, ACON::Command)).new commands.each do |name, command| key = @application.extract_namespace name, 1 if key.in? "", GLOBAL_NAMESPACE global_commands[name] = command else (namespaced_commands[key] ||= Hash(String, ACON::Command).new)[name] = command end end unless global_commands.empty? sorted_commands[GLOBAL_NAMESPACE] = self.sort_hash global_commands end unless namespaced_commands.empty? namespaced_commands = self.sort_hash namespaced_commands namespaced_commands.keys.sort!.each do |key| sorted_commands[key] = self.sort_hash namespaced_commands[key] end end sorted_commands end private def sort_hash(hash : Hash(String, Hash(String, Athena::Console::Command))) : Hash(String, Hash(String, Athena::Console::Command)) sorted_hash = Hash(String, Hash(String, Athena::Console::Command)).new hash.keys.sort!.each do |k| sorted_hash[k] = self.sort_hash hash[k] end sorted_hash end private def sort_hash(hash : Hash(String, ACON::Command)) : Hash(String, ACON::Command) sorted_hash = Hash(String, ACON::Command).new hash.keys.sort!.each do |k| sorted_hash[k] = hash[k] end sorted_hash end end ================================================ FILE: src/components/console/src/descriptor/context.cr ================================================ class Athena::Console::Descriptor::Context property format : String property? raw_text : Bool property? raw_output : Bool? property namespace : String? property total_width : Int32? property? short : Bool def initialize( @format : String = "txt", @raw_text : Bool = false, @raw_output : Bool? = nil, @namespace : String? = nil, @total_width : Int32? = nil, @short : Bool = false, ) end def_clone end ================================================ FILE: src/components/console/src/descriptor/descriptor.cr ================================================ require "./interface" # :nodoc: abstract class Athena::Console::Descriptor include Athena::Console::Descriptor::Interface getter! output : ACON::Output::Interface def describe(output : ACON::Output::Interface, object : _, context : ACON::Descriptor::Context) : Nil @output = output self.describe object, context end protected abstract def describe(application : ACON::Application, context : ACON::Descriptor::Context) : Nil protected abstract def describe(command : ACON::Command, context : ACON::Descriptor::Context) : Nil protected abstract def describe(definition : ACON::Input::Definition, context : ACON::Descriptor::Context) : Nil protected abstract def describe(argument : ACON::Input::Argument, context : ACON::Descriptor::Context) : Nil protected abstract def describe(option : ACON::Input::Option, context : ACON::Descriptor::Context) : Nil protected def describe(obj : _, context : ACON::Descriptor::Context) : Nil raise "BUG: Failed to describe #{obj}" end protected def write(content : String, decorated : Bool = false) : Nil self.output.print content, output_type: decorated ? Athena::Console::Output::Type::NORMAL : Athena::Console::Output::Type::RAW end end ================================================ FILE: src/components/console/src/descriptor/interface.cr ================================================ module Athena::Console::Descriptor::Interface abstract def describe(output : ACON::Output::Interface, object : _, context : ACON::Descriptor::Context) : Nil end ================================================ FILE: src/components/console/src/descriptor/text.cr ================================================ # :nodoc: # # TODO: Should/can this be implemented via `to_s(io)` on each type? class Athena::Console::Descriptor::Text < Athena::Console::Descriptor protected def describe(application : ACON::Application, context : ACON::Descriptor::Context) : Nil described_namespace = context.namespace description = ACON::Descriptor::Application.new application, context.namespace commands = description.commands.values if context.raw_text? width = self.width commands commands.each do |command| self.write_text sprintf("%-#{width}s %s", command.name, command.description), context self.write_text "\n" end return end self.write_text "#{application.help}\n\n", context self.write_text "Usage:\n", context self.write_text " command [options] [arguments]\n\n", context self.describe ACON::Input::Definition.new(application.definition.options), context self.write_text "\n" self.write_text "\n" commands = description.commands namespaces = description.namespaces if described_namespace && !namespaces.empty? namespaces.values.first[:commands].each do |n| commands[n] = description.command n end end width = self.width( namespaces.values.flat_map do |n| commands.keys & n[:commands] end.uniq! ) if described_namespace self.write_text %(Available commands for the '#{described_namespace}' namespace:), context else self.write_text "Available commands:", context end namespaces.each_value do |namespace| namespace[:commands].select! { |c| commands.has_key? c } next if namespace[:commands].empty? if !described_namespace && namespace[:id] != ACON::Descriptor::Application::GLOBAL_NAMESPACE self.write_text "\n" self.write_text " #{namespace[:id]}", context end namespace[:commands].each do |name| self.write_text "\n" spacing_width = width - ACON::Helper.width name command = commands[name] command_aliases = name === command.name ? self.command_aliases_text command : "" self.write_text " #{name}#{" " * spacing_width}#{command_aliases}#{command.description}", context end end self.write_text "\n" end protected def describe(argument : ACON::Input::Argument, context : ACON::Descriptor::Context) : Nil default = if !argument.default.nil? && !argument.default.is_a?(Array) %( [default: #{self.format_default_value argument.default}]) else "" end total_width = context.total_width || ACON::Helper.width argument.name spacing_width = total_width - argument.name.size self.write_text( sprintf( " %s %s%s%s", argument.name, " " * spacing_width, argument.description.gsub(/\s*[\r\n]\s*/, "\n#{" " * (total_width + 4)}"), default ), context ) end protected def describe(command : ACON::Command, context : ACON::Descriptor::Context) : Nil command.merge_application_definition false if description = command.description.presence self.write_text "Description:", context self.write_text "\n" self.write_text " #{description}" self.write_text "\n\n" end self.write_text "Usage:", context ([command.synopsis(true)] + command.aliases + command.usages).each do |usage| self.write_text "\n" self.write_text " #{ACON::Formatter::Output.escape usage}", context end self.write_text "\n" definition = command.definition if !definition.options.empty? || !definition.arguments.empty? self.write_text "\n" self.describe definition, context self.write_text "\n" end if (help = command.processed_help).presence && help != description self.write_text "\n" self.write_text "Help:", context self.write_text "\n" self.write_text " #{help.gsub("\n", "\n ")}", context self.write_text "\n" end end protected def describe(definition : ACON::Input::Definition, context : ACON::Descriptor::Context) : Nil total_width = self.calculate_total_width_for_options definition.options definition.arguments.each_value do |arg| total_width = Math.max total_width, ACON::Helper.width(arg.name) end unless definition.arguments.empty? self.write_text "Arguments:", context self.write_text "\n" new_context = context.clone new_context.total_width = total_width definition.arguments.each_value do |arg| self.describe arg, new_context self.write_text "\n" end end if !definition.arguments.empty? && !definition.options.empty? self.write_text "\n" end unless definition.options.empty? later_options = [] of ACON::Input::Option self.write_text "Options:", context definition.options.each_value do |option| if (option.shortcut || "").size > 1 later_options << option next end new_context = context.clone new_context.total_width = total_width self.write_text "\n" self.describe option, new_context end later_options.each do |option| self.write_text "\n" new_context = context.clone new_context.total_width = total_width self.describe option, new_context end end end # ameba:disable Metrics/CyclomaticComplexity protected def describe(option : ACON::Input::Option, context : ACON::Descriptor::Context) : Nil if option.accepts_value? && !option.default.nil? && (!option.default.is_a?(Array) || !option.default.as(Array).empty?) default = %( [default: #{self.format_default_value option.default}]) else default = "" end value = "" if option.accepts_value? value = "=#{option.name.upcase}" if option.value_optional? value = "[#{value}]" end end total_width = context.total_width || self.calculate_total_width_for_options [option] synopsis = sprintf( "%s%s", (s = option.shortcut) ? sprintf("-%s, ", s) : " ", (option.negatable? ? "--%s|--no-%s" : "--%s%s") % {name: option.name, value: value} ) spacing_width = total_width - ACON::Helper.width synopsis self.write_text( sprintf( " %s %s%s%s%s", synopsis, " " * spacing_width, option.description.gsub(/\s*[\r\n]\s*/, "\n#{" " * (total_width + 4)}"), default, option.is_array? ? " (multiple values allowed)" : "" ), context ) end private def calculate_total_width_for_options(options : Hash(String, ACON::Input::Option)) : Int32 self.calculate_total_width_for_options options.values end private def calculate_total_width_for_options(options : Array(ACON::Input::Option)) : Int32 return 0 if options.empty? options.max_of do |o| name_length = 1 + Math.max(ACON::Helper.width(o.shortcut || ""), 1) + 4 + ACON::Helper.width(o.name) if o.negatable? name_length += 6 + ACON::Helper.width(o.name) elsif o.accepts_value? name_length += 1 + ACON::Helper.width(o.name) + (o.value_optional? ? 2 : 0) end name_length end end private def command_aliases_text(command : ACON::Command) : String String.build do |io| unless (aliases = command.aliases).empty? io << '[' aliases.join io, '|' io << ']' << ' ' end end end private def format_default_value(default) case default when String %("#{ACON::Formatter::Output.escape default}") when Enumerable %([#{default.map { |item| %|"#{ACON::Formatter::Output.escape item.to_s}"| }.join ","}]) else default end end private def width(commands : Array(ACON::Command) | Array(String)) : Int32 widths = Array(Int32).new commands.each do |command| case command in ACON::Command widths << ACON::Helper.width command.name.not_nil! command.aliases.each do |a| widths << ACON::Helper.width a end in String widths << ACON::Helper.width command end end widths.empty? ? 0 : widths.max + 2 end private def write_text(content : String, context : ACON::Descriptor::Context? = nil) : Nil unless ctx = context return self.write content, true end raw_output = true ctx.raw_output?.try do |ro| raw_output = !ro end if ctx.raw_text? content = content.gsub(/(?:<\/?[^>]*>)|(?:[\n]?)/, "") # TODO: Use a more robust strip_tags implementation. end self.write( content, raw_output ) end end ================================================ FILE: src/components/console/src/exception/command_not_found.cr ================================================ class Athena::Console::Exception::CommandNotFound < ArgumentError include Athena::Console::Exception getter alternatives : Array(String) def initialize(message : String, @alternatives : Array(String) = [] of String, @code : Int32 = 0) super message end end ================================================ FILE: src/components/console/src/exception/invalid_argument.cr ================================================ class Athena::Console::Exception::InvalidArgument < ArgumentError include Athena::Console::Exception def initialize(message : String, @code : Int32 = 0) super message end end ================================================ FILE: src/components/console/src/exception/invalid_option.cr ================================================ class Athena::Console::Exception::InvalidOption < ArgumentError include Athena::Console::Exception def initialize(message : String, @code : Int32 = 0) super message end end ================================================ FILE: src/components/console/src/exception/logic.cr ================================================ # Represents a code logic error that should lead directly to a fix in your code. class Athena::Console::Exception::Logic < ::Exception include Athena::Console::Exception def initialize(message : String, @code : Int32 = 0, cause : ::Exception? = nil) super message, cause end end ================================================ FILE: src/components/console/src/exception/missing_input.cr ================================================ require "./runtime" class Athena::Console::Exception::MissingInput < Athena::Console::Exception::Runtime end ================================================ FILE: src/components/console/src/exception/namespace_not_found.cr ================================================ class Athena::Console::Exception::NamespaceNotFound < Athena::Console::Exception::CommandNotFound end ================================================ FILE: src/components/console/src/exception/runtime.cr ================================================ class Athena::Console::Exception::Runtime < RuntimeError include Athena::Console::Exception def initialize(message : String, @code : Int32 = 0, cause : ::Exception? = nil) super message, cause end end ================================================ FILE: src/components/console/src/ext/terminal.cr ================================================ {% if flag?(:win32) %} lib LibC STDOUT_HANDLE = 0xFFFFFFF5 struct Point x : UInt16 y : UInt16 end struct SmallRect left : UInt16 top : UInt16 right : UInt16 bottom : UInt16 end struct ScreenBufferInfo dwSize : Point dwCursorPosition : Point wAttributes : UInt16 srWindow : SmallRect dwMaximumWindowSize : Point end alias Handle = Void* alias ScreenBufferInfoPtr = ScreenBufferInfo* fun GetConsoleScreenBufferInfo(handle : Handle, info : ScreenBufferInfoPtr) : Bool fun GetStdHandle(handle : UInt32) : Handle end {% else %} lib LibC struct Winsize ws_row : UShort ws_col : UShort ws_xpixel : UShort ws_ypixel : UShort end # TIOCGWINSZ is a platform dependent magic number passed to ioctl that requests the current terminal window size. # Values lifted from https://github.com/crystal-term/screen/blob/ea51ee8d1f6c286573c41a7e784d31c80af7b9bb/src/term-screen.cr#L86-L88. {% begin %} {% if flag?(:darwin) || flag?(:bsd) %} TIOCGWINSZ = 0x40087468 {% elsif flag?(:unix) %} TIOCGWINSZ = 0x5413 {% else %} # Solaris TIOCGWINSZ = 0x5468 {% end %} {% end %} fun ioctl(fd : Int, request : ULong, ...) : Int end {% end %} ================================================ FILE: src/components/console/src/formatter/interface.cr ================================================ require "./output_style_interface" # A container that stores and applies `ACON::Formatter::OutputStyleInterface`. # Is responsible for formatting outputted messages as per their styles. module Athena::Console::Formatter::Interface # Sets if output messages should be decorated. abstract def decorated=(@decorated : Bool) # Returns `true` if output messages will be decorated, otherwise `false`. abstract def decorated? : Bool # Assigns the provided *style* to the provided *name*. abstract def set_style(name : String, style : ACON::Formatter::OutputStyleInterface) : Nil # Returns `true` if `self` has a style with the provided *name*, otherwise `false`. abstract def has_style?(name : String) : Bool # Returns an `ACON::Formatter::OutputStyleInterface` with the provided *name*. abstract def style(name : String) : ACON::Formatter::OutputStyleInterface # Formats the provided *message* according to the stored styles. abstract def format(message : String?) : String end ================================================ FILE: src/components/console/src/formatter/null.cr ================================================ require "./interface" # :nodoc: class Athena::Console::Formatter::Null include Athena::Console::Formatter::Interface @style : ACON::Formatter::NullStyle? = nil def decorated=(@decorated : Bool) end def decorated? : Bool false end def set_style(name : String, style : ACON::Formatter::OutputStyleInterface) : Nil end def has_style?(name : String) : Bool false end def style(name : String) : ACON::Formatter::OutputStyleInterface @style ||= ACON::Formatter::NullStyle.new end def format(message : String?) : String message end end ================================================ FILE: src/components/console/src/formatter/null_style.cr ================================================ # :nodoc: class Athena::Console::Formatter::NullStyle include Athena::Console::Formatter::OutputStyleInterface # :inherit: def foreground=(foreground : Colorize::Color) end # :inherit: def background=(background : Colorize::Color) end # :inherit: def add_option(option : Colorize::Mode) : Nil end # :inherit: def remove_option(option : Colorize::Mode) : Nil end # :inherit: def apply(text : String) : String text end end ================================================ FILE: src/components/console/src/formatter/output.cr ================================================ require "./wrappable_interface" # Default implementation of `ACON::Formatter::WrappableInterface`. class Athena::Console::Formatter::Output include Athena::Console::Formatter::WrappableInterface # Returns a new string where the special `<` characters in the provided *text* are escaped. def self.escape(text : String) : String text = text.gsub /([^\\\\]?)]*+ | \\.)*) | \/([a-z][^<>]*+)?)>/ix) do |match| pos = match.begin.not_nil! text = match[0] next if pos != 0 && '\\' == message[pos - 1] # Add text up to next tag. output += self.apply_current_style message[offset, pos - offset], output, width offset = pos + text.size tag = if open = '/' != text.char_at(1) match[2] else match[3]? || "" end if !open && !tag.presence # @style_stack.pop elsif (style = self.create_style_from_string(tag)).nil? output += self.apply_current_style text, output, width elsif open @style_stack << style else @style_stack.pop style end end output += self.apply_current_style message[offset...], output, width if output.includes? '\0' return output .gsub("\0", '\\') .gsub("\\<", '<') end output.gsub /\\ 201_100) end def initialize(foreground : Colorize::Color | String = :default, background : Colorize::Color | String = :default, @options : Colorize::Mode = :none) self.foreground = foreground self.background = background end # :inherit: def add_option(option : Colorize::Mode) : Nil @options |= option end # :ditto: def add_option(option : String) : Nil self.add_option Colorize::Mode.parse option end # :inherit: def background=(color : String) if hex_value = color.lchop? '#' r, g, b = hex_value.hexbytes return @background = Colorize::ColorRGB.new r, g, b end @background = Colorize::ColorANSI.parse color end # :inherit: def foreground=(foreground : String) if hex_value = foreground.lchop? '#' r, g, b = hex_value.hexbytes return @foreground = Colorize::ColorRGB.new r, g, b end @foreground = Colorize::ColorANSI.parse foreground end # :inherit: def remove_option(option : Colorize::Mode) : Nil @options ^= option end # :ditto: def remove_option(option : String) : Nil self.remove_option Colorize::Mode.parse option end # :inherit: def apply(text : String) : String if (href = @href) && self.handles_href_gracefully? text = "\e]8;;#{href}\e\\#{text}\e]8;;\e\\" end return text if self.default? apply_color text end # TODO: Remove methods below when/if https://github.com/crystal-lang/crystal/pull/16052 is merged/released. # Should then bump min crystal version. private def apply_color(text : String) : String String.build do |io| printed = false io << "\e[" unless @foreground == Colorize::ColorANSI::Default @foreground.fore io printed = true end unless @background == Colorize::ColorANSI::Default io << ';' if printed @background.back io printed = true end each_code(@options) do |flag| io << ';' if printed io << flag printed = true end io << 'm' io << text printed = false io << "\e[" unless @foreground == Colorize::ColorANSI::Default io << ';' if printed io << 39 printed = true end unless @background == Colorize::ColorANSI::Default io << ';' if printed io << 49 printed = true end each_code(@options, true) do |flag| io << ';' if printed io << flag printed = true end io << 'm' end end private def default? : Bool @foreground == Colorize::ColorANSI::Default && @background == Colorize::ColorANSI::Default && @options.none? end # ameba:disable Metrics/CyclomaticComplexity private def each_code(mode : Colorize::Mode, unset : Bool = false, &) yield (unset ? "22" : "1") if mode.bold? yield (unset ? "22" : "2") if mode.dim? yield (unset ? "23" : "3") if mode.italic? yield (unset ? "24" : "4") if mode.underline? yield (unset ? "25" : "5") if mode.blink? yield (unset ? "26" : "6") if mode.blink_fast? yield (unset ? "27" : "7") if mode.reverse? yield (unset ? "28" : "8") if mode.hidden? yield (unset ? "29" : "9") if mode.strikethrough? yield (unset ? "24" : "21") if mode.double_underline? yield (unset ? "55" : "53") if mode.overline? end end ================================================ FILE: src/components/console/src/formatter/output_style_interface.cr ================================================ require "colorize" # Output styles represent reusable formatting information that can be used when formatting output messages. # `Athena::Console` comes bundled with a few common styles including: # # * error # * info # * comment # * question # # Whenever you output text via an `ACON::Output::Interface`, you can surround the text with tags to color its output. For example: # # ``` # # Green text # output.puts "foo" # # # Yellow text # output.puts "foo" # # # Black text on a cyan background # output.puts "foo" # # # White text on a red background # output.puts "foo" # ``` # # ## Custom Styles # # Custom styles can also be defined/used: # # ``` # my_style = ACON::Formatter::OutputStyle.new :red, "#f87b05", Colorize::Mode[:bold, :underline] # output.formatter.set_style "fire", my_style # # output.puts "foo" # ``` # # ### Global Custom Styles # # You can also make your style global by extending `ACON::Application` and adding it within the `#configure_io` method: # # ``` # class MyCustomApplication < ACON::Application # protected def configure_io(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil # super # # my_style = ACON::Formatter::OutputStyle.new :red, "#f87b05", Colorize::Mode[:bold, :underline] # output.formatter.set_style "fire", my_style # end # end # ``` # # ## Inline Styles # # Styles can also be defined inline when printing a message: # # ``` # # Using named colors # output.puts "foo" # # # Using hexadecimal colors # output.puts "foo" # # # Black text on a cyan background # output.puts "foo" # # # Bold text on a yellow background # output.puts "foo" # # # Bold text with underline. # output.puts "foo" # ``` # # ## Clickable Links # # Commands can use the special `href` tag to display links within the console. # # ``` # output.puts "Athena" # ``` # # If your terminal [supports](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) it, you would be able to click # the text and have it open in your default browser. Otherwise, you will see it as regular text. module Athena::Console::Formatter::OutputStyleInterface # Sets the foreground color of `self`. abstract def foreground=(foreground : Colorize::Color) # Sets the background color of `self`. abstract def background=(background : Colorize::Color) # Adds a text mode to `self`. abstract def add_option(option : Colorize::Mode) : Nil # Removes a text mode to `self`. abstract def remove_option(option : Colorize::Mode) : Nil # Applies `self` to the provided *text*. abstract def apply(text : String) : String end ================================================ FILE: src/components/console/src/formatter/wrappable_interface.cr ================================================ require "./interface" # Extension of `ACON::Formatter::Interface` that supports word wrapping. module Athena::Console::Formatter::WrappableInterface include Athena::Console::Formatter::Interface # Formats the provided *message* according to the defined styles, wrapping it at the provided *width*. # A width of `0` means no wrapping. abstract def format_and_wrap(message : String?, width : Int32) : String end ================================================ FILE: src/components/console/src/helper/athena_question.cr ================================================ abstract class Athena::Console::Helper; end require "./question" # Extension of `ACON::Helper::Question` that provides more structured output. # # See `ACON::Style::Athena`. class Athena::Console::Helper::AthenaQuestion < Athena::Console::Helper::Question protected def write_error(output : ACON::Output::Interface, error : ::Exception) : Nil if output.is_a? ACON::Style::Athena output.new_line output.error error.message || "" return end super end # ameba:disable Metrics/CyclomaticComplexity protected def write_prompt(output : ACON::Output::Interface, question : ACON::Question::Base) : Nil text = ACON::Formatter::Output.escape_trailing_backslash question.question default = question.default if question.multi_line? text = "#{text} (press #{self.eof_shortcut} to continue)" end text = if default.nil? " #{text}:" elsif question.is_a? ACON::Question::Confirmation %( #{text} (yes/no) [#{default ? "yes" : "no"}]:) elsif question.is_a? ACON::Question::MultipleChoice choices = question.choices default = case default when String then default.split(',').map! do |item| if idx = item.to_i? item = idx end choices[item]? || item.to_s end else [default] end %( #{text} [#{ACON::Formatter::Output.escape default.join(", ")}]:) elsif question.is_a? ACON::Question::Choice choices = question.choices " #{text} [#{ACON::Formatter::Output.escape default.to_s}]:" else " #{text} [#{ACON::Formatter::Output.escape default.to_s}]:" end output.puts text prompt = " > " if question.is_a? ACON::Question::AbstractChoice output.puts self.format_choice_question_choices question, "comment" prompt = question.prompt end output.print prompt end private def eof_shortcut : String # TODO: Windows uses Ctrl+Z + Enter "Ctrl+D" end end ================================================ FILE: src/components/console/src/helper/descriptor_helper.cr ================================================ # :nodoc: class Athena::Console::Helper::Descriptor < Athena::Console::Helper @descriptors = Hash(String, ACON::Descriptor::Interface).new def initialize self.register "txt", ACON::Descriptor::Text.new end def describe(output : ACON::Output::Interface, object : _, context : ACON::Descriptor::Context) : Nil raise ACON::Exception::InvalidArgument.new "Unsupported format #{context.format}." unless descriptor = @descriptors[context.format]? descriptor.describe output, object, context end def register(format : String, descriptor : ACON::Descriptor::Interface) : self @descriptors[format] = descriptor self end def formats : Array(String) @descriptors.keys end end ================================================ FILE: src/components/console/src/helper/formatter.cr ================================================ # Provides additional ways to format output messages than `ACON::Formatter::OutputStyle` can do alone, such as: # # * Printing messages in a section # * Printing messages in a block # * Print truncated messages. # # The provided methods return a `String` which could then be passed to `ACON::Output::Interface#print` or `ACON::Output::Interface#puts`. class Athena::Console::Helper::Formatter < Athena::Console::Helper # Prints the provided *message* in the provided *section*. # Optionally allows setting the *style* of the section. # # ```text # [SomeSection] Here is some message related to that section # ``` # # ``` # output.puts formatter.format_section "SomeSection", "Here is some message related to that section" # ``` def format_section(section : String, message : String, style : String = "info") : String "<#{style}>[#{section}] #{message}" end # Prints the provided *messages* in a block formatted according to the provided *style*, with a total width a bit more than the longest line. # # The *large* options adds additional padding, one blank line above and below the messages, and 2 more spaces on the left and right. # # ``` # output.puts formatter.format_block({"Error!", "Something went wrong"}, "error", true) # ``` def format_block(messages : String | Enumerable(String), style : String, large : Bool = false) messages = messages.is_a?(String) ? {messages} : messages len = 0 lines = [] of String messages.each do |message| message = ACON::Formatter::Output.escape message lines << (large ? " #{message} " : " #{message} ") len = Math.max (message.size + (large ? 4 : 2)), len end messages = large ? [" " * len] : [] of String lines.each do |line| messages << %(#{line}#{" " * (len - line.delete('\\').size)}) end if large messages << " " * len end messages.each_with_index do |line, idx| messages[idx] = "<#{style}>#{line}" end messages.join '\n' end # Truncates the provided *message* to be at most *length* characters long, # with the optional *suffix* appended to the end. # # ``` # message = "This is a very long message, which should be truncated" # truncated_message = formatter.truncate message, 7 # output.puts truncated_message # => This is... # ``` # # If *length* is negative, it will start truncating from the end. # # ``` # message = "This is a very long message, which should be truncated" # truncated_message = formatter.truncate message, -4 # output.puts truncated_message # => This is a very long message, which should be trunc... # ``` def truncate(message : String, length : Int, suffix : String = "...") : String computed_length = length - self.class.width suffix if computed_length > self.class.width message return message end "#{message[0...length]}#{suffix}" end end ================================================ FILE: src/components/console/src/helper/helper.cr ================================================ require "./interface" # Contains `ACON::Helper::Interface` implementations that can be used to help with various tasks. # Such as asking questions, customizing the output format, or generating tables. # # This class also acts as a base type that implements common functionality between each helper. abstract class Athena::Console::Helper include Athena::Console::Helper::Interface private TIME_FORMATS = { {0, "< 1 sec", nil}, {1, "1 sec", nil}, {2, "secs", 1}, {60, "1 min", nil}, {120, "mins", 60}, {3_600, "1 hr", nil}, {7_200, "hrs", 3_600}, {86_400, "1 day", nil}, {172_800, "days", 86_400}, } # Formats the provided *span* of time as a human readable string. # # ``` # ACON::Helper.format_time 10.seconds # => "10 secs" # ACON::Helper.format_time 4.minutes # => "4 mins" # ACON::Helper.format_time 74.minutes # => "1 hr" # ``` def self.format_time(span : Time::Span) : String self.format_time span.total_seconds end # Formats the provided *seconds* as a human readable string. # # ``` # ACON::Helper.format_time 10 # => "10 secs" # ACON::Helper.format_time 240 # => "4 mins" # ACON::Helper.format_time 4400 # => "1 hr" # ``` def self.format_time(seconds : Number) : String TIME_FORMATS.each_with_index do |format, idx| min_seconds, label, max_seconds = format next unless seconds >= min_seconds if ((next_format = TIME_FORMATS[idx + 1]?) && (seconds < next_format[0])) || idx == TIME_FORMATS.size - 1 return label if max_seconds.nil? return "#{(seconds // max_seconds).to_i} #{label}" end end raise "BUG: Unable to format time: #{seconds}." end # Returns a new string with all of its ANSI formatting removed. def self.remove_decoration(formatter : ACON::Formatter::Interface, string : String) : String is_decorated = formatter.decorated? formatter.decorated = false # Remove <...> formatting string = formatter.format string # Remove already formatted characters string = string.gsub /\033\[[^m]*m/, "" # Remove terminal hyperlinks string = string.gsub /\033]8;[^;]*;[^\033]*\033\\/, "" formatter.decorated = is_decorated string end # Returns the width of a string; where the width is how many character positions the string will use. # # TODO: Support double width chars. def self.width(string : String) : Int32 string.size end property helper_set : ACON::Helper::HelperSet? = nil end ================================================ FILE: src/components/console/src/helper/helper_set.cr ================================================ # The container that stores various `ACON::Helper::Interface` implementations, keyed by their class. # # Each application includes a default helper set, but additional ones may be added. # See `ACON::Application#helper_set`. # # These helpers can be accessed from within a command via the `ACON::Command#helper` method. class Athena::Console::Helper::HelperSet @helpers = Hash(ACON::Helper.class, ACON::Helper::Interface).new def self.new(*helpers : ACON::Helper::Interface) : self helper_set = new helpers.each do |helper| helper_set << helper end helper_set end def initialize(@helpers : Hash(ACON::Helper.class, ACON::Helper::Interface) = Hash(ACON::Helper.class, ACON::Helper::Interface).new); end # Adds the provided *helper* to `self`. def <<(helper : ACON::Helper::Interface) : Nil @helpers[helper.class] = helper helper.helper_set = self end # Returns `true` if `self` has a helper for the provided *helper_class*, otherwise `false`. def has?(helper_class : ACON::Helper.class) : Bool @helpers.has_key? helper_class end # Returns the helper of the provided *helper_class*, or `nil` if it is not defined. def []?(helper_class : T.class) : T? forall T {% unless T <= ACON::Helper::Interface T.raise "Helper class type '#{T}' is not an 'ACON::Helper::Interface'." end %} @helpers[helper_class]?.as? T end # Returns the helper of the provided *helper_class*, or raises if it is not defined. def [](helper_class : T.class) : T forall T self.[helper_class]? || raise ACON::Exception::InvalidArgument.new "The helper '#{helper_class}' is not defined." end end ================================================ FILE: src/components/console/src/helper/interface.cr ================================================ module Athena::Console::Helper::Interface # Sets the `ACON::Helper::HelperSet` related to `self`. abstract def helper_set=(helper_set : ACON::Helper::HelperSet?) # Returns the `ACON::Helper::HelperSet` related to `self`, if any. abstract def helper_set : ACON::Helper::HelperSet? end ================================================ FILE: src/components/console/src/helper/output_wrapper.cr ================================================ # :nodoc: # # Adapted from https://github.com/symfony/symfony/blob/fbf6f56ca7321e28d9a4368e18b9da683c296046/src/Symfony/Component/Console/Helper/OutputWrapper.php struct Athena::Console::Helper::OutputWrapper def initialize(@allow_cut_urls : Bool = false); end def wrap(text : String, width : Int32, separator : String = "\n") : String return text if width.zero? row_pattern = if @allow_cut_urls %r((?:<(?:(?:[a-z](?:[^\\<>]*+ | \\.)*)|/(?:[a-z][^<>]*+)?)>|.){1,#{width}}) else %r((?:<(?:(?:[a-z](?:[^\\<>]*+ | \\.)*)|/(?:[a-z][^<>]*+)?)>|.|https?://\S+){1,#{width}}) end pattern = %r((?:((?>(#{row_pattern.source})((?<=[^\S\r\n])[^\S\r\n]?|(?=\r?\n)|$|[^\S\r\n]))|(#{row_pattern.source}))(?:\r?\n)?|(?:\r?\n|$)))imx text .gsub(pattern, "\\0#{separator}") .rstrip(separator) .gsub " #{separator}", separator end end ================================================ FILE: src/components/console/src/helper/progress_bar.cr ================================================ abstract class Athena::Console::Output; end require "../output/interface" # When executing longer-running commands, it can be helpful to show progress information that updates as the command runs: # # ![Progress Bar](/img/progress_bar.gif) # # TIP: Consider using `ACON::Style::Athena` to display a progress bar. # # The ProgressBar helper can be used to progress information to any `ACON::Output::Interface`: # # ``` # # Create a new progress bar with 50 required units for completion. # progress_bar = ACON::Helper::ProgressBar.new output, 50 # # # Start and display the progress bar. # progress_bar.start # # 50.times do # # Do work # # # Advance the progress bar by 1 unit. # progress_bar.advance # # # Or advance by more than a single unit. # # progress_bar.advance 3 # end # # # Ensure progress bar is at 100%. # progress_bar.finish # ``` # # A progress bar can also be created without a required number of units, in which case it will just act as a [throbber](https://en.wikipedia.org/wiki/Throbber). # However, `#max_steps=` can be called at any point to either set, or increase the required number of units. # E.g. if its only known after performing some calculations, or additional work is needed such that the original value is not invalid. # # TIP: Consider using an `ACON::Helper::ProgressIndicator` instead of a progress bar for this use case. # # Be sure to call `#finish` when the task completes to ensure the progress bar is refreshed with a 100% completion. # # NOTE: By default the progress bar will write its output to `STDERR`, however this can be customized by using an `ACON::Output::IO` explicitly. # # If the progress information is stored within an [Enumerable](https://crystal-lang.org/api/Enumerable.html) type, the `#iterate` method # can be used to start, advance, and finish the progress bar automatically, yielding each item in the collection: # # ``` # bar = ACON::Helper::ProgressBar.new output # arr = [1, 2, 3] # # bar.iterate(arr) do |item| # # Do something # end # ``` # # Which would output: # ```text # 0/2 [>---------------------------] 0% # 1/2 [==============>-------------] 50% # 2/2 [============================] 100% # ``` # # NOTE: `Iterator` types are also supported, but need the max value provided explicitly via the second argument to `#iterate` if known. # # ### Progressing # # While the `#advance` method can be used to move the progress bar ahead by a specific number of steps, # the current step can be set explicitly via `#progress=`. # # It is also possible to start the progress bar at a specific step, which is useful when resuming some long-standing task: # # ``` # # Create a 100 unit progress bar. # progress_bar = ACON::Helper::ProgressBar.new output, 100 # # # Display the progress bar starting at already 25% complete. # progress_bar.start at: 25 # ``` # # TIP: The progress can also be regressed (stepped backwards) by providing `#advance` a negative value. # # ### Controlling Rendering # # If available, [ANCI Escape Codes](https://en.wikipedia.org/wiki/ANSI_escape_code) are used to handle the rendering of the progress bar, # otherwise updates are added as new lines. `#minimum_seconds_between_redraws=` can be used to prevent the output being flooded. # `#redraw_frequency=` can be used to to redraw every _N_ iterations. By default, redraw frequency is **100ms** or **10%** of your `#max_steps`. # # ## Customizing # # ### Built-in Formats # # The progress bar comes with a few built-in formats based on the `ACON::Output::Verbosity` the command was executed with: # # ```text # # Verbosity::NORMAL (CLI with no verbosity flag) # 0/3 [>---------------------------] 0% # 1/3 [=========>------------------] 33% # 3/3 [============================] 100% # # # Verbosity::VERBOSE (-v) # 0/3 [>---------------------------] 0% 1 sec # 1/3 [=========>------------------] 33% 1 sec # 3/3 [============================] 100% 1 sec # # # Verbosity::VERY_VERBOSE (-vv) # 0/3 [>---------------------------] 0% 1 sec/1 sec # 1/3 [=========>------------------] 33% 1 sec/1 sec # 3/3 [============================] 100% 1 sec/1 sec # # # Verbosity::DEBUG (-vvv) # 0/3 [>---------------------------] 0% 1 sec/1 sec 1kiB # 1/3 [=========>------------------] 33% 1 sec/1 sec 1kiB # 3/3 [============================] 100% 1 sec/1 sec 1kiB # ``` # # NOTE: If a command called with `ACON::Output::Verbosity::QUIET`, the progress bar will not be displayed. # # The format may also be set explicitly in code via: # # ``` # # If the progress bar has a maximum number of steps. # bar.format = :very_verbose # # # Without a maximum # bar.format = :very_verbose_nomax # ``` # # ### Custom Formats # # While the built-in formats are sufficient for most use cases, custom ones may also be defined: # # ``` # bar.format = "%bar%" # ``` # # Which would set the format to only display the progress bar itself: # # ```text # >--------------------------- # =========>------------------ # ============================ # ``` # # A progress bar format is a string that contains specific placeholders (a name enclosed with the `%` character); # the placeholders are replaced based on the current progress of the bar. The built-in placeholders include: # # * `%current%` - The current step # * `%max%` - The maximum number of steps (or zero if there is not one) # * `%bar%` - The progress bar itself # * `%percent%` - The percentage of completion (not available if no max is defined) # * `%elapsed%` - The time elapsed since the start of the progress bar # * `%remaining%` - The remaining time to complete the task (not available if no max is defined) # * `%estimated%` - The estimated time to complete the task (not available if no max is defined) # * `%memory%` - The current memory usage # * `%message%` - Used to display arbitrary messages, more on this later # # For example, the format string for `ACON::Helper::ProgressBar::Format::NORMAL` is `" %current% [%bar%] %elapsed:6s%"`. # Individual placeholders can have their formatting tweaked by anything that [sprintf](https://crystal-lang.org/api/toplevel.html#sprintf(format_string,args:Array|Tuple):String-class-method) supports # by separating the name of the placeholder with a `:`. # The part after the colon will be passed to `sprintf`. # # If a format should be used across an entire application, they can be registered globally via `.set_format_definition`: # # ``` # ACON::Helper::ProgressBar.set_format_definition "minimal", "Progress: %percent%%" # # bar = ACON::Helper::ProgressBar.new output, 3 # bar.format = "minimal" # ``` # # Which would output: # # ```text # Progress: 0% # Progress: 33% # Progress: 100% # ``` # # TIP: It is almost always better to override the built-in formats in order to automatically vary the display based on the verbosity the command is being ran with. # # When creating a custom format, be sure to also define a `_nomax` variant if it is using a placeholder that is only available if `#max_steps` is defined. # # ``` # ACON::Helper::ProgressBar.set_format_definition "minimal", "%current%/%remaining%" # ACON::Helper::ProgressBar.set_format_definition "minimal_nomax", "%current%" # # bar = ACON::Helper::ProgressBar.new output, 3 # bar.format = "minimal" # ``` # # The format will automatically be set to `minimal_nomax` if the bar does not have a maximum number of steps. # # TIP: A format can contain any valid ANSI codes, or any `ACON::Formatter::OutputStyleInterface` markup. # # TIP: A format may also span multiple lines, which can be useful to also display contextual information (like the first example). # # ### Bar Settings # # The `bar` placeholder is a bit special in that all of the characters used to display it can be customized: # # ``` # # The Finished part of the bar. # bar.bar_character = "=" # # # The unfinished part of the bar. # bar.empty_bar_character = " " # # # The progress character. # bar.progress_character = "|" # # # The width of the bar. # bar.bar_width = 50 # ``` # # ### Custom Placeholders # # Just like the format, custom placeholders may also be defined. # This can be useful to have a common way of displaying some sort of application specific information between multiple progress bars: # # ``` # ACON::Helper::ProgressBar.set_placeholder_formatter "remaining_steps" do |bar| # "#{bar.max_steps - bar.progress}" # end # ``` # # From here it could then be used in a format string as `%remaining_steps%` just like any other placeholder. # `.set_placeholder_formatter` registers the format globally, while `#set_placeholder_formatter` would set it on a specific progress bar. # # ### Custom Messages # # While there is a built-in `message` placeholder that can be set via `#set_message`, none of the built-in formats include it. # As such, before displaying these messages, a custom format needs to be defined: # # ``` # bar = ACON::Helper::ProgressBar.new output, 100 # bar.format = " %current%/%max% -- %message%" # # bar.set_message "Start" # bar.start # 0/100 -- Start # # bar.set_message "Task is in progress..." # bar.advance # 1/100 -- Task is in progress... # ``` # # `#set_message` also allows or an optional second argument, which can be used to have multiple independent messages within the same format string: # # ``` # files.each do |file_name| # bar.set_message "Importing files..." # bar.set_message file_name, "filename" # bar.advance # => 2/100 -- Importing files... (foo/bar.txt) # end # ``` # # ## Multiple Progress Bars # # When using `ACON::Output::Section`s, multiple progress bars can be displayed at the same time and updated independently: # # ``` # output = output.as ACON::Output::ConsoleOutputInterface # # section1 = output.section # section2 = output.section # # bar1 = ACON::Helper::ProgressBar.new section1 # bar2 = ACON::Helper::ProgressBar.new section2 # # bar1.start 100 # bar2.start 100 # # 100.times do |idx| # bar1.advance # bar2.advance(4) if idx.divisible_by? 2 # # sleep 0.05.seconds # end # ``` # # Which would ultimately look something like: # # ```text # 34/100 [=========>------------------] 34% # 68/100 [===================>--------] 68% # ``` class Athena::Console::Helper::ProgressBar # Represents the built in progress bar formats. # # See [Built-In Formats][Athena::Console::Helper::ProgressBar--built-in-formats] for more information. enum Format # `" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%"` DEBUG # `" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%"` VERY_VERBOSE # `" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%"` VERBOSE # `" %current%/%max% [%bar%] %percent:3s%%"` NORMAL # `" %current% [%bar%] %elapsed:6s% %memory:6s%"` DEBUG_NOMAX # `" %current% [%bar%] %elapsed:6s%"` VERBOSE_NOMAX # `" %current% [%bar%] %elapsed:6s%"` VERY_VERBOSE_NOMAX # `" %current% [%bar%]"` NORMAL_NOMAX end # Represents the expected type of a [Placeholder Formatter][Athena::Console::Helper::ProgressBar--custom-placeholders]. alias PlaceholderFormatter = Proc(Athena::Console::Helper::ProgressBar, Athena::Console::Output::Interface, String) # INTERNAL protected class_getter formats : Hash(String, String) { self.init_formats } # INTERNAL protected class_getter placeholder_formatters : Hash(String, PlaceholderFormatter) { self.init_placeholder_formatters } # Registers the *format* globally with the provided *name*. def self.set_format_definition(name : String, format : String) : Nil self.formats[name] = format end # Returns the global format string for the provided *name* if it exists, otherwise `nil`. def self.format_definition(name : String) : String? self.formats[name]? end private def self.init_formats : Hash(String, String) { "debug" => " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%", "debug_nomax" => " %current% [%bar%] %elapsed:6s% %memory:6s%", "very_verbose" => " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%", "very_verbose_nomax" => " %current% [%bar%] %elapsed:6s%", "verbose" => " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%", "verbose_nomax" => " %current% [%bar%] %elapsed:6s%", "normal" => " %current%/%max% [%bar%] %percent:3s%%", "normal_nomax" => " %current% [%bar%]", } end # Registers a custom placeholder with the provided *name* with the block being the formatter. def self.set_placeholder_formatter(name : String, &block : self, ACON::Output::Interface -> String) : Nil self.set_placeholder_formatter name, block end # Registers a custom placeholder with the provided *name*, using the provided *callable* as the formatter. def self.set_placeholder_formatter(name : String, callable : ACON::Helper::ProgressBar::PlaceholderFormatter) : Nil self.placeholder_formatters[name] = callable end # Returns the global formatter for the provided *name* if it exists, otherwise `nil`. def self.placeholder_formatter(name : String) : ACON::Helper::ProgressBar::PlaceholderFormatter? self.placeholder_formatters[name]? end private def self.init_placeholder_formatters : Hash(String, PlaceholderFormatter) { "bar" => PlaceholderFormatter.new do |bar, output| completed_bars = bar.bar_offset display = bar.bar_character * completed_bars if completed_bars < bar.bar_width empty_bars = bar.bar_width - completed_bars - ACON::Helper.width(ACON::Helper.remove_decoration(output.formatter, bar.progress_character)) display += "#{bar.progress_character}#{bar.empty_bar_character * empty_bars}" end display end, "remaining" => PlaceholderFormatter.new do |bar, _| if bar.max_steps.zero? raise ACON::Exception::Logic.new "Unable to display the remaining time if the maximum number of steps is not set." end ACON::Helper.format_time bar.remaining end, "estimated" => PlaceholderFormatter.new do |bar, _| if bar.max_steps.zero? raise ACON::Exception::Logic.new "Unable to display the remaining time if the maximum number of steps is not set." end ACON::Helper.format_time bar.estimated end, "memory" => PlaceholderFormatter.new { |_| (GC.stats.heap_size - GC.stats.free_bytes).humanize_bytes }, "elapsed" => PlaceholderFormatter.new { |bar| ACON::Helper.format_time bar.clock.now - bar.start_time }, "current" => PlaceholderFormatter.new { |bar| bar.progress.to_s.rjust bar.step_width, ' ' }, "max" => PlaceholderFormatter.new(&.max_steps.to_s), "percent" => PlaceholderFormatter.new { |bar| (bar.progress_percent * 100).floor.to_i.to_s }, } end @output : ACON::Output::Interface @terminal : ACON::Terminal @cursor : ACON::Cursor @max : Int32 = 0 @redraw_frequency : Int32? = 1 @format : String? = nil @internal_format : String? = nil @step : Int32 = 0 @starting_step : Int32 = 0 @percent : Float64 = 0.0 @last_write_time : Time = Time::UNIX_EPOCH @previous_message : String? = nil @write_count : Int32 = 0 @messages : Hash(String, String) = Hash(String, String).new @placeholder_formatters : Hash(String, PlaceholderFormatter) = Hash(String, PlaceholderFormatter).new protected getter clock : ACLK::Interface # Returns the time the progress bar was started as a Unix epoch. getter start_time : Time # Returns the width of the progress bar in pixels. # # ``` # bar1 = ... # bar1.bar_width = 50 # bar1.start 10 # # bar2 = ... # bar2.bar_width = 10 # bar2.start 20 # # bar1.finish # bar2.finish # ``` # # ``` # 10/10 [==================================================] 100% # 20/20 [==========] 100% # ``` getter bar_width : Int32 = 28 # Explicitly sets the character to use for the finished part of the bar. setter bar_character : String? = nil # Represents the character used for the unfinished part of the bar. property empty_bar_character : String = "-" # Represents the character used for the current progress of the bar. property progress_character : String = ">" # Sets if the progress bar should overwrite the progress bar. # Set to `false` in order to print the progress bar on a new line for each update. setter overwrite : Bool = true # Returns the width in pixels that the current `#progress` takes up when displayed. getter! step_width : Int32 # Sets the minimum amount of time between redraws. # # See [Controlling Rendering][Athena::Console::Helper::ProgressBar--controlling-rendering] for more information. setter minimum_seconds_between_redraws : Float64 = 0 # Sets the maximum amount of time between redraws. # # See [Controlling Rendering][Athena::Console::Helper::ProgressBar--controlling-rendering] for more information. setter maximum_seconds_between_redraws : Float64 = 1 def initialize( output : ACON::Output::Interface, max : Int32? = nil, minimum_seconds_between_redraws : Float64 = 0.04, @clock : ACLK::Interface = ACLK::Native.new, ) if output.is_a? ACON::Output::ConsoleOutputInterface output = output.error_output end @output = output @terminal = ACON::Terminal.new if 0 < minimum_seconds_between_redraws @redraw_frequency = nil @minimum_seconds_between_redraws = minimum_seconds_between_redraws end unless @output.decorated? # Disable overwrite when output does not support ANSI codes. @overwrite = false # Set a reasonable redraw freq so output isn't flooded @redraw_frequency = nil end @start_time = @clock.now @cursor = ACON::Cursor.new @output self.max_steps = max || 0 end # Sets what built in *format* to use. # See [Built-in Formats][Athena::Console::Helper::ProgressBar--built-in-formats] for more information. def format=(format : ACON::Helper::ProgressBar::Format) self.format = format.to_s.downcase end # Sets the format string used to determine how to display the progress bar. # See [Custom Formats][Athena::Console::Helper::ProgressBar--custom-formats] for more information. def format=(format : String) @format = nil @internal_format = format end # Returns the current step of the progress bar def progress : Int32 @step end # Returns the maximum number of possible steps, or `0` if it is unknown. def max_steps : Int32 @max end # Sets the maximum possible steps to the provided *max*. def max_steps=(max : Int32) : Nil @format = nil @max = Math.max 0, max @step_width = @max > 0 ? ACON::Helper.width(@max.to_s) : 4 end # Returns the a percent of progress of `#progress` versus `#max_steps`. # Returns zero if there is no max defined. def progress_percent : Float64 @percent end # Returns the character to use for the finished part of the bar. def bar_character : String @bar_character || (@max > 0 ? "=" : @empty_bar_character) end # Sets the width of the bar in pixels to the provided *size*. # See `#bar_width`. def bar_width=(size : Int32) : Nil @bar_width = Math.max 1, size end # Returns the amount of `#bar_character` representing the current `#progress`. def bar_offset : Int32 if @max > 0 return (@percent * @bar_width).floor.to_i end if @redraw_frequency.nil? return ((Math.min(5, bar_width / 15) * @write_count) % @bar_width).floor.to_i end (@step % @bar_width).floor.to_i end # Returns an estimated amount of time in seconds until the progress bar is completed. def estimated : Float64 return 0.0 if @step.zero? || @step == @starting_step ((@clock.now - @start_time).total_seconds / (@step - @starting_step) * @max).round end # Returns an estimated total amount of time in seconds needed for the progress bar to complete. def remaining : Float64 return 0.0 if @step.zero? ((@clock.now - @start_time).total_seconds / (@step - @starting_step) * (@max - @step)).round 0 end # Returns the amount of time in seconds until the progress bar is completed. def placeholder_formatter(name : String) : ACON::Helper::ProgressBar::PlaceholderFormatter? @placeholder_formatters[name]? || self.class.placeholder_formatter name end # Same as `.set_placeholder_formatter`, but scoped to this particular progress bar. def set_placeholder_formatter(name : String, &block : self, ACON::Output::Interface -> String) : Nil self.set_placeholder_formatter name, block end # Same as `.set_placeholder_formatter`, but scoped to this particular progress bar. def set_placeholder_formatter(name : String, callable : ACON::Helper::ProgressBar::PlaceholderFormatter) : Nil @placeholder_formatters[name] = callable end # Sets the message with the provided *name* to that of the provided *message*. def set_message(message : String, name : String = "message") : Nil @messages[name] = message end # Returns the message associated with the provided *name* if defined, otherwise `nil`. def message(name : String = "message") : String? @messages[name]? end # Redraw the progress bar every after advancing the provided amount of *steps*. # # See [Controlling Rendering][Athena::Console::Helper::ProgressBar--controlling-rendering] for more information. def redraw_frequency=(steps : Int32?) : Nil @redraw_frequency = steps.try { |s| Math.max 1, s } end # Clears the progress bar from the output. # Can be used in conjunction with `#display` to allow outputting something while a progress bar is running. # Call `#clear`, write the content, then call `#display` to show the progress bar again. # # NOTE: Requires that `#overwrite=` be set to `true`. def clear : Nil return unless @overwrite if @format.nil? self.set_real_format @internal_format || self.determine_best_format.to_s.downcase end self.overwrite "" end # Starts the progress bar. # # Optionally sets the maximum number of steps to *max*, or `nil` to leave unchanged. # Optionally starts the progress bar *at* the provided step. def start(max : Int32? = nil, at start_at : Int32 = 0) : Nil @start_time = @clock.now @step = start_at @starting_step = start_at if start_at > 0 self.progress = start_at else @percent = 0.0 end unless max.nil? self.max_steps = max end self.display end # Advanced the progress bar *by* the provided number of steps. def advance(by step : Int32 = 1) : Nil self.progress = @step + step end # Explicitly sets the current step number of the progress bar. # # ameba:disable Metrics/CyclomaticComplexity def progress=(step : Int32) : Nil if @max > 0 && (step > @max) @max = step elsif step < 0 step = 0 end redraw_frequency = @redraw_frequency || ((@max > 0 ? @max : 10) / 10) previous_period = @step // redraw_frequency current_period = step // redraw_frequency @step = step @percent = @max > 0 ? @step / @max : 0.0 time_interval = @clock.now - @last_write_time # Draw regardless of other limits if @max == step self.display return end # Throttling if time_interval.total_seconds < @minimum_seconds_between_redraws return end # Draw each step period, but not too late if previous_period != current_period || time_interval.total_seconds >= @maximum_seconds_between_redraws self.display end end # Displays the progress bar's current state. def display : Nil return if @output.verbosity.quiet? if @format.nil? self.set_real_format @internal_format || self.determine_best_format.to_s.downcase end self.overwrite self.build_line end # Finishes the progress output, making it 100% complete. def finish if @max.zero? @max = @step end if @step == @max && !@overwrite # Prevent double 100% output return end self.progress = @max end # Start, advance, and finish the progress bar automatically, yielding each item in the provided *enumerable*. # # ``` # bar = ACON::Helper::ProgressBar.new output # arr = [1, 2, 3] # # bar.iterate(arr) do |item| # # Do something # end # ``` # # Which would output: # ``` # 0/2 [>---------------------------] 0% # 1/2 [==============>-------------] 50% # 2/2 [============================] 100% # ``` # # NOTE: `Iterator` types are also supported, but need the max value provided explicitly via the second argument to `#iterate` if known. def iterate(enumerable : Enumerable(T), max : Int32? = nil, & : T -> Nil) : Nil forall T self.start(enumerable.is_a?(Indexable) ? enumerable.size : 0) enumerable.each do |value| yield value self.advance end self.finish end private def overwrite(message : String) : Nil return if message == @previous_message original_message = message if @overwrite if previous_message = @previous_message if (output = @output).is_a? ACON::Output::Section message_lines = previous_message.split '\n' # Don't use `#lines` to retain empty values line_count = message_lines.size last_line_without_decoration = ACON::Helper.remove_decoration output.formatter, (message_lines.last? || "") # When the last previous line is empty (without formatting) it is already cleared by the section output, so we don't need to clear it again if last_line_without_decoration.empty? line_count -= 1 end message_lines.each do |line| message_line_length = ACON::Helper.width ACON::Helper.remove_decoration output.formatter, line if message_line_length > @terminal.width line_count += message_line_length // @terminal.width end end output.clear line_count else previous_message.count('\n').times do |_| @cursor.move_to_column 1 @cursor.clear_line @cursor.move_up end @cursor.move_to_column 1 @cursor.clear_line end end elsif @step > 0 message = "#{EOL}#{message}" end @previous_message = original_message @last_write_time = @clock.now @output.print message @write_count += 1 end private def build_line : String format = @format.not_nil! regex = /%([a-z\-_]+)(?::([^%]+))?%/i callback = Proc(String, Regex::MatchData, String).new do |_, match| if formatter = self.placeholder_formatter match[1] text = formatter.call self, @output elsif message = @messages[match[1]]? text = message else next match[0] end if format_string = match[2]? text = sprintf "%#{format_string}", text end text end line = format.gsub regex, &callback # Gets string length for each sub-line with multiline format lines_length = line.split("\n").map { |sub_line| ACON::Helper.width ACON::Helper.remove_decoration @output.formatter, sub_line.rstrip "\r" } lines_width = lines_length.max terminal_width = @terminal.width if lines_width <= terminal_width return line end self.bar_width = @bar_width - lines_width + terminal_width format.gsub regex, &callback end private def set_real_format(format : String) : Nil # Try to use the _NOMAX variant if available @format = if @max.zero? && (resolved_format = self.class.format_definition "#{format}_nomax") resolved_format elsif resolved_format = self.class.format_definition format resolved_format else format end end private def determine_best_format : Format case @output.verbosity when .debug? then @max > 0 ? Format::DEBUG : Format::DEBUG_NOMAX when .very_verbose? then @max > 0 ? Format::VERY_VERBOSE : Format::VERY_VERBOSE_NOMAX when .verbose? then @max > 0 ? Format::VERBOSE : Format::VERBOSE_NOMAX else @max > 0 ? Format::NORMAL : Format::NORMAL_NOMAX end end end ================================================ FILE: src/components/console/src/helper/progress_indicator.cr ================================================ # Progress indicators are useful to let users know that a command isn't stalled. # However, unlike `ACON::Helper::ProgressBar`s, these indicators are used when the command's duration is indeterminate, # such as long-running commands or tasks that are quantifiable. # # ![Progress Indicator](/img/progress_indicator.gif) # # ``` # # Create a new progress indicator. # indicator = ACON::Helper::ProgressIndicator.new output # # # Start and display the progress indicator with a custom message. # indicator.start "Processing..." # # 50.times do # # Do work # # # Advance the progress indicator. # indicator.advance # end # # # Ensure the progress indicator shows a final completion message # indicator.finish "Finished!" # ``` # # ## Customizing # # ### Built-in Formats # # The progress indicator comes with a few built-in formats based on the `ACON::Output::Verbosity` the command was executed with: # # ```text # # Verbosity::NORMAL (CLI with no verbosity flag) # \ Processing... # | Processing... # / Processing... # - Processing... # # # Verbosity::VERBOSE (-v) # \ Processing... (1 sec) # | Processing... (1 sec) # / Processing... (1 sec) # - Processing... (1 sec) # # # Verbosity::VERY_VERBOSE (-vv) and Verbosity::DEBUG (-vvv) # \ Processing... (1 sec, 1kiB) # | Processing... (1 sec, 1kiB) # / Processing... (1 sec, 1kiB) # - Processing... (1 sec, 1kiB) # ``` # # NOTE: If a command called with `ACON::Output::Verbosity::QUIET`, the progress bar will not be displayed. # # The format may also be set explicitly in code within the constructor: # # ``` # # If the progress bar has a maximum number of steps. # ACON::Helper::ProgressIndicator.new output, format: :very_verbose # ``` # # ### Custom Indicator Values # # Custom indicator values may also be used: # # ``` # indicator = ACON::Helper::ProgressIndicator.new output, indicator_values: %w(⠏ ⠛ ⠹ ⢸ ⣰ ⣤ ⣆ ⡇) # ``` # # The progress indicator would now look like: # # ```text # ⠏ Processing... # ⠛ Processing... # ⠹ Processing... # ⢸ Processing... # ``` # # ### Custom Placeholders # # A progress indicator uses placeholders (a name enclosed with the `%` character) to determine the output format. # The built-in placeholders include: # # * `%indicator%` - The current indicator # * `%elapsed%` - The time elapsed since the start of the progress indicator # * `%memory%` - The current memory usage # * `%message%` - Used to display arbitrary messages # # These can be customized via `.set_placeholder_formatter`. # # ``` # ACON::Helper::ProgressIndicator.set_placeholder_formatter "message" do # # Return any arbitrary string # "My Custom Message" # end # ``` # # NOTE: Placeholder customization is global and would affect any indicator used after calling `.set_placeholder_formatter`. class Athena::Console::Helper::ProgressIndicator # Represents the built in progress indicator formats. # # See [Built-In Formats][Athena::Console::Helper::ProgressIndicator--built-in-formats] for more information. enum Format # `" %indicator% %message%"` NORMAL # `" %message%"` NORMAL_NO_ANSI # `" %indicator% %message% (%elapsed:6s%)"` VERBOSE # `" %message% (%elapsed:6s%)"` VERBOSE_NO_ANSI # `" %indicator% %message% (%elapsed:6s%, %memory:6s%)"` VERY_VERBOSE # `" %message% (%elapsed:6s%, %memory:6s%)"` VERY_VERBOSE_NO_ANSI # `" %indicator% %message% (%elapsed:6s%, %memory:6s%)"` DEBUG # `" %message% (%elapsed:6s%, %memory:6s%)"` DEBUG_NO_ANSI def format : String case self in .normal? then " %indicator% %message%" in .normal_no_ansi? then " %message%" in .verbose? then " %indicator% %message% (%elapsed:6s%)" in .verbose_no_ansi? then " %message% (%elapsed:6s%)" in .very_verbose?, .debug? then " %indicator% %message% (%elapsed:6s%, %memory:6s%)" in .very_verbose_no_ansi?, .debug_no_ansi? then " %message% (%elapsed:6s%, %memory:6s%)" end end end # Represents the expected type of a [Placeholder Formatter][Athena::Console::Helper::ProgressIndicator--custom-placeholders]. alias PlaceholderFormatter = Proc(Athena::Console::Helper::ProgressIndicator, String) # INTERNAL protected class_getter placeholder_formatters : Hash(String, PlaceholderFormatter) { self.init_placeholder_formatters } # Registers a custom placeholder with the provided *name* with the block being the formatter. def self.set_placeholder_formatter(name : String, &block : self -> String) : Nil self.set_placeholder_formatter name, block end # Registers a custom placeholder with the provided *name*, using the provided *callable* as the formatter. def self.set_placeholder_formatter(name : String, callable : ACON::Helper::ProgressIndicator::PlaceholderFormatter) : Nil self.placeholder_formatters[name] = callable end # Returns the global formatter for the provided *name* if it exists, otherwise `nil`. def self.placeholder_formatter(name : String) : ACON::Helper::ProgressIndicator::PlaceholderFormatter? self.placeholder_formatters[name]? end private def self.init_placeholder_formatters : Hash(String, PlaceholderFormatter) { "elapsed" => PlaceholderFormatter.new { |indicator| ACON::Helper.format_time indicator.clock.now - indicator.start_time }, "indicator" => PlaceholderFormatter.new do |indicator| indicator.finished? ? indicator.finished_indicator : indicator.indicator_values[indicator.indicator_index % indicator.indicator_values.size] end, "memory" => PlaceholderFormatter.new { (GC.stats.heap_size - GC.stats.free_bytes).humanize_bytes }, "message" => PlaceholderFormatter.new(&.message.to_s), } end protected getter indicator_values : Indexable(String) protected getter indicator_index : Int32 = 0 protected getter start_time : Time protected getter message : String? = nil protected getter clock : ACLK::Interface protected getter? finished : Bool = false protected getter finished_indicator : String @output : ACON::Output::Interface @format : Format @indicator_change_interval : Time::Span @started : Bool = false @indicator_update_time : Time = Time::UNIX_EPOCH def initialize( @output : ACON::Output::Interface, format : ACON::Helper::ProgressIndicator::Format? = nil, indicator_change_interval : Time::Span = 100.milliseconds, indicator_values : Indexable(String)? = nil, @clock : ACLK::Interface = ACLK::Native.new, @finished_indicator : String = "✔", ) indicator_values ||= ["-", "\\", "|", "/"] if 2 > indicator_values.size raise ACON::Exception::InvalidArgument.new "Must have at least 2 indicator value characters." end @format = format || determine_best_format @indicator_values = indicator_values @start_time = @clock.now @indicator_change_interval = indicator_change_interval end # Sets the *message* to display alongside the indicator. def message=(@message : String?) : Nil self.display end # Starts and displays the indicator with the provided *message*. def start(message : String) : Nil raise ACON::Exception::Logic.new "Progress indicator is already started." if @started @message = message @started = true @start_time = @clock.now @indicator_update_time = @clock.now + @indicator_change_interval @indicator_index = 0 @finished = false self.display end # Advance the indicator to display the next indicator character. def advance : Nil raise ACON::Exception::Logic.new "Progress indicator has not yet been started." unless @started return unless @output.decorated? current_time = @clock.now return if current_time < @indicator_update_time @indicator_update_time = current_time + @indicator_change_interval @indicator_index += 1 self.display end # Display the current state of the indicator. def display : Nil return if @output.verbosity.quiet? self.overwrite( @format.format.gsub /%([a-z\-_]+)(?:\:([^%]+))?%/i do |_, match| if formatter = self.class.placeholder_formatter match[1] next formatter.call self end match[0] end ) end # Completes the indicator with the provided *message*. def finish(@message : String, finished_indicator : String? = nil) : Nil raise ACON::Exception::Logic.new "Progress indicator has not yet been started." unless @started if finished_indicator @finished_indicator = finished_indicator end @finished = true self.display @output.puts "" @started = false end private def overwrite(message : String) : Nil if @output.decorated? @output.print "\x0D\x1B[2K" @output.print message else @output.puts message end end private def determine_best_format : Format case {@output.verbosity, @output.decorated?} when {.debug?, true} then Format::VERY_VERBOSE when {.debug?, false} then Format::VERY_VERBOSE_NO_ANSI when {.very_verbose?, true} then Format::VERY_VERBOSE when {.very_verbose?, false} then Format::VERY_VERBOSE_NO_ANSI when {.verbose?, true} then Format::VERBOSE when {.verbose?, false} then Format::VERBOSE_NO_ANSI else @output.decorated? ? Format::NORMAL : Format::NORMAL_NO_ANSI end end end ================================================ FILE: src/components/console/src/helper/question.cr ================================================ # Provides a method to ask the user for more information; # such as to confirm an action, or to provide additional values. # # See `ACON::Question` namespace for more information. class Athena::Console::Helper::Question < Athena::Console::Helper @@stty : Bool = true def self.disable_stty : Nil @@stty = false end @stream : IO? = nil def ask(input : ACON::Input::Interface, output : ACON::Output::Interface, question : ACON::Question::Base) if output.is_a? ACON::Output::ConsoleOutputInterface output = output.error_output end return self.default_answer question unless input.interactive? if input.is_a?(ACON::Input::Streamable) && (stream = input.stream) @stream = stream end begin if question.validator.nil? return self.do_ask output, question end self.validate_attempts(output, question) do self.do_ask output, question end rescue ex : ACON::Exception::MissingInput input.interactive = false raise ex end end protected def format_choice_question_choices(question : ACON::Question::AbstractChoice, tag : String) : Array(String) messages = Array(String).new choices = question.choices max_width = choices.keys.max_of { |k| k.is_a?(String) ? self.class.width(k) : k.digits.size } choices.each do |k, v| padding = " " * (max_width - (k.is_a?(String) ? k.size : k.digits.size)) messages << " [<#{tag}>#{k}#{padding}] #{v}" end messages end protected def write_error(output : ACON::Output::Interface, error : ::Exception) : Nil message = if (helper_set = self.helper_set) && (formatter_helper = helper_set[ACON::Helper::Formatter]?) formatter_helper.format_block error.message || "", "error" else "#{error.message}" end output.puts message end protected def write_prompt(output : ACON::Output::Interface, question : ACON::Question::Base) : Nil message = question.question if question.is_a? ACON::Question::AbstractChoice output.puts question.question output.puts self.format_choice_question_choices question, "info" message = question.prompt end output.print message end private def default_answer(question : ACON::Question::Base) default = question.default return default if default.nil? if validator = question.validator return validator.call default elsif question.is_a? ACON::Question::AbstractChoice choices = question.choices unless question.is_a? ACON::Question::MultipleChoice return choices[default]? || default end default = case default when String then default.split(',').map! do |item| if idx = item.to_i? item = idx end choices[item]? || item.to_s end else default end end default end # ameba:disable Metrics/CyclomaticComplexity private def do_ask(output : ACON::Output::Interface, question : ACON::Question::Base) self.write_prompt output, question input_stream = @stream || STDIN autocompleter = question.autocompleter_callback # TODO: Handle invalid input IO if autocompleter.nil? || !@@stty || !ACON::Terminal.has_stty_available? response = nil if question.hidden? begin hidden_response = self.hidden_response output, input_stream response = question.trimmable? ? hidden_response.strip : hidden_response rescue ex : ACON::Exception raise ex unless question.hidden_fallback? end end if response.nil? raise ACON::Exception::MissingInput.new "Aborted." unless response = self.read_input input_stream, question response = response.strip if question.trimmable? end else autocomplete = self.autocomplete output, question, input_stream, autocompleter response = question.trimmable? ? autocomplete.strip : autocomplete end if output.is_a? ACON::Output::Section output.add_content "" # add EOL to the question output.add_content response end question.process_response response end private def autocomplete(output : ACON::Output::Interface, question : ACON::Question::Base, input_stream : IO, autocompleter) : String # TODO: Support autocompletion. self.read_input(input_stream, question) || raise ACON::Exception::MissingInput.new "Aborted." end private def hidden_response(output : ACON::Output::Interface, input_stream : IO) : String response = if input_stream.tty? && input_stream.responds_to? :noecho input_stream.noecho &.gets 4096 elsif @@stty && ACON::Terminal.has_stty_available? stty_mode = `stty -g` system "stty -echo" input_stream.gets(4096).tap { system "stty #{stty_mode}" } elsif input_stream.tty? raise ACON::Exception::Runtime.new "Unable to hide the response." end raise ACON::Exception::MissingInput.new "Aborted." if response.nil? output.puts "" response end private def read_input(input_stream : IO, question : ACON::Question::Base) : String? unless question.multi_line? return input_stream.gets 4096 end # Can't just do `.gets_to_end` because we need to be able # to return early if the only input provided is a newline. String.build do |io| input_stream.each_char do |char| break if '\n' == char && io.empty? io << char end end end private def validate_attempts(output : ACON::Output::Interface, question : ACON::Question::Base, &) error = nil attempts = question.max_attempts while attempts.nil? || attempts > 0 self.write_error output, error if error begin return question.validator.not_nil!.call yield rescue ex : ACON::Exception::Runtime raise ex rescue ex : ::Exception error = ex ensure attempts -= 1 if attempts Fiber.yield end end raise error.not_nil! end end ================================================ FILE: src/components/console/src/helper/table.cr ================================================ # The Table helper can be used to display tabular data rendered to any `ACON::Output::Interface`. # # ```text # +---------------+--------------------------+------------------+ # | ISBN | Title | Author | # +---------------+--------------------------+------------------+ # | 99921-58-10-7 | Divine Comedy | Dante Alighieri | # | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | # | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | # | 80-902734-1-6 | And Then There Were None | Agatha Christie | # +---------------+--------------------------+------------------+ # ``` # # # Usage # # Most commonly, a table will consist of a header row followed by one or more data rows: # ``` # @[ACONA::AsCommand("table")] # class TableCommand < ACON::Command # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # ACON::Helper::Table.new(output) # .headers("ISBN", "Title", "Author") # .rows([ # ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], # ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens"], # ["960-425-059-0", "The Lord of the Rings", "J. R. R. Tolkien"], # ["80-902734-1-6", "And Then There Were None", "Agatha Christie"], # ]) # .render # # ACON::Command::Status::SUCCESS # end # end # ``` # # ## Separating Rows # # Row separators can be added anywhere in the output by passing an `ACON::Helper::Table::Separator` as a row. # # ``` # table # .rows([ # ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], # ["9971-5-0210-0", "A Tale of Two Cities", "Charles Dickens"], # ACON::Helper::Table::Separator.new, # ["960-425-059-0", "The Lord of the Rings", "J. R. R. Tolkien"], # ["80-902734-1-6", "And Then There Were None", "Agatha Christie"], # ]) # ``` # # ```text # +---------------+--------------------------+------------------+ # | ISBN | Title | Author | # +---------------+--------------------------+------------------+ # | 99921-58-10-7 | Divine Comedy | Dante Alighieri | # | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | # +---------------+--------------------------+------------------+ # | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | # | 80-902734-1-6 | And Then There Were None | Agatha Christie | # +---------------+--------------------------+------------------+ # ``` # # ## Header/Footer Titles # # Header and/or footer titles can optionally be added via the `#header_title` and/or `#footer_title` methods. # # ``` # table # .header_title("Books") # .footer_title("Page 1/2") # ``` # # ```text # +---------------+----------- Books --------+------------------+ # | ISBN | Title | Author | # +---------------+--------------------------+------------------+ # | 99921-58-10-7 | Divine Comedy | Dante Alighieri | # | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | # +---------------+--------------------------+------------------+ # | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | # | 80-902734-1-6 | And Then There Were None | Agatha Christie | # +---------------+--------- Page 1/2 -------+------------------+ # ``` # # ## Column Sizing # # By default, the width of each column is calculated automatically based on their contents. # The `#column_widths` method can be used to set the column widths explicitly. # # ``` # table # .column_widths(10, 0, 30) # .render # ``` # # In this example, the first column's width will be `10`, the last column's width will be `30`, and the second column's width will be calculated automatically since it is zero. # If you only want to set the width of a specific column, the `#column_width` method can be used. # # ``` # table # .column_width(0, 10) # .column_width(2, 30) # .render # ``` # # The resulting table would be: # # ```text # +---------------+------------------ Books -+--------------------------------+ # | ISBN | Title | Author | # +---------------+--------------------------+--------------------------------+ # | 99921-58-10-7 | Divine Comedy | Dante Alighieri | # | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | # +---------------+--------------------------+--------------------------------+ # | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | # | 80-902734-1-6 | And Then There Were None | Agatha Christie | # +---------------+--------------------------+--------------------------------+ # ``` # # Notice that the width of the first column is greater than 10 characters wide. # This is because column widths are always considered as the minimum width. # If the content doesn't fit, it will be automatically increased to the longest content length. # # ### Max Width # # If you would rather wrap the contents in multiple rows, the `#column_max_width` method can be used. # # ``` # table # .column_max_width(0, 5) # .column_max_width(1, 10) # .render # ``` # # This would cause the table to now be: # # ```text # +-------+------------+-- Books -----------------------+ # | ISBN | Title | Author | # +-------+------------+--------------------------------+ # | 99921 | Divine Com | Dante Alighieri | # | -58-1 | edy | | # | 0-7 | | | # | (the rest of the rows...) | # +-------+------------+--------------------------------+ # ``` # # ## Orientation # # By default, the table contents are displayed as a normal table with the data being in rows, the first being the header row(s). # The table can also be rendered vertically or horizontally via the `#vertical` and `#horizontal` methods respectively. # # For example, the same contents rendered vertically would be: # # ```text # +----------------------------------+ # | ISBN: 99921-58-10-7 | # | Title: Divine Comedy | # | Author: Dante Alighieri | # |----------------------------------| # | ISBN: 9971-5-0210-0 | # | Title: A Tale of Two Cities | # | Author: Charles Dickens | # |----------------------------------| # | ISBN: 960-425-059-0 | # | Title: The Lord of the Rings | # | Author: J. R. R. Tolkien | # |----------------------------------| # | ISBN: 80-902734-1-6 | # | Title: And Then There Were None | # | Author: Agatha Christie | # +----------------------------------+ # ``` # # While horizontally, it would be: # # ```text # +--------+-----------------+----------------------+-----------------------+--------------------------+ # | ISBN | 99921-58-10-7 | 9971-5-0210-0 | 960-425-059-0 | 80-902734-1-6 | # | Title | Divine Comedy | A Tale of Two Cities | The Lord of the Rings | And Then There Were None | # | Author | Dante Alighieri | Charles Dickens | J. R. R. Tolkien | Agatha Christie | # +--------+-----------------+----------------------+-----------------------+--------------------------+ # ``` # # ## Styles # # Up until now, all the tables have been rendered using the `default` style. # The table helper comes with a few additional built in styles, including: # # * borderless # * compact # * box # * double-box # * markdown # # The desired can be set via the `#style` method. # # ``` # table # .style("default") # Same as not calling the method # .render # ``` # # ### borderless # # ```text # =============== ========================== ================== # ISBN Title Author # =============== ========================== ================== # 99921-58-10-7 Divine Comedy Dante Alighieri # 9971-5-0210-0 A Tale of Two Cities Charles Dickens # =============== ========================== ================== # 960-425-059-0 The Lord of the Rings J. R. R. Tolkien # 80-902734-1-6 And Then There Were None Agatha Christie # =============== ========================== ================== # ``` # # ### compact # # ```text # ISBN Title Author # 99921-58-10-7 Divine Comedy Dante Alighieri # 9971-5-0210-0 A Tale of Two Cities Charles Dickens # 960-425-059-0 The Lord of the Rings J. R. R. Tolkien # 80-902734-1-6 And Then There Were None Agatha Christie # ``` # # ### box # # ```text # ┌───────────────┬──────────────────────────┬──────────────────┐ # │ ISBN │ Title │ Author │ # ├───────────────┼──────────────────────────┼──────────────────┤ # │ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri │ # │ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens │ # ├───────────────┼──────────────────────────┼──────────────────┤ # │ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien │ # │ 80-902734-1-6 │ And Then There Were None │ Agatha Christie │ # └───────────────┴──────────────────────────┴──────────────────┘ # ``` # # ### double-box # # ```text # ╔═══════════════╤══════════════════════════╤══════════════════╗ # ║ ISBN │ Title │ Author ║ # ╠═══════════════╪══════════════════════════╪══════════════════╣ # ║ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri ║ # ║ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens ║ # ╟───────────────┼──────────────────────────┼──────────────────╢ # ║ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien ║ # ║ 80-902734-1-6 │ And Then There Were None │ Agatha Christie ║ # ╚═══════════════╧══════════════════════════╧══════════════════╝ # ``` # # ### markdown # # ```text # | ISBN | Title | Author | # |---------------|--------------------------|------------------| # | 99921-58-10-7 | Divine Comedy | Dante Alighieri | # | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | # | 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | # | 80-902734-1-6 | And Then There Were None | Agatha Christie | # ``` # # ## Custom Styles # # If you would rather something more personal, custom styles can also be defined by providing `#style` with an `ACON::Helper::Table::Style` instance. # # ``` # table_style = ACON::Helper::Table::Style.new # .horizontal_border_chars("|") # .vertical_border_chars("-") # .default_crossing_char(' ') # # table # .style(table_style) # .render # ``` # # Notice you can use the same style tags as you can with `ACON::Formatter::OutputStyleInterface`s. # This is used by default to give some color to headers when allowed. # # TIP: Custom styles can also be registered globally: # ``` # ACON::Helper::Table.set_style_definition "colorful", table_style # # # ... # # table.style("colorful") # ``` # This method can also be used to override the built-in styles. # # See `ACON::Helper::Table::Style` for more information. # # ## Table Cells # # The `ACON::Helper::Table::Cell` type can be used to style a specific cell. # Such as customizing the fore/background color, the alignment of the text, or the overall format of the cell. # # See the related type for more information/examples. # # ### Spanning Multiple Columns and Rows # # The `ACON::Helper::Table::Cell` type can also be used to add *colspan* and/or *rowspan* to a cell; # which would make it span more than one column/row. # # ``` # ACON::Helper::Table.new(output) # .headers("ISBN", "Title", "Author") # .rows([ # ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], # ACON::Helper::Table::Separator.new, # [ACON::Helper::Table::Cell.new("This value spans 3 columns.", colspan: 3)], # ]) # .render # ``` # # This would result in: # # ```text # +---------------+---------------+-----------------+ # | ISBN | Title | Author | # +---------------+---------------+-----------------+ # | 99921-58-10-7 | Divine Comedy | Dante Alighieri | # +---------------+---------------+-----------------+ # | This value spans 3 columns. | # +---------------+---------------+-----------------+ # ``` # # TIP: This table cells with colspan and `center` alignment can be used to create header cells that span the entire table width: # ``` # table # .headers([ # [ACON::Helper::Table::Cell.new( # "Main table title", # colspan: 3, # style: ACON::Helper::Table::CellStyle.new( # align: :center # ) # )], # %w(ISBN Title Author), # ]) # ``` # Would generate: # ```text # +--------+--------+--------+ # | Main table title | # +--------+--------+--------+ # | ISBN | Title | Author | # +--------+--------+--------+ # ``` # # In a similar way, *rowspan* can be used to have a column span multiple rows. # This is especially helpful for columns with line breaks. # # ``` # ACON::Helper::Table.new(output) # .headers("ISBN", "Title", "Author") # .rows([ # [ # "978-0521567817", # "De Monarchia", # ACON::Helper::Table::Cell.new("Dante Alighieri\nspans multiple rows", rowspan: 2), # ], # ["978-0804169127", "Divine Comedy"], # ]) # .render # ``` # # This would result in: # # ```text # +----------------+---------------+---------------------+ # | ISBN | Title | Author | # +----------------+---------------+---------------------+ # | 978-0521567817 | De Monarchia | Dante Alighieri | # | 978-0804169127 | Divine Comedy | spans multiple rows | # +----------------+---------------+---------------------+ # ``` # # *colspan* and *rowspan* may also be used together to create any layout you can think of. # # ## Modifying Rendered Tables # # The `#render` method requires providing the entire table's content in order to fully render the table. # In some cases, that may not be possible if the data is generated dynamically. # In such cases, the `#append_row` method can be used which functions similarly to `#add_row`, but will append the rows to an already rendered table. # # INFO: This feature is only available when the table is rendered in an `ACON::Output::Section`. # # ``` # @[ACONA::AsCommand("table")] # class TableCommand < ACON::Command # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # section = output.section # table = ACON::Helper::Table.new(section) # .add_row("Foo") # # table.render # # table.append_row "Bar" # # ACON::Command::Status::SUCCESS # end # end # ``` # # This ultimately results in: # # ```text # +-----+ # | Foo | # | Bar | # +-----+ # ``` class Athena::Console::Helper::Table # Represents how the text within a cell should be aligned. enum Alignment # Aligns the text to the left of the cell. # # ```text # +-----------------+ # | Text | # +-----------------+ # ``` LEFT # Aligns the text to the right of the cell. # # ```text # +-----------------+ # | Text | # +-----------------+ # ``` RIGHT # Centers the text within the cell. # # ```text # +-----------------+ # | Text | # +-----------------+ # ``` CENTER end private enum Orientation DEFAULT HORIZONTAL VERTICAL end # Represents a cell that can span more than one column/row and/or have a unique style. # The cell may also have a value, which represents the value to display in the cell. # # For example: # # ``` # table # .rows([ # [ # "Foo", # ACON::Helper::Table::Cell.new( # "Bar", # style: ACON::Helper::Table::CellStyle.new( # align: :center, # foreground: "red", # background: "green" # ) # ), # ], # ]) # ``` # # See the [table docs][Athena::Console::Helper::Table--table-cells] and `ACON::Helper::Table::CellStyle` for more information. class Cell # Returns how many rows this cell should span. getter rowspan : Int32 # Returns how many columns this cell should span. getter colspan : Int32 # Returns the style representing how this cell should be styled. getter style : Table::CellStyle? @value : String def initialize( value : _ = "", @rowspan : Int32 = 1, @colspan : Int32 = 1, @style : Table::CellStyle? = nil, ) @value = value.to_s end def to_s(io : IO) : Nil io << @value end end # Represents a line that separates one or more rows. # # See the [separating rows][Athena::Console::Helper::Table--separating-rows] section for more information. class Separator < Table::Cell def initialize( rowspan : Int32 = 1, colspan : Int32 = 1, style : Table::CellStyle? = nil, ) super "", rowspan, colspan, style end end # The possible types that are accepted as cell values. # They are all eventually turned into strings. alias CellType = String | Number::Primitive | Bool | Athena::Console::Helper::Table::Cell | Nil # The possible types that represent a row. alias RowType = Enumerable(CellType) | Athena::Console::Helper::Table::Separator private struct Row alias Type = String | Table::Cell | Nil include Indexable::Mutable(Type) delegate :insert, :<<, :[], to: @columns @columns : Array(Type) def self.new(columns : Enumerable(CellType)) new(columns.map do |c| case c when Athena::Console::Helper::Table::Cell, Nil then c else c.to_s end end) end def initialize(columns : Enumerable(Type)) @columns = columns.to_a.map &.as Type end def size : Int @columns.size end def unsafe_fetch(index : Int) : Type @columns[index] end def unsafe_put(index : Int, value : Type) : Nil @columns[index] = value end def each(& : Type ->) : Nil @columns.each do |c| yield c end end end # OPTIMIZE: Can this be merged into `Row`? private struct Rows alias Type = Table::Separator | Array(Row::Type) include Enumerable(Type) @columns : Array(Array(Type)) def initialize(@columns : Array(Array(Type))); end def each(& : Array(Type) ->) : Nil @columns.each do |c| yield c end end end # INTERNAL protected class_getter styles : Hash(String, ACON::Helper::Table::Style) { self.init_styles } # Registers the provided *style* with the provided *name*. # # See [custom styles][Athena::Console::Helper::Table--custom-styles]. def self.set_style_definition(name : String, style : ACON::Helper::Table::Style) : Nil self.styles[name] = style end # Returns the `ACON::Helper::Table::Style` style with the provided *name*, # raising an `ACON::Exception::InvalidArgument` if no style with that name is defined. def self.style_definition(name : String) : ACON::Helper::Table::Style self.styles[name]? || raise ACON::Exception::InvalidArgument.new "The table style '#{name}' is not defined." end # INTERNAL private def self.init_styles : Hash(String, ACON::Helper::Table::Style) markdown = Table::Style.new .default_crossing_char('|') .display_outside_border(false) borderless = Table::Style.new .horizontal_border_chars("=") .vertical_border_chars(" ") .default_crossing_char(" ") compact = Table::Style.new .horizontal_border_chars("") .vertical_border_chars("") .default_crossing_char("") .cell_row_content_format("%s ") suggested = Table::Style.new .horizontal_border_chars("-") .vertical_border_chars(" ") .default_crossing_char(" ") .cell_header_format("%s") box = Table::Style.new .horizontal_border_chars("─") .vertical_border_chars("│") .crossing_chars("┼", "┌", "┬", "┐", "┤", "┘", "┴", "└", "├") double_box = Table::Style.new .horizontal_border_chars("═", "─") .vertical_border_chars("║", "│") .crossing_chars("┼", "╔", "╤", "╗", "╢", "╝", "╧", "╚", "╟", "╠", "╪", "╣") { "borderless" => borderless, "box" => box, "compact" => compact, "default" => ACON::Helper::Table::Style.new, "double-box" => double_box, "markdown" => markdown, "suggested" => suggested, } end @header_title : String? = nil @footer_title : String? = nil @headers = Array(Row).new @rows = Array(Row | Table::Separator).new @effective_column_widths = Hash(Int32, Int32).new @number_of_columns : Int32? = nil @column_styles = Hash(Int32, ACON::Helper::Table::Style).new @column_widths = Hash(Int32, Int32).new @column_max_widths = Hash(Int32, Int32).new @rendered = false @orientation : Orientation = :default # Returns the `ACON::Helper::Table::Style` used by this table. getter style : ACON::Helper::Table::Style @output : ACON::Output::Interface def initialize(@output : ACON::Output::Interface) @style = ACON::Helper::Table::Style.new end # Sets the table [header title][Athena::Console::Helper::Table--headerfooter-titles]. def header_title(@header_title : String?) : self self end # Sets the table [footer title][Athena::Console::Helper::Table--headerfooter-titles]. def footer_title(@footer_title : String?) : self self end # Sets the style of this table. # *style* may either be an explicit `ACON::Helper::Table::Style`, # or the name of the style to use if it is built-in, or was registered via `.set_style_definition`. # # See [styles][Athena::Console::Helper::Table--styles] and [custom styles][Athena::Console::Helper::Table--custom-styles]. def style(style : String | ACON::Helper::Table::Style) : self @style = self.resolve_style style self end # Sets the style of the column at the provided *index*. # *style* may either be an explicit `ACON::Helper::Table::Style`, # or the name of the style to use if it is built-in, or was registered via `.set_style_definition`. def column_style(index : Int32, style : ACON::Helper::Table::Style | String) : self @column_styles[index] = self.resolve_style style self end # Returns the `ACON::Helper::Table::Style` the column at the provided *index* is using, falling back on `#style`. def column_style(index : Int32) : ACON::Helper::Table::Style @column_styles[index]? || self.style end # Sets the minimum *width* for the column at the provided *index*. # # See [column sizing][Athena::Console::Helper::Table--column-sizing]. def column_width(index : Int32, width : Int32) : self @column_widths[index] = width self end # Sets the minimum column widths to the provided *widths*. # # See [column sizing][Athena::Console::Helper::Table--column-sizing]. def column_widths(widths : Enumerable(Int32)) : self @column_widths.clear widths.each_with_index do |w, idx| self.column_width idx, w end self end # :ditto: def column_widths(*widths : Int32) : self self.column_widths widths end # Sets the maximum *width* for the column at the provided *index*. # # See [column sizing][Athena::Console::Helper::Table--column-sizing]. def column_max_width(index : Int32, width : Int32) : self if !@output.formatter.is_a? ACON::Formatter::WrappableInterface raise ACON::Exception::Logic.new "Setting a maximum column width is only supported when using a #{ACON::Formatter::WrappableInterface} formatter, got #{@output.class}." end @column_max_widths[index] = width self end def headers(*names : CellType) : self self.headers names end def headers(headers : RowType) : self self.headers({headers}) end def headers(headers : Enumerable(RowType)) : self @headers.clear headers.each do |h| @headers << Row.new h end self end # Overrides the rows of this table to those provided in *rows*. # # ``` # table # .rows(%w(Foo Bar Baz)) # .render # ``` def rows(rows : RowType) : self self.rows({rows}) end # Overrides the rows of this table to those provided in *rows*. # # ``` # table # .rows([ # %w(One Two Three), # %w(Foo Bar Baz), # ]) # .render # ``` def rows(rows : Enumerable(RowType)) : self @rows.clear self.add_rows rows end # Similar to `#rows(rows : Enumerable(RowType))`, but appends the provided *rows* to this table. # # ``` # # Existing rows are not removed. # table # .add_rows([ # %w(One Two Three), # %w(Foo Bar Baz), # ]) # .render # ``` def add_rows(rows : Enumerable(RowType)) : self rows.each do |r| self.add_row r end self end # Adds a single new *row* to this table. # # ``` # # Existing rows are not removed. # table # .add_row(%w(One Two Three)) # .add_row(%w(Foo Bar Baz)) # .render # ``` def add_row(row : RowType) : self @rows << case row when Table::Separator then row else Row.new row end self end # Adds the provided *columns* as a single row to this table. # # ``` # # Existing rows are not removed. # table # .add_row("One", "Two", "Three") # .add_row("Foo", "Bar", "Baz") # .render # ``` def add_row(*columns : CellType) : self self.add_row columns self end # Appends *row* to an already rendered table. # # See [modifying rendered tables][Athena::Console::Helper::Table--modifying-rendered-tables] def append_row(row : RowType) : self unless (output = @output).is_a? ACON::Output::Section raise ACON::Exception::Logic.new "Appending a row is only supported when using a #{ACON::Output::Section} output, got #{@output.class}." end if @rendered output.clear self.calculate_row_count end self.add_row row self.render self end # Appends the provided *columns* as a single row to an already rendered table. # # See [modifying rendered tables][Athena::Console::Helper::Table--modifying-rendered-tables] def append_row(*columns : CellType) : self self.append_row([*columns]) end # Manually sets the provided *row* to the provided *index*. # # ``` # # Existing rows are not removed. # table # .add_row(%w(One Two Three)) # .row(0, %w(Foo Bar Baz)) # Overrides row 0 to this row # .render # ``` def row(index : Int32, row : RowType) : self @rows[index] = Row.new row self end # Changes this table's [orientation][Athena::Console::Helper::Table--orientation] to horizontal. def horizontal : self @orientation = :horizontal self end # Changes this table's [orientation][Athena::Console::Helper::Table--orientation] to vertical. def vertical : self @orientation = :vertical self end private alias InternalRowType = Row | ACON::Helper::Table::Separator # Renders this table to the `ACON::Output::Interface` it was instantiated with. # # ameba:disable Metrics/CyclomaticComplexity def render divider = ACON::Helper::Table::Separator.new rows = self.combined_rows divider self.calculate_number_of_columns rows row_groups = self.build_table_rows rows self.calculate_columns_width row_groups is_header = !@orientation.horizontal? is_first_row = @orientation.horizontal? has_title = !!@header_title.presence row_groups.each do |row_group| is_header_separator_rendered : Bool = false row_group.each do |row| if divider == row is_header = false is_first_row = true next end if row.is_a? Table::Separator self.render_row_separator next end # TODO: Handle empty/nil rows? if is_header && !is_header_separator_rendered && @style.display_outside_border? self.render_row_separator( is_header ? RowSeparator::TOP : RowSeparator::TOP_BOTTOM, has_title ? @header_title : nil, has_title ? @style.header_title_format : nil ) has_title = false is_header_separator_rendered = true end if is_first_row self.render_row_separator( is_header ? RowSeparator::TOP : RowSeparator::TOP_BOTTOM, has_title ? @header_title : nil, has_title ? @style.header_title_format : nil ) is_first_row = false has_title = false end if @orientation.vertical? is_header = false is_first_row = false end if @orientation.horizontal? self.render_row row, @style.cell_row_format, @style.cell_header_format else self.render_row row, is_header ? @style.cell_header_format : @style.cell_row_format end end end if @style.display_outside_border? self.render_row_separator :bottom, @footer_title, @style.footer_title_format end self.cleanup @rendered = true end # ameba:disable Metrics/CyclomaticComplexity private def combined_rows(divider : Table::Separator) : Array(InternalRowType) rows = Array(InternalRowType).new is_cell_with_colspan = ->(cell : CellType) { cell.is_a?(ACON::Helper::Table::Cell) && cell.colspan >= 2 } if @orientation.horizontal? @headers[0]?.try &.each_with_index do |header, idx| rows.insert idx, Row.new [header] @rows.each do |row| next if row.is_a? Table::Separator if rv = row[idx]? rows[idx].as(Row) << rv elsif is_cell_with_colspan.call rows[idx].as(Row)[0] # Noop, there is a "title" else rows[idx].as(Row) << "" end end end elsif @orientation.vertical? formatter = @output.formatter max_header_length = (@headers[0]? || [] of String).reduce 0 do |acc, header| Math.max acc, Helper.width(Helper.remove_decoration(formatter, header.to_s)) end @rows.each do |row| next if row.is_a? Table::Separator unless rows.empty? rows << Row.new [divider] end contains_colspan = false row.each do |cell| if contains_colspan = is_cell_with_colspan.call cell break end end headers = @headers[0]? || [] of String max_rows = Math.max headers.size, row.size max_rows.times do |idx| cell = (row[idx]? || "").to_s cell.split("\n").each_with_index do |part, part_idx| if !headers.empty? && !contains_colspan if part_idx.zero? rows << Row.new([ sprintf( "%s: %s", headers[idx]?.to_s.rjust(max_header_length, ' '), part ), ]) else rows << Row.new([ sprintf( "%s %s", "".rjust(max_header_length, ' '), part ), ]) end elsif !cell.empty? rows << Row.new [part] end end end end else @headers.each { |h| rows << Row.new h unless h.empty? } rows << divider @rows.each do |r| case r when Table::Separator then rows << r else rows << Row.new r unless r.empty? end end end rows end private def cleanup : Nil @effective_column_widths.clear @number_of_columns = nil end # ameba:disable Metrics/CyclomaticComplexity private def build_table_rows(rows : Array(InternalRowType)) : Rows formatter = @output.formatter.as ACON::Formatter::WrappableInterface # row_key => line_key => column idx unmerged_rows = Hash(Int32, Hash(Int32, Hash(Int32, String | Table::Cell))).new row_key = 0 while row_key < rows.size self.fill_next_rows rows, row_key # Remove any line breaks and replace it with a new line self.iterate_row(rows, row_key) do |cell, column| cell_value = cell.to_s colspan = cell.is_a?(ACON::Helper::Table::Cell) ? cell.colspan : 1 if (max_width = @column_max_widths[column]?) && (Helper.width(Helper.remove_decoration(formatter, cell_value)) > max_width) cell_value = formatter.format_and_wrap cell_value, max_width * colspan end next unless cell_value.includes? '\n' escaped = cell_value.split('\n').join '\n' { |v| ACON::Formatter::Output.escape_trailing_backslash v } cell = cell.is_a?(Table::Cell) ? Table::Cell.new(escaped, colspan: cell.colspan) : escaped cell_value = cell.to_s lines = cell_value.gsub('\n', "\n").split '\n' lines.each_with_index do |line, line_key| if colspan > 1 line = Table::Cell.new line, colspan: colspan end if line_key.zero? rows[row_key].as(Row)[column] = line else if !unmerged_rows.has_key?(row_key) || !unmerged_rows[row_key].has_key? line_key (unmerged_rows[row_key] ||= Hash(Int32, Hash(Int32, String | Table::Cell)).new)[line_key] = self.copy_row rows, row_key end unmerged_rows[row_key][line_key][column] = line end end end row_key += 1 end row_groups = [] of Array(Rows::Type) rows.each_with_index do |row, rk| row_group = [row.is_a?(Table::Separator) ? row : self.fill_cells(row)] of Rows::Type if ur = unmerged_rows[rk]? ur.each_value do |r| row_group << (r.is_a?(Table::Separator) ? r : self.fill_cells(r)) end end row_groups << row_group end Rows.new row_groups end # Fills rows that contain rowspan > 1 private def fill_next_rows(rows : Enumerable, line : Int32) : Nil unmerged_rows = Hash(Int32, Hash(Int32, Table::Cell)).new self.iterate_row(rows, line) do |cell, column| cell_value = cell.to_s if cell.is_a?(ACON::Helper::Table::Cell) && cell.rowspan > 1 nb_lines = cell.rowspan - 1 lines = [cell_value] if cell_value.includes? '\n' lines = cell_value.gsub("\n", "\n").split '\n' nb_lines = lines.size > nb_lines ? cell_value.count('\n') : nb_lines rows[line].as(Row)[column] = Table::Cell.new lines.first, colspan: cell.colspan, style: cell.style end fill = Hash(Int32, Hash(Int32, Table::Cell)).new nb_lines.times do |l| fill[line + 1 + l] = Hash(Int32, Table::Cell).new end unmerged_rows = fill.merge! unmerged_rows unmerged_rows.each_key do |unmerged_row_key| value = lines[unmerged_row_key - line]? || "" (unmerged_rows[unmerged_row_key] ||= Hash(Int32, Table::Cell).new)[column] = Table::Cell.new value, colspan: cell.colspan, style: cell.style if nb_lines == unmerged_row_key - line break end end end end unmerged_rows.each do |unmerged_row_key, unmerged_row| if (ur = rows[unmerged_row_key]?) && ur.is_a?(Enumerable) && ((self.get_number_of_columns(ur) + self.get_number_of_columns(unmerged_rows[unmerged_row_key])) <= @number_of_columns.not_nil!) unmerged_row.each do |cell_key, c| rows[unmerged_row_key].as(Row).insert cell_key, c end else row = self.copy_row rows, unmerged_row_key - 1 unmerged_row.each_key do |column| row[column] = unmerged_row[column] end rows.insert unmerged_row_key, Row.new row.values end end end # Fills cells for a colspan > 1 private def fill_cells(row : Row) : Array(Row::Type) new_row = [] of Row::Type row.each_with_index do |cell, column| new_row << cell if cell.is_a?(Table::Cell) && cell.colspan > 1 ((column + 1)...(column + cell.colspan)).each do new_row << "" end end end new_row.empty? ? row.to_a : new_row end # # OPTIMIZE: See about making Row an Enumerable({Int32, Row::Type}) to allow both Row and Hash contexts private def fill_cells(row : Hash(Int32, Row::Type)) : Array(Row::Type) new_row = [] of Row::Type row.each do |column, cell| new_row << cell if cell.is_a?(Table::Cell) && cell.colspan > 1 ((column + 1)...(column + cell.colspan)).each do new_row << "" end end end new_row end private def copy_row(rows : Array(InternalRowType), line : Int32) : Hash(Int32, String | Table::Cell) new_row = Hash(Int32, String | Table::Cell).new rows[line].as(Row).each_with_index do |cell, cell_key| new_row[cell_key] = "" if cell.is_a? Table::Cell new_row[cell_key] = Table::Cell.new("", colspan: cell.colspan) end end new_row end private def calculate_number_of_columns(rows : Enumerable) : Nil columns = [0] rows.each do |row| next if row.is_a? ACON::Helper::Table::Separator columns << self.get_number_of_columns row end @number_of_columns = columns.max end private def calculate_columns_width(groups : Rows) : Nil @number_of_columns.not_nil!.times do |column| lengths = [] of Int32 groups.each do |group| group.each do |row| # Avoid mutating the actual row, as the logic below is just used to calculate widths row = row.dup next if row.is_a? Table::Separator row.each_with_index do |cell, idx| if cell.is_a? Table::Cell text_content = Helper.remove_decoration @output.formatter, cell.to_s text_length = Helper.width text_content if text_length > 0 # Split content into an array of n chars each content_columns = text_content.split(/(#{"." * (text_length / cell.colspan).ceil.to_i})/, remove_empty: true) content_columns.each_with_index do |content, position| row[idx + position] = content end end end end lengths << self.get_cell_width row, column end end @effective_column_widths[column] = lengths.max + Helper.width(@style.cell_row_content_format) - 2 end end private def get_cell_width(row : Rows::Type, column : Int32) : Int32 cell_width = 0 if cell = row[column]? cell_width = Helper.width Helper.remove_decoration @output.formatter, cell.to_s end column_width = @column_widths[column]? || 0 cell_width = Math.max cell_width, column_width (max_width = @column_max_widths[column]?) ? Math.min(max_width, cell_width) : cell_width end private def get_column_separator_width : Int32 Helper.width sprintf @style.border_format, @style.border_chars[3] end private def get_number_of_columns(row : Enumerable) : Int32 columns = row.size row.each do |column| columns += column.is_a?(ACON::Helper::Table::Cell) ? column.colspan - 1 : 0 end columns end private def calculate_row_count : Int32 number_of_rows = self.combined_rows(Table::Separator.new).size unless @headers.empty? number_of_rows += 1 end unless @rows.empty? number_of_rows += 1 end number_of_rows end private enum RowSeparator TOP TOP_BOTTOM MIDDLE BOTTOM end # ameba:disable Metrics/CyclomaticComplexity private def render_row_separator(type : RowSeparator = :middle, title : String? = nil, title_format : String? = nil) : Nil return unless count = @number_of_columns borders = @style.border_chars crossings = @style.crossing_chars horizontal, left_char, middle_char, right_char = case type when .middle? then {borders[2], crossings[8], crossings[0], crossings[4]} when .top? then {borders[0], crossings[1], crossings[2], crossings[3]} when .top_bottom? then {borders[0], crossings[9], crossings[10], crossings[11]} else {borders[0], crossings[7], crossings[6], crossings[5]} end markup = String.build do |io| break "" if count.zero? io << left_char count.times do |column| io << horizontal * @effective_column_widths[column] io << ((column == (count - 1)) ? right_char : middle_char) end end if !title.nil? && title_format title_length = Helper.width Helper.remove_decoration((formatter = @output.formatter), (formatted_title = sprintf(title_format, title))) markup_length = Helper.width markup if title_length > (limit = markup_length - 4) title_length = limit format_length = Helper.width Helper.remove_decoration(formatter, sprintf(title_format, "")) formatted_title = sprintf title_format, "#{title[0, limit - format_length - 3]}..." end title_start = (markup_length - title_length) // 2 markup = "#{markup[0, title_start]}#{formatted_title}#{markup[((title_start + title_length)..)]}" end return unless markup.presence @output.puts sprintf @style.border_format, markup end private def render_row(row : Rows::Type, cell_format : String, first_cell_format : String? = nil) : Nil columns = self.get_row_columns row last = columns.size - 1 markup = String.build do |io| io << self.render_column_separator :outside columns.each_with_index do |column, idx| io << if first_cell_format && idx.zero? self.render_cell row, column, first_cell_format else self.render_cell row, column, cell_format end io << self.render_column_separator last == idx ? Border::OUTSIDE : Border::INSIDE end end @output.puts markup end private def render_cell(row : Rows::Type, column : Int32, cell_format : String) : String cell = (row[column]? || "") cell_value = cell.to_s width = @effective_column_widths[column] if cell.is_a?(Table::Cell) && cell.colspan > 1 # Add the width of the following columns (numbers of colspan) ((column + 1)..(column + cell.colspan - 1)).each do |next_column| width += self.get_column_separator_width + @effective_column_widths[next_column] end end style = self.get_column_style column padding_style = style if cell.is_a? Table::Separator return sprintf style.border_format, style.border_chars[2] * width end width += cell_value.size - Helper.remove_decoration(@output.formatter, cell_value).size content = sprintf style.cell_row_content_format, cell_value if cell.is_a?(Table::Cell) && (cell_style = cell.style) unless cell_value.matches? /^<(\w+|(\w+=[\w,]+;?)*)>.+<\/(\w+|(\w+=\w+;?)*)?>$/ unless cell_format = cell_style.format tag = cell_style.tag cell_format = "<#{tag}>%s" end if content.includes? "" content = content.gsub "", "" width -= 3 end if content.includes? "" content = content.gsub "", "" width -= "".size end end padding_style = cell_style end sprintf cell_format, padding_style.pad(content, width, style.padding_char) end private def get_row_columns(row : Rows::Type) : Array(Int32) columns = (0...@number_of_columns).to_a row.each_with_index do |cell, cell_key| if cell.is_a? Table::Cell # Exclude grouped columns columns = columns - ((cell_key + 1)...(cell_key + cell.colspan)).to_a end end columns end private enum Border OUTSIDE INSIDE end private def render_column_separator(type : Border = :outside) : String borders = @style.border_chars sprintf @style.border_format, type.outside? ? borders[1] : borders[3] end # Helper method that allows iterating over the cells of a row, skipping cell separators private def iterate_row(rows : Enumerable, line : Int32, & : Row::Type, Int32 ->) : Nil columns = rows[line] return if columns.is_a? ACON::Helper::Table::Separator columns.each_with_index do |cell, idx| yield cell, idx end end private def get_column_style(column : Int32) : Style @column_styles[column]? || @style end private def resolve_style(style : ACON::Helper::Table::Style) : Style style end private def resolve_style(style : String) : Style self.class.styles[style]? || raise ACON::Exception::InvalidArgument.new "The table style '#{style}' is not defined." end end ================================================ FILE: src/components/console/src/helper/table_cell_style.cr ================================================ # Represents the styling for a specific `ACON::Helper::Table::Cell`. struct Athena::Console::Helper::Table::CellStyle # Returns the foreground color for this cell. # # Can be any color string supported via [ACON::Formatter::OutputStyleInterface][Athena::Console::Formatter::OutputStyleInterface--inline-styles], # e.g. named (`"red"`) or hexadecimal (`"#38bdc2"`) colors. getter foreground : String # Returns the background color for this cell. # # Can be any color string supported via [ACON::Formatter::OutputStyleInterface][Athena::Console::Formatter::OutputStyleInterface--inline-styles], # e.g. named (`"red"`) or hexadecimal (`"#38bdc2"`) colors. getter background : String # How the text should be aligned in the cell. # # See `ACON::Helper::Table::Alignment`. getter align : ACON::Helper::Table::Alignment # A `sprintf` format string representing the content of the cell. # Should have a single `%s` representing the cell's value. # # Can be used to reuse [custom style tags][Athena::Console::Formatter::OutputStyleInterface--custom-styles]. # E.g. `"%s"`. getter format : String? def initialize( @foreground : String = "default", @background : String = "default", @align : ACON::Helper::Table::Alignment = :left, @format : String? = nil, ) end protected def tag : String "fg=#{@foreground};bg=#{@background}" end protected def pad(string : String, width : Int32, padding_char) : String case @align in .left? then string.ljust width, padding_char in .right? then string.rjust width, padding_char in .center? then string.center width, padding_char end end end ================================================ FILE: src/components/console/src/helper/table_style.cr ================================================ # Represents the overall style for a table. # Including the characters that make up the row/column separators, crosses, cell formats, and default alignment. # # This class provides a fluent interface for configuring each part of the style. class Athena::Console::Helper::Table::Style @horizontal_outside_border_char = "-" @horizontal_inside_border_char = "-" @vertical_outside_border_char = "|" @vertical_inside_border_char = "|" @crossing_char : String = "+" @crossing_top_right_char = "+" @crossing_top_middle_char = "+" @crossing_top_left_char = "+" @crossing_bottom_right_char = "+" @crossing_bottom_middle_char = "+" @crossing_bottom_left_char = "+" @crossing_middle_right_char = "+" @crossing_middle_left_char = "+" @crossing_top_left_bottom_char = "+" @crossing_top_middle_bottom_char = "+" @crossing_top_right_bottom_char = "+" protected getter padding_char : Char = ' ' protected getter header_title_format : String = " %s " protected getter footer_title_format : String = " %s " protected getter cell_header_format : String = "%s" protected getter cell_row_format : String = "%s" protected getter cell_row_content_format : String = " %s " protected getter border_format : String = "%s" protected getter? display_outside_border : Bool = true protected getter align : ACON::Helper::Table::Alignment = :left def_clone # Sets the default cell alignment for the table. # # See `ACON::Helper::Table::Alignment`. def align(@align : ACON::Helper::Table::Alignment) : self self end def display_outside_border(@display_outside_border : Bool) : self self end # Sets the `sprintf` format string for the border, defaulting to `"%s"`. # # For example, if set to `"~%s~"` with the cell's content being `text`: # # ```text # ~+------+~ # ~|~ text ~|~ # ~+------+~ # ``` # # WARNING: Customizing this format can mess with the formatting of the whole table. def border_format(format : String) : self @border_format = format self end # Sets the the character that is added to the cell to ensure its content has the correct `ACON::Helper::Table::Alignment`, defaulting to `' '`. # # For example, if the padding character was `'_'` with a left alignment: # # ```text # +-----+ # | 7 __| # +-----+ # ``` def padding_char(char : Char) : self @padding_char = char self end # Sets the `sprintf` format string used for [header titles][Athena::Console::Helper::Table--headerfooter-titles], defaulting to `" %s "`. def header_title_format(format : String) : self @header_title_format = format.to_s self end # Sets the `sprintf` format string used for [footer titles][Athena::Console::Helper::Table--headerfooter-titles], defaulting to `" %s "`. def footer_title_format(format : String) : self @footer_title_format = format.to_s self end # Sets the `sprintf` format string used for table headings, defaulting to `"%s"`. def cell_header_format(format : String) : self @cell_header_format = format.to_s self end # Sets the `sprintf` format string used for cell contents, defaulting to `"%s"`. # # For example, if set to `"~%s~"` with the cell's content being `text`: # # ```text # +------+ # |~ text ~| # +------+ # ``` # # WARNING: Customizing this format can mess with the formatting of the whole table. def cell_row_format(format : String) : self @cell_row_format = format.to_s self end # Sets the `sprintf` format string used for cell contents, defaulting to `" %s "`. # # For example, if set to `" =%s= "` with the cell's content being `text`: # # ```text # +--------+ # | =text= | # +--------+ # ``` def cell_row_content_format(format : String) : self @cell_row_content_format = format.to_s self end protected def pad(string : String, width : Int32, padding_char) : String case @align in .left? then string.ljust width, padding_char in .right? then string.rjust width, padding_char in .center? then string.center width, padding_char end end # Sets the horizontal border chars, defaulting to `"-"`. # # *inside* defaults to *outside* if not provided. # # For example: # # ``` # ╔═══════════════╤══════════════════════════╤══════════════════╗ # 1 ISBN 2 Title │ Author ║ # ╠═══════════════╪══════════════════════════╪══════════════════╣ # ║ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri ║ # ║ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens ║ # ║ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien ║ # ║ 80-902734-1-6 │ And Then There Were None │ Agatha Christie ║ # ╚═══════════════╧══════════════════════════╧══════════════════╝ # ``` # # Legend: # # * #1 *outside* # * #2 *inside* def horizontal_border_chars(outside : String | Char, inside : String | Char | Nil = nil) : self @horizontal_outside_border_char = outside.to_s @horizontal_inside_border_char = inside.try &.to_s || outside.to_s self end # Sets the vertical border chars, defaulting to `"|"`. # # *inside* defaults to *outside* if not provided. # # For example: # # ``` # ╔═══════════════╤══════════════════════════╤══════════════════╗ # ║ ISBN │ Title │ Author ║ # ╠═══════1═══════╪══════════════════════════╪══════════════════╣ # ║ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri ║ # ║ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens ║ # ╟───────2───────┼──────────────────────────┼──────────────────╢ # ║ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien ║ # ║ 80-902734-1-6 │ And Then There Were None │ Agatha Christie ║ # ╚═══════════════╧══════════════════════════╧══════════════════╝ # ``` # # Legend: # # * #1 *outside* # * #2 *inside* def vertical_border_chars(outside : String | Char, inside : String | Char | Nil = nil) : self @vertical_outside_border_char = outside.to_s @vertical_inside_border_char = inside.try &.to_s || outside.to_s self end protected def border_chars : Tuple(String, String, String, String) { @horizontal_outside_border_char, @vertical_outside_border_char, @horizontal_inside_border_char, @vertical_inside_border_char, } end # Sets the crossing characters individually, defaulting to `"+"`. # See `#default_crossing_char(char)` to default them all to a single character. # # ``` # 1═══════════════2══════════════════════════2══════════════════3 # ║ ISBN │ Title │ Author ║ # 8═══════════════0══════════════════════════0══════════════════4 # ║ 99921-58-10-7 │ Divine Comedy │ Dante Alighieri ║ # ║ 9971-5-0210-0 │ A Tale of Two Cities │ Charles Dickens ║ # 8───────────────0──────────────────────────0──────────────────4 # ║ 960-425-059-0 │ The Lord of the Rings │ J. R. R. Tolkien ║ # ║ 80-902734-1-6 │ And Then There Were None │ Agatha Christie ║ # 7═══════════════6══════════════════════════6══════════════════5 # ``` # # Legend: # # * #0 *cross* # * #1 *top_left* # * #2 *top_middle* # * #3 *top_right* # * #4 *middle_right* # * #5 *bottom_right* # * #6 *bottom_middle* # * #7 *bottom_left* # * #8 *middle_left* # # * #8 *top_left_bottom* - defaults to *middle_left* if `nil` # * #0 *top_middle_bottom* - defaults to *cross* if `nil` # * #4 *top_right_bottom* - defaults to *middle_right* if `nil` def crossing_chars( cross : String | Char, top_left : String | Char, top_middle : String | Char, top_right : String | Char, middle_right : String | Char, bottom_right : String | Char, bottom_middle : String | Char, bottom_left : String | Char, middle_left : String | Char, top_left_bottom : String | Char | Nil = nil, top_middle_bottom : String | Char | Nil = nil, top_right_bottom : String | Char | Nil = nil, ) : self @crossing_char = cross.to_s @crossing_top_left_char = top_left.to_s @crossing_top_middle_char = top_middle.to_s @crossing_top_right_char = top_right.to_s @crossing_middle_right_char = middle_right.to_s @crossing_bottom_right_char = bottom_right.to_s @crossing_bottom_middle_char = bottom_middle.to_s @crossing_bottom_left_char = bottom_left.to_s @crossing_middle_left_char = middle_left.to_s @crossing_top_left_bottom_char = top_left_bottom.try &.to_s || middle_left.to_s @crossing_top_middle_bottom_char = top_middle_bottom.try &.to_s || cross.to_s @crossing_top_right_bottom_char = top_right_bottom.try &.to_s || middle_right.to_s self end # Sets the default character used for each cross type. # # See `#crossing_chars`. def default_crossing_char(char : String | Char) : self self .crossing_chars( char, char, char, char, char, char, char, char, char, ) self end protected def crossing_chars : Tuple(String, String, String, String, String, String, String, String, String, String, String, String) { @crossing_char, @crossing_top_left_char, @crossing_top_middle_char, @crossing_top_right_char, @crossing_middle_right_char, @crossing_bottom_right_char, @crossing_bottom_middle_char, @crossing_bottom_left_char, @crossing_middle_left_char, @crossing_top_left_bottom_char, @crossing_top_middle_bottom_char, @crossing_top_right_bottom_char, } end end ================================================ FILE: src/components/console/src/input/argument.cr ================================================ # Represents a value (or array of values) provided to a command as a ordered positional argument, # that can either be required or optional, optionally with a default value and/or description. # # Arguments are strings separated by spaces that come _after_ the command name. # For example, `./console test arg1 "Arg2 with spaces"`. # # Arguments can be added via the `ACON::Command#argument` method, # or by instantiating one manually as part of an `ACON::Input::Definition`. # The value of the argument could then be accessed via one of the `ACON::Input::Interface#argument` overloads. # # See `ACON::Input::Interface` for more examples on how arguments/options are parsed, and how they can be accessed. class Athena::Console::Input::Argument @[Flags] # Represents the possible modes of an `ACON::Input::Argument`, # that describe the "type" of the argument. # # Modes can also be combined using the [Enum.[]](https://crystal-lang.org/api/Enum.html#%5B%5D%28%2Avalues%29-macro) macro. # For example, `ACON::Input::Argument::Mode[:required, :is_array]` which defines a required array argument. enum Mode # Represents a required argument that _MUST_ be provided. # Otherwise the command will not run. REQUIRED # Represents an optional argument that could be omitted. OPTIONAL # Represents an argument that accepts a variable amount of values. # Arguments of this type must be last. IS_ARRAY end # Returns the name of the `self`. getter name : String # Returns the `ACON::Input::Argument::Mode` of `self`. getter mode : ACON::Input::Argument::Mode # Returns the description of `self`. getter description : String @default : ACON::Input::Value? = nil @suggested_values : Array(String) | Proc(ACON::Completion::Input, Array(String)) | Nil def initialize( @name : String, @mode : ACON::Input::Argument::Mode = :optional, @description : String = "", default = nil, @suggested_values : Array(String) | Proc(ACON::Completion::Input, Array(String)) | Nil = nil, ) raise ACON::Exception::InvalidArgument.new "An argument name cannot be blank." if name.blank? self.default = default end # Returns the default value of `self`, if any. def default @default.try do |value| case value when ACON::Input::Value::Array value.value.map &.value else value.value end end end # Returns the default value of `self`, if any, converted to the provided *type*. def default(type : T.class) : T forall T {% if T.nilable? %} self.default.as T {% else %} @default.not_nil!.get T {% end %} end # Sets the default value of `self`. def default=(default = nil) raise ACON::Exception::Logic.new "Cannot set a default value when the argument is required." if @mode.required? && !default.nil? if @mode.is_array? if default.nil? return @default = ACON::Input::Value::Array.new elsif !default.is_a? Array raise ACON::Exception::Logic.new "Default value for an array argument must be an array." end end @default = ACON::Input::Value.from_value default end # Returns `true` if this argument is able to suggest values, otherwise `false` def has_completion? : Bool !@suggested_values.nil? end # Determines what values should be added to the possible *suggestions* based on the provided *input*. def complete(input : ACON::Completion::Input, suggestions : ACON::Completion::Suggestions) : Nil return unless values = @suggested_values if values.is_a?(Proc) values = values.call input end suggestions.suggest_values values end # Returns `true` if `self` is a required argument, otherwise `false`. def required? : Bool @mode.required? end # Returns `true` if `self` expects an array of values, otherwise `false`. # ameba:disable Naming/PredicateName def is_array? : Bool @mode.is_array? end end ================================================ FILE: src/components/console/src/input/argv.cr ================================================ # An `ACON::Input::Interface` based on [ARGV](https://crystal-lang.org/api/toplevel.html#ARGV). class Athena::Console::Input::ARGV < Athena::Console::Input @tokens : Array(String) @parsed : Array(String) = [] of String def self.new(*tokens : String) new tokens.to_a end def initialize(@tokens : Array(String) = ::ARGV, definition : ACON::Input::Definition? = nil) super definition end # :inherit: # ameba:disable Metrics/CyclomaticComplexity def first_argument : String? is_option = false @tokens.each_with_index do |token, idx| if !token.empty? && token.starts_with? '-' next if token.includes?('=') || @tokens[idx + 1]?.nil? name = '-' == token.char_at(1) ? token[2..] : token[-1..] if !@options.has_key?(name) && !@definition.has_shortcut?(name) # noop elsif (@options.has_key?(name) || @options.has_key?(name = @definition.shortcut_to_name(name))) && @tokens[idx + 1]? == @options[name].value is_option = true end next end if is_option is_option = false next end return token end nil end # :inherit: def has_parameter?(*values : String, only_params : Bool = false) : Bool @tokens.each do |token| return false if only_params && "--" == token values.each do |value| leading = value.starts_with?("--") ? "#{value}=" : value return true if token == value || (!leading.empty? && token.starts_with? leading) end end false end # :inherit: def parameter(value : String, default : _ = false, only_params : Bool = false) tokens = @tokens.dup while token = tokens.shift? return default if only_params && "--" == token return tokens.shift? if token == value leading = value.starts_with?("--") ? "#{value}=" : value return token[leading.size..] if !leading.empty? && token.starts_with? leading end default end # :inherit: def to_s(io : IO) : Nil @tokens.join io, " " do |token, join_io| if match = token.match /^(-[^=]+=)(.+)/ join_io << match[1] join_io << self.escape_token match[2] next end if !token.empty? && '-' != token[0] join_io << self.escape_token token next end join_io << token end end protected def parse : Nil parse_options = true @parsed = @tokens.dup while token = @parsed.shift? parse_options = self.parse_token token, parse_options end end protected def parse_token(token : String, parse_options : Bool) : Bool if parse_options && token.empty? self.parse_argument token elsif parse_options && "--" == token return false elsif parse_options && token.starts_with? "--" self.parse_long_option token elsif parse_options && token.starts_with?('-') && "-" != token self.parse_short_option token else self.parse_argument token end parse_options end private def parse_argument(token : String) : Nil count = @arguments.size # If expecting another argument, add it. if @definition.has_argument? count argument = @definition.argument count @arguments[argument.name] = argument.is_array? ? ACON::Input::Value::Array.new(token) : ACON::Input::Value.from_value token # If the last argument IS_ARRAY, append token to last argument. elsif @definition.has_argument?(count - 1) && @definition.argument(count - 1).is_array? argument = @definition.argument(count - 1) @arguments[argument.name].as(ACON::Input::Value::Array) << token # TODO: Handle unexpected argument. else end end private def parse_long_option(token : String) : Nil name = token.lchop "--" if pos = name.index '=' if (value = name[(pos + 1)..]).empty? @parsed.unshift value end self.add_long_option name[0, pos], value else self.add_long_option name, nil end end # ameba:disable Metrics/CyclomaticComplexity private def add_long_option(name : String, value : String?) : Nil unless @definition.has_option?(name) raise ACON::Exception::Runtime.new "The '--#{name}' option does not exist." unless @definition.has_negation? name option_name = @definition.negation_to_name name raise ACON::Exception::Runtime.new "The '--#{name}' option does not accept a value." unless value.nil? return @options[option_name] = ACON::Input::Value.from_value false end option = @definition.option name if !value.nil? && !option.accepts_value? raise ACON::Exception::Runtime.new "The --#{option.name} option does not accept a value." end if value.in?("", nil) && option.accepts_value? && !@parsed.empty? next_value = @parsed.shift? if ((v = next_value.presence) && '-' != v.char_at(0)) || next_value.in?("", nil) value = next_value else @parsed.unshift next_value || "" end end if value.nil? raise ACON::Exception::Runtime.new "The --#{option.name} option requires a value." if option.value_required? value = true if !option.is_array? && !option.value_optional? end if option.is_array? (@options[name] ||= ACON::Input::Value::Array.new).as(ACON::Input::Value::Array) << value else @options[name] = ACON::Input::Value.from_value value end end private def parse_short_option(token : String) : Nil name = token.lchop '-' if name.size > 1 if @definition.has_shortcut?(name[0]) && @definition.option_for_shortcut(name[0]).accepts_value? # Option with a value & no space self.add_short_option name[0], name[1..] else self.parse_short_option_set name end else self.add_short_option name, nil end end private def parse_short_option_set(name : String) : Nil length = name.size name.each_char_with_index do |char, idx| raise ACON::Exception::Runtime.new "The -#{char} option does not exist." unless @definition.has_shortcut? char option = @definition.option_for_shortcut char if option.accepts_value? self.add_long_option option.name, idx == length - 1 ? nil : name[(idx + 1)..] break else self.add_long_option option.name, nil end end end private def add_short_option(name : String | Char, value : String?) : Nil name = name.to_s raise ACON::Exception::Runtime.new "The -#{name} option does not exist." if !@definition.has_shortcut? name self.add_long_option @definition.option_for_shortcut(name).name, value end end ================================================ FILE: src/components/console/src/input/definition.cr ================================================ # Represents a collection of `ACON::Input::Argument`s and `ACON::Input::Option`s that are to be parsed from an `ACON::Input::Interface`. # # Can be used to set the inputs of an `ACON::Command` via the `ACON::Command#definition=` method if so desired, # instead of using the dedicated methods. class Athena::Console::Input::Definition getter options : ::Hash(String, ACON::Input::Option) = ::Hash(String, ACON::Input::Option).new getter arguments : ::Hash(String, ACON::Input::Argument) = ::Hash(String, ACON::Input::Argument).new @last_array_argument : ACON::Input::Argument? = nil @last_optional_argument : ACON::Input::Argument? = nil @shortcuts = ::Hash(String, String).new @negations = ::Hash(String, String).new getter required_argument_count : Int32 = 0 def self.new(definition : ::Hash(String, ACON::Input::Option) | ::Hash(String, ACON::Input::Argument)) : self new definition.values end def self.new(*definitions : ACON::Input::Argument | ACON::Input::Option) : self new definitions.to_a end def initialize(definition : Array(ACON::Input::Argument | ACON::Input::Option) = Array(ACON::Input::Argument | ACON::Input::Option).new) self.definition = definition end # Adds the provided *argument* to `self`. def <<(argument : ACON::Input::Argument) : Nil raise ACON::Exception::Logic.new "An argument with the name '#{argument.name}' already exists." if @arguments.has_key?(argument.name) if last_array_argument = @last_array_argument raise ACON::Exception::Logic.new "Cannot add a required argument '#{argument.name}' after Array argument '#{last_array_argument.name}'." end if argument.required? && (last_optional_argument = @last_optional_argument) raise ACON::Exception::Logic.new "Cannot add required argument '#{argument.name}' after the optional argument '#{last_optional_argument.name}'." end if argument.is_array? @last_array_argument = argument end if argument.required? @required_argument_count += 1 else @last_optional_argument = argument end @arguments[argument.name] = argument end # Adds the provided *options* to `self`. def <<(option : ACON::Input::Option) : Nil if self.has_option?(option.name) && option != self.option(option.name) raise ACON::Exception::Logic.new "An option named '#{option.name}' already exists." end if self.has_negation?(option.name) raise ACON::Exception::Logic.new "An option named '#{option.name}' already exists." end if shortcut = option.shortcut shortcut.split('|', remove_empty: true) do |s| if self.has_shortcut?(s) && option != self.option_for_shortcut(s) raise ACON::Exception::Logic.new "An option with shortcut '#{s}' already exists." end end end @options[option.name] = option if shortcut shortcut.split('|', remove_empty: true) do |s| @shortcuts[s] = option.name end end if option.negatable? negated_name = "no-#{option.name}" raise ACON::Exception::Logic.new "An option named '#{negated_name}' already exists." if self.has_option? negated_name @negations[negated_name] = option.name end end # Adds the provided *arguments* to `self`. def <<(arguments : Array(ACON::Input::Argument | ACON::Input::Option)) : Nil arguments.each do |arg| self.<< arg end end # Overrides the arguments and options of `self` to those in the provided *definition*. def definition=(definition : Array(ACON::Input::Argument | ACON::Input::Option)) : Nil arguments = Array(ACON::Input::Argument).new options = Array(ACON::Input::Option).new definition.each do |d| case d in ACON::Input::Argument then arguments << d in ACON::Input::Option then options << d end end self.arguments = arguments self.options = options end # Overrides the arguments of `self` to those in the provided *arguments* array. def arguments=(arguments : Array(ACON::Input::Argument)) : Nil @arguments.clear @required_argument_count = 0 @last_array_argument = nil @last_optional_argument = nil self.<< arguments end # Returns the `ACON::Input::Argument` with the provided *name_or_index*, # otherwise raises `ACON::Exception::InvalidArgument` if that argument is not defined. def argument(name_or_index : String | Int32) : ACON::Input::Argument raise ACON::Exception::InvalidArgument.new "The argument '#{name_or_index}' does not exist." unless self.has_argument? name_or_index case name_or_index in String then @arguments[name_or_index] in Int32 then @arguments.values[name_or_index] end end # Returns `true` if `self` has an argument with the provided *name_or_index*. def has_argument?(name_or_index : String | Int32) : Bool case name_or_index in String then @arguments.has_key? name_or_index in Int32 then !@arguments.values.[name_or_index]?.nil? end end # Returns the number of `ACON::Input::Argument`s defined within `self`. def argument_count : Int32 !@last_array_argument.nil? ? Int32::MAX : @arguments.size end # Returns a `::Hash` whose keys/values represent the names and default values of the `ACON::Input::Argument`s defined within `self`. def argument_defaults : ::Hash @arguments.to_h do |(name, arg)| {name, arg.default} end end # Overrides the options of `self` to those in the provided *options* array. def options=(options : Array(ACON::Input::Option)) : Nil @options.clear @shortcuts.clear @negations.clear self.<< options end # Returns the `ACON::Input::Option` with the provided *name_or_index*, # otherwise raises `ACON::Exception::InvalidArgument` if that option is not defined. def option(name_or_index : String | Int32) : ACON::Input::Option raise ACON::Exception::InvalidArgument.new "The '--#{name_or_index}' option does not exist." unless self.has_option? name_or_index case name_or_index in String then @options[name_or_index] in Int32 then @options.values[name_or_index] end end # Returns a `::Hash` whose keys/values represent the names and default values of the `ACON::Input::Option`s defined within `self`. def option_defaults : ::Hash @options.to_h do |(name, opt)| {name, opt.default} end end # Returns `true` if `self` has an option with the provided *name_or_index*. def has_option?(name_or_index : String | Int32) : Bool case name_or_index in String then @options.has_key? name_or_index in Int32 then !@options.values.[name_or_index]?.nil? end end # Returns `true` if `self` has a shortcut with the provided *name*, otherwise `false`. def has_shortcut?(name : String | Char) : Bool @shortcuts.has_key? name.to_s end # Returns `true` if `self` has a negation with the provided *name*, otherwise `false`. def has_negation?(name : String | Char) : Bool @negations.has_key? name.to_s end # Returns the name of the `ACON::Input::Option` that maps to the provided *negation*. def negation_to_name(negation : String) : String raise ACON::Exception::InvalidArgument.new "The '--#{negation}' option does not exist." unless self.has_negation? negation @negations[negation] end # Returns the name of the `ACON::Input::Option` with the provided *shortcut*. def option_for_shortcut(shortcut : String | Char) : ACON::Input::Option self.option self.shortcut_to_name shortcut.to_s end # Returns an optionally *short* synopsis based on the `ACON::Input::Argument`s and `ACON::Input::Option`s defined within `self`. # # The synopsis being the [docopt](http://docopt.org) string representing the expected options/arguments. # E.g. ` move [--speed=]`. # ameba:disable Metrics/CyclomaticComplexity def synopsis(short : Bool = false) : String elements = [] of String if short && !@options.empty? elements << "[options]" elsif !short @options.each_value do |opt| value = "" if opt.accepts_value? value = sprintf( " %s%s%s", opt.value_optional? ? "[" : "", opt.name.upcase, opt.value_optional? ? "]" : "", ) end shortcut = (s = opt.shortcut) ? sprintf("-%s|", s) : "" negation = opt.negatable? ? sprintf("|--no-%s", opt.name) : "" elements << "[#{shortcut}--#{opt.name}#{value}#{negation}]" end end if !elements.empty? && !@arguments.empty? elements << "[--]" end tail = "" @arguments.each_value do |arg| element = "<#{arg.name}>" element += "..." if arg.is_array? unless arg.required? element = "[#{element}" tail += "]" end elements << element end %(#{elements.join " "}#{tail}) end protected def shortcut_to_name(shortcut : String) : String raise ACON::Exception::InvalidArgument.new "The '-#{shortcut}' option does not exist." unless self.has_shortcut? shortcut @shortcuts[shortcut] end end ================================================ FILE: src/components/console/src/input/hash.cr ================================================ # An `ACON::Input::Interface` based on a [Hash](https://crystal-lang.org/api/Hash.html). # # Primarily useful for manually invoking commands, or as part of tests. # # ``` # ACON::Input::Hash.new(name: "George", "--foo": "bar") # ``` # # The keys of the input should be the name of the argument. # Options should have `--` prefixed to their name. class Athena::Console::Input::Hash < Athena::Console::Input @parameters : ::Hash(String, ACON::Input::Value) def self.new(*args : _) : self new args end def self.new(**args : _) : self new args.to_h end def initialize(args : ::Hash = ::Hash(NoReturn, NoReturn).new, definition : ACON::Input::Definition? = nil) hash = ::Hash(String, ACON::Input::Value).new args.each do |key, value| hash[key.to_s] = ACON::Input::Value.from_value value end @parameters = hash super definition end def initialize(args : Enumerable, definition : ACON::Input::Definition? = nil) hash = ::Hash(String, ACON::Input::Value).new args.each do |arg| hash[arg.to_s] = ACON::Input::Value::Nil.new end @parameters = hash super definition end # :inherit: def first_argument : String? @parameters.each do |name, value| next if name.starts_with? '-' return value.value.as(String) end nil end # :inherit: def has_parameter?(*values : String, only_params : Bool = false) : Bool @parameters.each do |name, value| value = value.value value = name unless value.is_a? Number return false if only_params && "--" == value return true if values.includes? value end false end # :inherit: def parameter(value : String, default : _ = false, only_params : Bool = false) @parameters.each do |name, v| return default if only_params && ("--" == name || "--" == value) return v.value if value == name end default end # :inherit: def to_s(io : IO) : Nil params = [] of String @parameters.each do |param, val| if param[0] == '-' separator = param[1] == '-' ? '=' : ' ' if val.is_a? ACON::Input::Value::Array val.value.each do |v| params << %(#{param}#{v.value != "" && !val.value.nil? ? %<#{separator}#{self.escape_token v}> : ""}) end else params << %(#{param}#{val.value != "" && !val.value.nil? ? %<#{separator}#{self.escape_token val}> : ""}) end else params << self.escape_token val end end params.join io, " " end protected def parse : Nil @parameters.each do |name, value| return if "--" == name if name.starts_with? "--" self.add_long_option name.lchop("--"), value elsif name.starts_with? '-' self.add_short_option name.lchop('-'), value else self.add_argument name, value end end end private def add_argument(name : String, value : ACON::Input::Value) : Nil raise ACON::Exception::InvalidArgument.new "The '#{name}' argument does not exist." if !@definition.has_argument? name @arguments[name] = value end private def add_long_option(name : String, value : ACON::Input::Value) : Nil unless @definition.has_option?(name) raise ACON::Exception::InvalidOption.new "The '--#{name}' option does not exist." unless @definition.has_negation? name option_name = @definition.negation_to_name name return @options[option_name] = ACON::Input::Value::Bool.new false end option = @definition.option name if value.is_a? ACON::Input::Value::Nil raise ACON::Exception::InvalidOption.new "The '--#{option.name}' option requires a value." if option.value_required? value = ACON::Input::Value::Bool.new(true) if !option.is_array? && !option.value_optional? end @options[name] = value end private def add_short_option(name : String, value : ACON::Input::Value) : Nil name = name.to_s raise ACON::Exception::InvalidOption.new "The '-#{name}' option does not exist." if !@definition.has_shortcut? name self.add_long_option @definition.option_for_shortcut(name).name, value end end ================================================ FILE: src/components/console/src/input/input.cr ================================================ require "./interface" require "./streamable" require "./value/*" # Common base implementation of `ACON::Input::Interface`. abstract class Athena::Console::Input include Athena::Console::Input::Streamable # :inherit: property stream : IO? = nil # :inherit: property? interactive : Bool = true @arguments = ::Hash(String, ACON::Input::Value).new @definition : ACON::Input::Definition @options = ::Hash(String, ACON::Input::Value).new def initialize(definition : ACON::Input::Definition? = nil) if definition.nil? @definition = ACON::Input::Definition.new else @definition = definition self.bind definition self.validate end end # :inherit: def argument(name : String) : String? raise ACON::Exception::InvalidArgument.new "The '#{name}' argument does not exist." unless @definition.has_argument? name value = if @arguments.has_key? name @arguments[name] else @definition.argument(name).default end case value when Nil, ACON::Input::Value::Nil then nil else value.to_s end end # :inherit: def argument(name : String, type : T.class) : T forall T raise ACON::Exception::InvalidArgument.new "The '#{name}' argument does not exist." unless @definition.has_argument? name {% unless T.nilable? %} if !@definition.argument(name).required? && @definition.argument(name).default.nil? raise ACON::Exception::Logic.new "Cannot cast optional argument '#{name}' to non-nilable type '#{T}' without a default." end {% end %} if @arguments.has_key? name return @arguments[name].get T end @definition.argument(name).default T end # :inherit: def set_argument(name : String, value : _) : Nil raise ACON::Exception::InvalidArgument.new "The '#{name}' argument does not exist." unless @definition.has_argument? name @arguments[name] = ACON::Input::Value.from_value value end # :inherit: def arguments : ::Hash @definition.argument_defaults.merge(self.resolve @arguments) end # :inherit: def has_argument?(name : String) : Bool @definition.has_argument? name end # :inherit: def option(name : String) : String? if @definition.has_negation?(name) self.option(@definition.negation_to_name(name), Bool?).try do |v| return (!v).to_s end return end raise ACON::Exception::InvalidArgument.new "The '#{name}' option does not exist." unless @definition.has_option? name value = if @options.has_key? name @options[name] else @definition.option(name).default end case value when Nil, ACON::Input::Value::Nil then nil else value.to_s end end # :inherit: def option(name : String, type : T.class) : T forall T {% if T <= Bool? %} if @definition.has_negation?(name) negated_name = @definition.negation_to_name(name) if @options.has_key? negated_name return !@options[negated_name].get T end raise "BUG: Didn't return negated value." end {% end %} raise ACON::Exception::InvalidArgument.new "The '#{name}' option does not exist." unless @definition.has_option? name {% unless T <= Bool? %} raise ACON::Exception::Logic.new "Cannot cast negatable option '#{name}' to non 'Bool?' type." if @definition.option(name).negatable? {% end %} {% unless T.nilable? %} if !@definition.option(name).value_required? && !@definition.option(name).negatable? && @definition.option(name).default.nil? raise ACON::Exception::Logic.new "Cannot cast optional option '#{name}' to non-nilable type '#{T}' without a default." end {% end %} if @options.has_key? name return @options[name].get T end @definition.option(name).default T end # :inherit: def set_option(name : String, value : _) : Nil if @definition.has_negation?(name) return @options[@definition.negation_to_name(name)] = ACON::Input::Value.from_value !value end raise ACON::Exception::InvalidArgument.new "The '#{name}' option does not exist." unless @definition.has_option? name @options[name] = ACON::Input::Value.from_value value end # :inherit: def options : ::Hash @definition.option_defaults.merge(self.resolve @options) end # :inherit: def has_option?(name : String) : Bool @definition.has_option?(name) || @definition.has_negation?(name) end # :inherit: def bind(definition : ACON::Input::Definition) : Nil @arguments.clear @options.clear @definition = definition self.parse end protected abstract def parse : Nil # :inherit: def validate : Nil missing_args = @definition.arguments.keys.select do |arg| !@arguments.has_key?(arg) && @definition.argument(arg).required? end raise ACON::Exception::Runtime.new %(Not enough arguments (missing: '#{missing_args.join(", ")}').) unless missing_args.empty? end # Escapes a token via [Process.quote](https://crystal-lang.org/api/Process.html#quote%28arg%3AString%29%3AString-class-method) if it contains unsafe characters. def escape_token(token : String) : String Process.quote token end # :nodoc: def escape_token(token : ACON::Input::Value::String) : String self.escape_token token.value end # :nodoc: def escape_token(token : ACON::Input::Value::Array) : String token.value.join " " do |t| self.escape_token t end end # :nodoc: def escape_token(token : ACON::Input::Value::Nil) : String "" end # :nodoc: def escape_token(token : _) : String self.escape_token token.to_s end private def resolve(hash : ::Hash(String, ACON::Input::Value)) : ::Hash hash.transform_values do |value| case value when ACON::Input::Value::Array value.value.map &.value else value.value end end end end ================================================ FILE: src/components/console/src/input/interface.cr ================================================ require "./definition" # `Athena::Console` uses a dedicated interface for representing an input source. # This allows it to have multiple more specialized implementations as opposed to # being tightly coupled to `STDIN` or a raw [IO](https://crystal-lang.org/api/IO.html). # This interface represents the methods that _must_ be implemented, however implementations can add additional functionality. # # All input sources follow the [docopt](http://docopt.org) standard, used by many CLI utility tools. # Documentation on this type covers functionality/logic common to all inputs. # See each type for more specific information. # # Option and argument values can be accessed via `ACON::Input::Interface#option` and `ACON::Input::Interface#argument` respectively. # There are two overloads, the first accepting just the name of the option/argument as a `String`, returning the raw value as a `String?`, # with arrays being represented as a comma separated list. # The other two overloads accept a `T.class` representing the desired type the value should be parsed as. # For example, given a command with two required and one array arguments: # # ``` # protected def configure : Nil # self # .argument("bool", :required) # .argument("int", :required) # .argument("floats", :is_array) # end # ``` # # Assuming the invocation is `./console test false 10 3.14 172.0 123.7777`, the values could then be accessed like: # # ``` # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # input.argument "bool" # => "false" : String # input.argument "bool", Bool # => false : Bool # input.argument "int", Int8 # => 10 : Int8 # # input.argument "floats" # => "3.14,172.0,123.7777" : String # input.argument "floats", Array(Float64) # => [3.14, 172.0, 123.7777] : Array(Float64) # # ACON::Command::Status::SUCCESS # end # ``` # # The latter syntax is preferred since it correctly types the value. # If a provided value cannot be converted to the expected type, # an `ACON::Exception::Logic` exception will be raised. # E.g. `'123' is not a valid 'Bool'.`. # # TIP: Argument/option modes can be combined. # E.g.`ACON::Input::Argument::Mode[:required, :is_array]` for a required array argument. # # There are a lot of possible combinations in regards to what options are defined versus those are provided. # To better illustrate how these cases are handled, let's look at an example of a command with three `ACON::Input::Option`s: # # ``` # protected def configure : Nil # self # .option("foo", "f") # .option("bar", "b", :required) # .option("baz", "z", :optional) # end # ``` # # The value of `foo` will either be `true` if provided, otherwise `false`; this is the default behavior of `ACON::Input::Option`s. # The `bar` (`b`) option is required to have a value. # A value can be separated from the option's long name by either a space or `=` or by its short name by an optional space. # Finally, the `baz` (`z`) option's value is optional. # # This table shows how the value of each option based on the provided input: # # | Input | foo | bar | baz | # | :-----------------: | :-----: | :--------: | :--------: | # | `--bar=Hello` | `false` | `"Hello"` | `nil` | # | `--bar Hello` | `false` | `"Hello"` | `nil` | # | `-b=Hello` | `false` | `"=Hello"` | `nil` | # | `-b Hello` | `false` | `"Hello"` | `nil` | # | `-bHello` | `false` | `"Hello"` | `nil` | # | `-fzWorld -b Hello` | `true` | `"Hello"` | `"World"` | # | `-zfWorld -b Hello` | `false` | `"Hello"` | `"fWorld"` | # | `-zbWorld` | `false` | `nil` | `"bWorld"` | # # Things get a bit trickier when an optional `ACON::Input::Argument`: # # ``` # protected def configure : Nil # self # .option("foo", "f") # .option("bar", "b", :required) # .option("baz", "z", :optional) # .argument("arg", :optional) # end # ``` # # In some cases you may need to use the special `--` option in order to denote later values should be parsed as arguments, not as a value to an option: # # | Input | bar | baz | arg | # | :--------------------------: | :-------------: | :-------: | :-------: | # | `--bar Hello` | `"Hello"` | `nil` | `nil` | # | `--bar Hello World` | `"Hello"` | `nil` | `"World"` | # | `--bar "Hello World"` | `"Hello World"` | `nil` | `nil` | # | `--bar Hello --baz World` | `"Hello"` | `"World"` | `nil` | # | `--bar Hello --baz -- World` | `"Hello"` | `nil` | `"World"` | # | `-b Hello -z World` | `"Hello"` | `"World"` | `nil` | # # ## Argument/Option Value Completion # # If the [completion script](/Console#console-completion) is installed, command and option names will be auto completed by the shell. # However, value completion may also be implemented in custom commands by providing the suggested values for a particular option/argument. # # ``` # @[ACONA::AsCommand("greet")] # class GreetCommand < ACON::Command # protected def configure : Nil # # The suggested values do not need to be a static array, # # they could be sourced via a class/instance method, a constant, etc. # self # .argument("name", suggested_values: ["Jim", "Bob", "Sally"]) # end # # # ... # end # ``` # # Additionally, a block version of `ACON::Command#argument(name,mode,description,default,&)` and `ACON::Command#option(name,shortcut,value_mode,description,default,&)` may be used if more complex logic is required. # # ``` # @[ACONA::AsCommand("greet")] # class GreetCommand < ACON::Command # protected def configure : Nil # self # .argument("name") do |input| # # The value the user already typed, e.g. the value the user already typed, # # e.g. when typing "greet Ge" before pressing Tab, this will contain "Ge". # current_value = input.completion_value # # # Get the list of username names from somewhere (e.g. the database) # # you may use current_value to filter down the names # available_usernames = ... # # # then suggested the usernames as values # return available_usernames # end # end # # # ... # end # ``` # # TIP: The shell completion script is able to handle huge amounts of suggestions and will automatically filter # the values based on existing input from the user. # You do not have to implement any filter logic in the command. # `input.completion_value` can still be used to filter if it helps with performance, such as reducing amount of rows the DB returns. module Athena::Console::Input::Interface # Returns the first argument from the raw un-parsed input. # Mainly used to get the command that should be executed. abstract def first_argument : String? # Returns `true` if the raw un-parsed input contains one of the provided *values*. # # This method is to be used to introspect the input parameters before they have been validated. # It must be used carefully. # It does not necessarily return the correct result for short options when multiple flags are combined in the same option. # # If *only_params* is `true`, only real parameters are checked. I.e. skipping those that come after the `--` option. abstract def has_parameter?(*values : String, only_params : Bool = false) : Bool # Returns the value of a raw un-parsed parameter for the provided *value*.. # # This method is to be used to introspect the input parameters before they have been validated. # It must be used carefully. # It does not necessarily return the correct result for short options when multiple flags are combined in the same option. # # If *only_params* is `true`, only real parameters are checked. I.e. skipping those that come after the `--` option. abstract def parameter(value : String, default : _ = false, only_params : Bool = false) # Binds the provided *definition* to `self`. # Essentially provides what should be parsed from `self`. abstract def bind(definition : ACON::Input::Definition) : Nil # Validates the input, asserting all of the required parameters are provided. # Raises `ACON::Exception::Runtime` when not enough arguments are given. abstract def validate : Nil # Returns a `::Hash` representing the keys and values of the parsed arguments of `self`. abstract def arguments : ::Hash # Returns the raw string value of the argument with the provided *name*, or `nil` if is optional and was not provided. abstract def argument(name : String) : String? # Returns the value of the argument with the provided *name* converted to the desired *type*. # This method is preferred over `#argument` since it provides better typing. # # Raises an `ACON::Exception::Logic` if the actual argument value could not be converted to a *type*. abstract def argument(name : String, type : T.class) forall T # Sets the *value* of the argument with the provided *name*. abstract def set_argument(name : String, value : _) : Nil # Returns `true` if `self` has an argument with the provided *name*, otherwise `false`. abstract def has_argument?(name : String) : Bool # Returns a `::Hash` representing the keys and values of the parsed options of `self`. abstract def options : ::Hash # Returns the raw string value of the option with the provided *name*, or `nil` if is optional and was not provided. abstract def option(name : String) : String? # Returns the value of the option with the provided *name* converted to the desired *type*. # This method is preferred over `#option` since it provides better typing. # # Raises an `ACON::Exception::Logic` if the actual option value could not be converted to a *type*. abstract def option(name : String, type : T.class) forall T # Sets the *value* of the option with the provided *name*. abstract def set_option(name : String, value : _) : Nil # Returns `true` if `self` has an option with the provided *name*, otherwise `false`. abstract def has_option?(name : String) : Bool # Returns `true` if `self` represents an interactive input, such as a TTY. abstract def interactive? : Bool # Sets if `self` is `#interactive?`. abstract def interactive=(interactive : Bool) # Returns a string representation of the args passed to the command. abstract def to_s(io : IO) : Nil end ================================================ FILE: src/components/console/src/input/option.cr ================================================ # Represents a value (or array of ) provided to a command as optional un-ordered flags # that be setup to accept a value, or represent a boolean flag. # Options can also have an optional shortcut, default value, and/or description. # # Options are specified with two dashes, or one dash when using the shortcut. # For example, `./console test --yell --dir=src -v`. # We have one option representing a boolean value, providing a value to another, and using the shortcut of another. # # Options can be added via the `ACON::Command#option` method, # or by instantiating one manually as part of an `ACON::Input::Definition`. # The value of the option could then be accessed via one of the `ACON::Input::Interface#option` overloads. # # See `ACON::Input::Interface` for more examples on how arguments/options are parsed, and how they can be accessed. class Athena::Console::Input::Option @[Flags] # Represents the possible vale types of an `ACON::Input::Option`. # # Value modes can also be combined using the [Enum.[]](https://crystal-lang.org/api/Enum.html#%5B%5D%28%2Avalues%29-macro) macro. # For example, `ACON::Input::Option::Value[:required, :is_array]` which defines a required array option. enum Value # Represents a boolean flag option that will be `true` if provided, otherwise `false`. # E.g. `--yell`. NONE = 0 # Represents an option that _MUST_ have a value if provided. # The option itself is still optional. # E.g. `--dir=src`. REQUIRED # Represents an option that _MAY_ have a value, but it is not a requirement. # E.g. `--yell` or `--yell=loud`. # # When using the option value mode, it can be hard to distinguish between passing an option without a value and not passing it at all. # In this case you should set the default of the option to `false`, instead of the default of `nil`. # Then you would be able to tell it wasn't passed by the value being `false`, passed without a value as `nil`, and passed with a value. # # NOTE: In this context you will need to work with the raw `String?` representation of the value due to the union of types the value could be. OPTIONAL # Represents an option that can be provided multiple times to produce an array of values. # E.g. `--dir=/foo --dir=/bar`. IS_ARRAY # Similar to `NONE`, but also accepts its negation. # E.g. `--yell` or `--no-yell`. NEGATABLE def accepts_value? : Bool self.required? || self.optional? end end # Returns the name of `self`. getter name : String # Returns the shortcut of `self`, if any. getter shortcut : String? # Returns the `ACON::Input::Option::Value` of `self`. getter value_mode : ACON::Input::Option::Value # Returns the description of `self`. getter description : String @default : ACON::Input::Value? = nil @suggested_values : Array(String) | Proc(ACON::Completion::Input, Array(String)) | Nil def initialize( name : String, shortcut : String | Enumerable(String) | Nil = nil, @value_mode : ACON::Input::Option::Value = :none, @description : String = "", default = nil, @suggested_values : Array(String) | Proc(ACON::Completion::Input, Array(String)) | Nil = nil, ) @name = name.lchop "--" raise ACON::Exception::InvalidArgument.new "An option name cannot be blank." if name.blank? unless shortcut.nil? if shortcut.is_a? Enumerable shortcut = shortcut.join '|' end shortcut = shortcut.lchop('-').split(/(?:\|)-?/, remove_empty: true).map(&.strip.lchop('-')) # Ensure each grouping contains only the same character shortcut.each do |s| unless s.split("").uniq!.size == 1 raise ACON::Exception::InvalidArgument.new "An option shortcut must consist of the same character, got '#{s}'." end end shortcut = shortcut.join '|' raise ACON::Exception::InvalidArgument.new "An option shortcut cannot be blank." if shortcut.blank? end @shortcut = shortcut if @suggested_values && !self.accepts_value? raise ACON::Exception::Logic.new "Cannot set suggested values if the option does not accept a value." end if @value_mode.is_array? && !self.accepts_value? raise ACON::Exception::InvalidArgument.new " Cannot have VALUE::IS_ARRAY option mode when the option does not accept a value." end if @value_mode.negatable? && self.accepts_value? raise ACON::Exception::InvalidArgument.new " Cannot have VALUE::NEGATABLE option mode if the option also accepts a value." end self.default = default end def_equals @name, @shortcut, @default, @value_mode # Returns the default value of `self`, if any. def default @default.try do |value| case value when ACON::Input::Value::Array value.value.map &.value else value.value end end end # Returns the default value of `self`, if any, converted to the provided *type*. def default(type : T.class) : T forall T {% if T.nilable? %} self.default.as T {% else %} @default.not_nil!.get T {% end %} end # Sets the default value of `self`. def default=(default = nil) : Nil raise ACON::Exception::Logic.new "Cannot set a default value when using Value::NONE mode." if @value_mode.none? && !default.nil? if @value_mode.is_array? if default.nil? return @default = ACON::Input::Value::Array.new else raise ACON::Exception::Logic.new "Default value for an array option must be an array." unless default.is_a? Array end end @default = ACON::Input::Value.from_value (@value_mode.accepts_value? || @value_mode.negatable?) ? default : false end # Returns `true` if this option is able to suggest values, otherwise `false` def has_completion? : Bool !@suggested_values.nil? end # Determines what values should be added to the possible *suggestions* based on the provided *input*. def complete(input : ACON::Completion::Input, suggestions : ACON::Completion::Suggestions) : Nil return unless values = @suggested_values if values.is_a?(Proc) values = values.call input end suggestions.suggest_values values end # Returns `true` if `self` is able to accept a value, otherwise `false`. def accepts_value? : Bool @value_mode.accepts_value? end # Returns `true` if `self` is a required argument, otherwise `false`. # ameba:disable Naming/PredicateName def is_array? : Bool @value_mode.is_array? end # Returns `true` if `self` is negatable, otherwise `false`. def negatable? : Bool @value_mode.negatable? end # Returns `true` if `self` accepts a value and it is required, otherwise `false`. def value_required? : Bool @value_mode.required? end # Returns `true` if `self` accepts a value but is optional, otherwise `false`. def value_optional? : Bool @value_mode.optional? end end ================================================ FILE: src/components/console/src/input/streamable.cr ================================================ # An extension of `ACON::Input::Interface` that supports input stream [IOs](https://crystal-lang.org/api/IO.html). # # Allows customizing where the input data is read from. # Defaults to `STDIN`. module Athena::Console::Input::Streamable include Athena::Console::Input::Interface # Returns the input stream. abstract def stream : IO? # Sets the input stream. abstract def stream=(@stream : IO?) end ================================================ FILE: src/components/console/src/input/string_line.cr ================================================ # An `ACON::Input::Interface` based on a command line string. class Athena::Console::Input::StringLine < Athena::Console::Input::ARGV private REGEX_UNQUOTED_STRING = /([^\s\\]+?)/ private REGEX_QUOTED_STRING = /(?:"([^"\\]*(?:\\.[^"\\]*)*)"|\'([^\'\\]*(?:\\.[^\'\\]*)*)\')/ def initialize(input : String) super [] of String @tokens = self.tokenize input end private def tokenize(input : String) : Array(String) tokens = [] of String length = input.size idx = 0 token = "" while idx < length if '\\' == input[idx] idx += 1 token += input[idx]? || "" idx += 1 next end match = if m = input.match /\G\s+/, idx unless token.blank? tokens << token token = "" end m elsif m = input.match /\G([^="\'\s]+?)(=?)(#{REGEX_QUOTED_STRING}+)/, idx token += %(#{m[1]}#{m[2]}#{m[3][1...-1].gsub(/("\'|\'"|\'\'|\"\")/, "").gsub(/\\'/, {"\\'" => "'"})}) m elsif m = input.match /\G#{REGEX_QUOTED_STRING}/, idx token += m[0][1...-1].gsub(/\\'/, {"\\'" => "'"}) m elsif m = input.match /\G#{REGEX_UNQUOTED_STRING}/, idx token += m[1] m else raise ACON::Exception::InvalidArgument.new "Unable to parse input neat '... #{input[idx, 10]} ...'." end idx += match[0].size end tokens << token unless token.blank? tokens end end ================================================ FILE: src/components/console/src/input/value/array.cr ================================================ abstract struct Athena::Console::Input::Value; end # :nodoc: struct Athena::Console::Input::Value::Array < Athena::Console::Input::Value getter value : ::Array(Athena::Console::Input::Value) def self.from_array(array : ::Array) : self new(array.map { |item| ACON::Input::Value.from_value item }) end def self.new(value) new [ACON::Input::Value.from_value value] end def self.new new [] of ACON::Input::Value end def initialize(@value : ::Array(Athena::Console::Input::Value)); end def <<(value) @value << ACON::Input::Value.from_value value end def get(type : ::Array(T).class) : ::Array(T) forall T arr = ::Array(T).new @value.each do |v| arr << v.get T end arr end def get(type : ::Array(T)?.class) : ::Array(T)? forall T arr = ::Array(T).new @value.each do |v| arr << v.get T end arr || nil end def to_s(io : IO) : ::Nil @value.join io, ',' end # :nodoc: forward_missing_to @value end ================================================ FILE: src/components/console/src/input/value/bool.cr ================================================ # :nodoc: struct Athena::Console::Input::Value::Bool < Athena::Console::Input::Value getter value : ::Bool def initialize(@value : ::Bool); end def get(type : ::Bool.class) : ::Bool @value end def get(type : ::Bool?.class) : ::Bool? @value.try do |v| return v end nil end end ================================================ FILE: src/components/console/src/input/value/nil.cr ================================================ # :nodoc: struct Athena::Console::Input::Value::Nil < Athena::Console::Input::Value def value : ::Nil; end end ================================================ FILE: src/components/console/src/input/value/number.cr ================================================ # :nodoc: struct Athena::Console::Input::Value::Number < Athena::Console::Input::Value getter value : ::Number::Primitive def initialize(@value : ::Number::Primitive); end {% for type in ::Number::Primitive.union_types %} def get(type : {{type.id}}.class) : {{type.id}} {{type.id}}.new @value end def get(type : {{type.id}}?.class) : {{type.id}}? {{type.id}}.new(@value) || nil end {% end %} end ================================================ FILE: src/components/console/src/input/value/string.cr ================================================ # :nodoc: struct Athena::Console::Input::Value::String < Athena::Console::Input::Value getter value : ::String def initialize(@value : ::String); end def get(type : ::Bool.class) : ::Bool raise ACON::Exception::Logic.new "'#{@value}' is not a valid 'Bool'." unless @value.in? "true", "false" @value == "true" end def get(type : ::Bool?.class) : ::Bool? (@value == "true").try do |v| raise ACON::Exception::Logic.new "'#{@value}' is not a valid 'Bool?'." unless @value.in? "true", "false" return v end nil end def get(type : ::Array(T).class) : ::Array(T) forall T Array.from_array(@value.split(',')).get ::Array(T) end def get(type : ::Array(T)?.class) : ::Array(T)? forall T Array.from_array(@value.split(',')).get ::Array(T)? end {% for type in ::Number::Primitive.union_types %} def get(type : {{type.id}}.class) : {{type.id}} {{type.id}}.new @value rescue ArgumentError raise ACON::Exception::Logic.new "'#{@value}' is not a valid '#{{{type.id}}}'." end def get(type : {{type.id}}?.class) : {{type.id}}? {{type.id}}.new(@value) || nil rescue ArgumentError raise ACON::Exception::Logic.new "'#{@value}' is not a valid '#{{{type.id}}}'." end {% end %} end ================================================ FILE: src/components/console/src/input/value/value.cr ================================================ # :nodoc: abstract struct Athena::Console::Input::Value def self.from_value(value : T) : self forall T case value when ACON::Input::Value then value when ::Nil then ACON::Input::Value::Nil.new when ::String then ACON::Input::Value::String.new value when ::Number then ACON::Input::Value::Number.new value when ::Bool then ACON::Input::Value::Bool.new value when ::Array then ACON::Input::Value::Array.from_array value else raise "Unsupported type: #{T}." end end def get(type : ::String.class) : ::String self.to_s end def get(type : ::String?.class) : ::String? self.to_s.presence end def get(type : T.class) forall T raise ACON::Exception::Logic.new "'#{self.value}' is not a valid '#{T}'." end def to_s(io : IO) : ::Nil self.value.to_s io end def inspect(io : IO) : ::Nil io << "#" end abstract def value end ================================================ FILE: src/components/console/src/loader/factory.cr ================================================ require "./interface" # A default implementation of `ACON::Loader::Interface` that accepts a `Hash(String, Proc(ACON::Command))`. # # A factory could then be set on the `ACON::Application`: # # ``` # application = MyCustomApplication.new "My CLI" # # application.command_loader = Athena::Console::Loader::Factory.new({ # "command1" => Proc(ACON::Command).new { Command1.new }, # "app:create-user" => Proc(ACON::Command).new { CreateUserCommand.new }, # }) # # application.run # ``` struct Athena::Console::Loader::Factory include Athena::Console::Loader::Interface @factories : Hash(String, Proc(ACON::Command)) def initialize(@factories : Hash(String, Proc(ACON::Command))); end # :inherit: def get(name : String) : ACON::Command if factory = @factories[name]? factory.call else raise ACON::Exception::CommandNotFound.new "Command '#{name}' does not exist." end end # :inherit: def has?(name : String) : Bool @factories.has_key? name end # :inherit: def names : Array(String) @factories.keys end end ================================================ FILE: src/components/console/src/loader/interface.cr ================================================ # Normally the `ACON::Application#add` method requires instances of each command to be provided. # `ACON::Loader::Interface` provides a way to lazily instantiate only the command(s) being called, # which can be more performant since not every command needs instantiated. module Athena::Console::Loader::Interface # Returns an `ACON::Command` with the provided *name*. # Raises `ACON::Exception::CommandNotFound` if it is not defined. abstract def get(name : String) : ACON::Command # Returns `true` if `self` has a command with the provided *name*, otherwise `false`. abstract def has?(name : String) : Bool # Returns all of the command names defined within `self`. abstract def names : Array(String) end ================================================ FILE: src/components/console/src/output/console_output.cr ================================================ abstract class Athena::Console::Output; end require "./console_output_interface" require "./io" # An `ACON::Output::ConsoleOutputInterface` that wraps `STDOUT` and `STDERR`. class Athena::Console::Output::ConsoleOutput < Athena::Console::Output::IO include Athena::Console::Output::ConsoleOutputInterface # Sets the `ACON::Output::Interface` that represents `STDERR`. setter stderr : ACON::Output::Interface @console_section_outputs = Array(ACON::Output::Section).new def initialize( verbosity : ACON::Output::Verbosity = :normal, decorated : Bool? = nil, formatter : ACON::Formatter::Interface? = nil, ) super STDOUT, verbosity, decorated, formatter @stderr = ACON::Output::IO.new STDERR, verbosity, decorated, @formatter actual_decorated = self.decorated? if decorated.nil? self.decorated = actual_decorated && @stderr.decorated? end end # :inherit: def section : ACON::Output::Section ACON::Output::Section.new( self.io, @console_section_outputs, self.verbosity, self.decorated?, self.formatter ) end # :inherit: def error_output : ACON::Output::Interface @stderr end # :inherit: def error_output=(@stderr : ACON::Output::Interface) : Nil end # :inherit: def decorated=(decorated : Bool) : Nil super @stderr.decorated = decorated end # :inherit: def formatter=(formatter : Bool) : Nil super @stderr.formatter = formatter end # :inherit: def verbosity=(verbosity : ACON::Output::Verbosity) : Nil super @stderr.verbosity = verbosity end end ================================================ FILE: src/components/console/src/output/console_output_interface.cr ================================================ require "./interface" # Extension of `ACON::Output::Interface` that adds additional functionality for terminal based outputs. module Athena::Console::Output::ConsoleOutputInterface # include Athena::Console::Output::Interface # Returns an `ACON::Output::Interface` that represents `STDERR`. abstract def error_output : ACON::Output::Interface # Sets the `ACON::Output::Interface` that represents `STDERR`. abstract def error_output=(stderr : ACON::Output::Interface) : Nil abstract def section : ACON::Output::Section end ================================================ FILE: src/components/console/src/output/interface.cr ================================================ # `Athena::Console` uses a dedicated interface for representing an output destination. # This allows it to have multiple more specialized implementations as opposed to # being tightly coupled to `STDOUT` or a raw [IO](https://crystal-lang.org/api/IO.html). # This interface represents the methods that _must_ be implemented, however implementations can add additional functionality. # # The most common implementations include `ACON::Output::ConsoleOutput` which is based on `STDOUT` and `STDERR`, # and `ACON::Output::Null` which can be used when you want to silent all output, such as for tests. # # Each output's `ACON::Output::Verbosity` and output `ACON::Output::Type` can also be configured on a per message basis. module Athena::Console::Output::Interface # Outputs the provided *message* followed by a new line. # The *verbosity* and/or *output_type* parameters can be used to control when and how the *message* is printed. abstract def puts(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil # Outputs the provided *message*. # The *verbosity* and/or *output_type* parameters can be used to control when and how the *message* is printed. abstract def print(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil # Returns the minimum `ACON::Output::Verbosity` required for a message to be printed. abstract def verbosity : ACON::Output::Verbosity # Set the minimum `ACON::Output::Verbosity` required for a message to be printed. abstract def verbosity=(verbosity : ACON::Output::Verbosity) : Nil # Returns `true` if printed messages should have their decorations applied. # I.e. `ACON::Formatter::OutputStyleInterface`. abstract def decorated? : Bool # Sets if printed messages should be *decorated*. abstract def decorated=(decorated : Bool) : Nil # Returns the `ACON::Formatter::Interface` used by `self`. abstract def formatter : ACON::Formatter::Interface # Sets the `ACON::Formatter::Interface` used by `self`. abstract def formatter=(formatter : ACON::Formatter::Interface) : Nil end ================================================ FILE: src/components/console/src/output/io.cr ================================================ # An `ACON::Output::Interface` implementation that wraps an [IO](https://crystal-lang.org/api/IO.html). class Athena::Console::Output::IO < Athena::Console::Output property io : ::IO delegate :to_s, to: @io def initialize( @io : ::IO, verbosity : ACON::Output::Verbosity? = :normal, decorated : Bool? = nil, formatter : ACON::Formatter::Interface? = nil, ) decorated = self.has_color_support? if decorated.nil? super verbosity, decorated, formatter end protected def do_write(message : String, new_line : Bool) : Nil message += EOL if new_line @io.print message end private def io_do_write(message : String, new_line : Bool) : Nil message += EOL if new_line @io.print message end private def has_color_support? : Bool # Respect https://no-color.org. return false if ENV["NO_COLOR"]?.presence # Respect https://force-color.org. return true if ENV["FORCE_COLOR"]?.presence if "Hyper" == ENV["TERM_PROGRAM"]? || ENV.has_key?("COLORTERM") || ENV.has_key?("ANSICON") || "ON" == ENV["ConEmuANSI"]? return true end return @io.tty? unless term = ENV["TERM"]? return false if "dumb" == term # See https://github.com/chalk/supports-color/blob/d4f413efaf8da045c5ab440ed418ef02dbb28bf1/index.js#L157 term.matches? /^((screen|xterm|vt100|vt220|putty|rxvt|ansi|cygwin|linux).*)|(.*-256(color)?(-bce)?)$/ end end ================================================ FILE: src/components/console/src/output/null.cr ================================================ require "./interface" # An `ACON::Output::Interface` that does not output anything, such as for tests. class Athena::Console::Output::Null include Athena::Console::Output::Interface @formatter : ACON::Formatter::Interface? = nil # :inherit: def puts(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil end # :inherit: def print(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil end def puts(message, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil end def print(message, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil end # :inherit: def verbosity : ACON::Output::Verbosity ACON::Output::Verbosity::SILENT end # :inherit: def verbosity=(verbosity : ACON::Output::Verbosity) : Nil end # :inherit: def decorated=(decorated : Bool) : Nil end # :inherit: def decorated? : Bool false end # :inherit: def formatter : ACON::Formatter::Interface @formatter ||= ACON::Formatter::Null.new end # :inherit: def formatter=(formatter : ACON::Formatter::Interface) : Nil end end ================================================ FILE: src/components/console/src/output/output.cr ================================================ require "./interface" # Common base implementation of `ACON::Output::Interface`. abstract class Athena::Console::Output include Athena::Console::Output::Interface @formatter : ACON::Formatter::Interface @verbosity : ACON::Output::Verbosity def initialize( verbosity : ACON::Output::Verbosity? = :normal, decorated : Bool = false, formatter : ACON::Formatter::Interface? = nil, ) @verbosity = verbosity || ACON::Output::Verbosity::NORMAL @formatter = formatter || ACON::Formatter::Output.new @formatter.decorated = decorated end # :inherit: def verbosity : ACON::Output::Verbosity @verbosity end # :inherit: def verbosity=(@verbosity : ACON::Output::Verbosity) : Nil end # :inherit: def formatter : ACON::Formatter::Interface @formatter end # :inherit: def formatter=(@formatter : ACON::Formatter::Interface) : Nil end # :inherit: def decorated? : Bool @formatter.decorated? end # :inherit: def decorated=(decorated : Bool) : Nil @formatter.decorated = decorated end # :inherit: def puts(*messages : String) : Nil self.puts messages end # :inherit: def puts(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil self.write message, true, verbosity, output_type end # :inherit: def puts(message : _, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil self.puts message.to_s, verbosity, output_type end # :inherit: def print(*messages : String) : Nil self.print messages end # :inherit: def print(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil self.write message, false, verbosity, output_type end # :inherit: def print(message : _, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil self.print message.to_s, verbosity, output_type end protected def write( message : String | Enumerable(String), new_line : Bool, verbosity : ACON::Output::Verbosity, output_type : ACON::Output::Type, ) messages = message.is_a?(String) ? {message} : message return if verbosity > self.verbosity messages.each do |m| self.do_write( case output_type in .normal? then @formatter.format m in .plain? then @formatter.format(m).gsub(/(?:<\/?[^>]*>)|(?:[\n]?)/, "") # TODO: Use a more robust strip_tags implementation. in .raw? then m end, new_line ) end end protected abstract def do_write(message : String, new_line : Bool) : Nil end ================================================ FILE: src/components/console/src/output/section.cr ================================================ require "./io" # A `ACON::Output::ConsoleOutput` can be divided into multiple sections that can be written to and cleared independently of one another. # # Output sections can be used for advanced console outputs, such as displaying multiple progress bars which are updated independently, # or appending additional rows to tables. # # ``` # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # raise ArgumentError.new "This command may only be used with `ACON::Output::ConsoleOutputInterface`." unless output.is_a? ACON::Output::ConsoleOutputInterface # # section1 = output.section # section2 = output.section # # section1.puts "Hello" # section2.puts "World!" # # Output contains "Hello\nWorld!\n" # # sleep 1.second # # # Replace "Hello" with "Goodbye!" # section1.overwrite "Goodbye!" # # Output now contains "Goodbye\nWorld!\n" # # sleep 1.second # # # Clear "World!" # section2.clear # # Output now contains "Goodbye!\n" # # sleep 1.second # # # Delete the last 2 lines of the first section # section1.clear 2 # # Output is now empty # # ACON::Command::Status::SUCCESS # end # ``` class Athena::Console::Output::Section < Athena::Console::Output::IO protected getter lines = 0 protected getter max_height : Int32? = nil @content = [] of String @sections : Array(self) @terminal : ACON::Terminal def initialize( io : ::IO, @sections : Array(self), verbosity : ACON::Output::Verbosity, decorated : Bool, formatter : ACON::Formatter::Interface, ) super io, verbosity, decorated, formatter @terminal = ACON::Terminal.new @sections.unshift self end # Returns the full content string contained within `self`. def content : String @content.join end # Clears at most *lines* from `self`. # If *lines* is `nil`, all of `self` is cleared. def clear(lines : Int32? = nil) : Nil return if @content.empty? || !self.decorated? if lines && lines > 0 @content.delete_at -Math.min(lines, @content.size).. else lines = @lines @content.clear end @lines -= lines self.io_do_write self.pop_stream_content_until_current_section((mh = @max_height) ? Math.min(mh, lines) : lines), false end # Overrides the current content of `self` with the provided *messages*. def overwrite(*messages : String) : Nil self.overwrite messages end # Overrides the current content of `self` with the provided *message*. def overwrite(message : String | Enumerable(String)) : Nil self.clear self.puts message end def max_height=(max_height : Int32?) : Nil # Clear output of current section and redraw again with new height previous_max_height = @max_height @max_height = max_height existing_content = self.pop_stream_content_until_current_section previous_max_height ? Math.min(previous_max_height, @lines) : @lines self.io_do_write self.visible_content, false self.io_do_write existing_content, false end protected def add_content(input : String, new_line : Bool = true) : Int32 width = @terminal.width lines = input.split EOL, remove_empty: false lines_added = 0 count = lines.size - 1 lines.each_with_index do |line, idx| # re-add the line break that has been removed in `#lines` for: # - every line that is not the last line # - if new_line is required, also add it to the last line if idx < count || new_line line += EOL end # Skip line if there is no text (or new line) next if line.empty? # For the first line, check if the previous line (last entry of @content) needs to be continued # I.e. does not end with a line break if idx == 0 && @content[-1]?.try { |l| !l.ends_with? EOL } # Deduct the line count of the previous line w = (self.get_display_width(@content[-1]) / width).ceil.to_i @lines -= w.zero? ? 1 : w # Concat previous and new line line = "#{@content[-1]}#{line}" # Replace last entry of @content with the new expanded line @content[-1] = line else @content << line end w = (self.get_display_width(line) / width).ceil.to_i lines_added += w.zero? ? 1 : w end @lines += lines_added lines_added end protected def do_write(message : String, new_line : Bool) : Nil if !new_line && message.ends_with? EOL message = message.chomp new_line = true end unless self.decorated? super message, new_line return end # Check if the previous line (last entry of @content) needs to be continued # i.e. does not end with a line break. In which case, it needs to be erased first lines_to_clear = (last_line = @content[-1]? || "").presence.try { |l| !l.ends_with?(EOL) } ? 1 : 0 delete_last_line = lines_to_clear == 1 lines_added = self.add_content message, new_line max_height = @max_height || 0 if line_overflow = (max_height > 0 && @lines > max_height) # on overflow, clear the whole section and redraw again (to remove the first lines) lines_to_clear = max_height end erased_content = self.pop_stream_content_until_current_section lines_to_clear if line_overflow previous_lines_of_section = @content[@lines - max_height, max_height - lines_added] self.io_do_write previous_lines_of_section.join(""), false end # if the last line was removed, re-print its content together with the new content # otherwise, just print the new content self.io_do_write delete_last_line ? "#{last_line}#{message}" : message, true self.io_do_write erased_content, false end private def get_display_width(input : String) : Int32 ACON::Helper.width ACON::Helper.remove_decoration(self.formatter, input.gsub("\t", " ")) end private def pop_stream_content_until_current_section(lines_to_clear_from_current_section : Int32 = 0) : String number_of_lines_to_clear = lines_to_clear_from_current_section erased_content = Array(String).new @sections.each do |section| break if self == section number_of_lines_to_clear += (max_height = section.max_height) ? Math.min(section.lines, max_height) : section.lines unless (section_content = section.visible_content).empty? unless section_content.ends_with? EOL section_content = "#{section_content}#{EOL}" end erased_content << section_content end end if number_of_lines_to_clear > 0 # Move cursor up n lines self.io_do_write "\e[#{number_of_lines_to_clear}A", false # Erase to end of screen self.io_do_write "\e[0J", false end erased_content.reverse.join end protected def visible_content : String return self.content unless max_height = @max_height @content.replace @content[-Math.min(max_height, @content.size)..] @content.join end end ================================================ FILE: src/components/console/src/output/sized_buffer.cr ================================================ # :nodoc: class Athena::Console::Output::SizedBuffer < Athena::Console::Output @buffer : String = "" @max_length : Int32 def initialize( @max_length : Int32, verbosity : ACON::Output::Verbosity? = :normal, decorated : Bool = false, formatter : ACON::Formatter::Interface? = nil, ) if @max_length < 0 raise ACON::Exception::InvalidArgument.new "'#{self.class}#max_length' must be a positive, got: '#{@max_length}'." end super verbosity, decorated, formatter end def fetch : String content = @buffer @buffer = "" content end protected def do_write(message : String, new_line : Bool) : Nil @buffer += message @buffer += EOL if new_line @buffer = @buffer.chars.last(@max_length).join end end ================================================ FILE: src/components/console/src/output/type.cr ================================================ # Determines how a message should be printed. # # When you output a message via `ACON::Output::Interface#puts` or `ACON::Output::Interface#print`, they also provide a way to set the output type it should be printed: # # ``` # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # output.puts "Some Message", output_type: :raw # # ACON::Command::Status::SUCCESS # end # ``` enum Athena::Console::Output::Type # Normal output, with any styles applied to format the text. NORMAL # Output style tags as is without formatting the string. RAW # Strip any style tags and only output the actual text. PLAIN end ================================================ FILE: src/components/console/src/output/verbosity.cr ================================================ # Verbosity levels determine which messages will be displayed, essentially the same idea as [Log::Severity](https://crystal-lang.org/api/Log/Severity.html) but for console output. # # For example: # # ```sh # # Output nothing # ./console my-command --silent # # # Output only errors # ./console my-command -q # ./console my-command --quiet # # # Display only useful output # ./console my-command # # # Increase the verbosity of messages # ./console my-command -v # # # Also display non-essential information # ./console my-command -vv # # # Display all messages, such as for debugging # ./console my-command -vvv # ``` # # As used in the previous example, the verbosity can be controlled on a command invocation basis using a CLI option, # but may also be globally set via the `SHELL_VERBOSITY` environmental variable. # # When you output a message via `ACON::Output::Interface#puts` or `ACON::Output::Interface#print`, they also provide a way to set the verbosity at which that message should print: # # ``` # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # # Via conditional logic # if output.verbosity.verbose? # output.puts "Obj class: #{obj.class}" # end # # # Inline within the method # output.puts "Only print this in verbose mode or higher", verbosity: :verbose # # ACON::Command::Status::SUCCESS # end # ``` # # TIP: The full stack trace of an exception is printed in `ACON::Output::Verbosity::VERBOSE` mode or higher. enum Athena::Console::Output::Verbosity # Silences all output. # Equivalent to `--silent` CLI option or `SHELL_VERBOSITY=-2`. SILENT = -2 # Output only errors. # Equivalent to `-q`, `--quiet` CLI options or `SHELL_VERBOSITY=-1`. QUIET = -1 # Normal behavior, display only useful messages. # Equivalent not providing any CLI options or `SHELL_VERBOSITY=0`. NORMAL = 0 # Increase the verbosity of messages. # Equivalent to `-v`, `--verbose=1` CLI options or `SHELL_VERBOSITY=1`. VERBOSE = 1 # Display all the informative non-essential messages. # Equivalent to `-vv`, `--verbose=2` CLI options or `SHELL_VERBOSITY=2`. VERY_VERBOSE = 2 # Display all messages, such as for debugging. # Equivalent to `-vvv`, `--verbose=3` CLI options or `SHELL_VERBOSITY=3`. DEBUG = 3 end ================================================ FILE: src/components/console/src/question/abstract_choice.cr ================================================ class Athena::Console::Question(T); end require "./base" # Base type of choice based questions. # See each subclass for more information. abstract class Athena::Console::Question::AbstractChoice(T, ChoiceType) include Athena::Console::Question::Base(T?) # Returns the possible choices. getter choices : Hash(String | Int32, T) # Returns the message to display if the provided answer is not a valid choice. getter error_message : String = "Value '%s' is invalid." # Returns/sets the prompt to use for the question. # The prompt being the character(s) before the user input. property prompt : String = " > " # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. property validator : Proc(T?, ChoiceType)? = nil def self.new(question : String, choices : Indexable(T), default : Int | T | Nil = nil) choices_hash = Hash(String | Int32, T).new choices.each_with_index do |choice, idx| choices_hash[idx] = choice end new question, choices_hash, (default.is_a?(Int) ? choices[default]? : default) end def initialize(question : String, choices : Hash(String | Int32, T), default : T? = nil) super question, default raise ACON::Exception::Logic.new "Choice questions must have at least 1 choice available." if choices.empty? @choices = choices.transform_keys &.as String | Int32 self.validator = ->default_validator(T?) self.autocompleter_values = choices end def error_message=(@error_message : String) : self self.validator = ->default_validator(T?) self end # Sets the validator callback to the provided block. # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. def validator(&@validator : T? -> ChoiceType) : Nil end private def selected_choices(answer : String?) : Array(T) selected_choices = self.parse_answers answer if @trimmable selected_choices.map! &.strip end valid_choices = [] of String selected_choices.each do |value| results = [] of String @choices.each do |key, choice| results << key.to_s if choice == value end raise ACON::Exception::InvalidArgument.new %(The provided answer is ambiguous. Value should be one of #{results.join(" or ") { |i| "'#{i}'" }}.) if results.size > 1 result = @choices.find { |(k, v)| v == value || k.to_s == value }.try &.first.to_s # If none of the keys are a string, assume the original choice values were an Indexable. if @choices.keys.none?(String) && result result = @choices[result.to_i] elsif @choices.has_key? value result = @choices[value] elsif @choices.has_key? result result = @choices[result] end if result.nil? raise ACON::Exception::InvalidArgument.new sprintf(@error_message, value) end valid_choices << result end valid_choices end protected abstract def default_validator(answer : T?) : ChoiceType protected abstract def parse_answers(answer : T?) : Array(String) end ================================================ FILE: src/components/console/src/question/base.cr ================================================ # Common logic shared between all question types. # See each type for more information. module Athena::Console::Question::Base(T) # Returns the question that should be asked. getter question : String # Returns the default value if no valid input is provided. getter default : T # Returns the answer should be hidden. # See [Hiding User Input][Athena::Console::Question--hiding-user-input]. getter? hidden : Bool = false # If hidden questions should fallback on making the response visible if it was unable to be hidden. # See [Hiding User Input][Athena::Console::Question--hiding-user-input]. property? hidden_fallback : Bool = true # Returns how many attempts the user has to enter a valid value when a `#validator` is set. # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. getter max_attempts : Int32? = nil # :nodoc: getter autocompleter_callback : Proc(String, Array(String))? = nil # See [Normalizing the Answer][Athena::Console::Question--normalizing-the-answer]. property normalizer : Proc(T | String, T)? = nil # If multi line text should be allowed in the response. # See [Multiline Input][Athena::Console::Question--multiline-input]. property? multi_line : Bool = false # Returns/sets if the answer value should be automatically [trimmed](https://crystal-lang.org/api/String.html#strip%3AString-instance-method). # See [Trimming the Answer][Athena::Console::Question--trimming-the-answer]. property? trimmable : Bool = true def initialize(@question : String, @default : T) {% if T == Nil T.raise "An ACON::Question generic argument cannot be 'Nil'. Use 'String?' instead." end %} end # :nodoc: def autocompleter_values : Array(String)? if callback = @autocompleter_callback return callback.call "" end nil end # :nodoc: def autocompleter_values=(values : Hash(String, _)?) : self self.autocompleter_values = values.keys + values.values end # :nodoc: def autocompleter_values=(values : Hash?) : self self.autocompleter_values = values.values end # :nodoc: def autocompleter_values=(values : Indexable?) : self if values.nil? @autocompleter_callback = nil return self end callback = Proc(String, Array(String)).new do values.to_a end self.autocompleter_callback &callback self end # :nodoc: def autocompleter_callback(&block : String -> Array(String)) : Nil raise ACON::Exception::Logic.new "A hidden question cannot use the autocompleter." if @hidden @autocompleter_callback = block end # Sets if the answer should be *hidden*. # See [Hiding User Input][Athena::Console::Question--hiding-user-input]. def hidden=(hidden : Bool) : self raise ACON::Exception::Logic.new "A hidden question cannot use the autocompleter." if @autocompleter_callback @hidden = hidden self end # Allow at most *attempts* for the user to enter a valid value when a `#validator` is set. # If *attempts* is `nil`, they have an unlimited amount. # # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. def max_attempts=(attempts : Int32?) : self raise ACON::Exception::InvalidArgument.new "Maximum number of attempts must be a positive value." if attempts && attempts < 0 @max_attempts = attempts self end # Sets the normalizer callback to this block. # See [Normalizing the Answer][Athena::Console::Question--normalizing-the-answer]. def normalizer(&@normalizer : T | String -> T) : Nil end protected def process_response(response : String) response = response.presence || @default # Only call the normalizer with the actual response or a non nil default. if (normalizer = @normalizer) && !response.nil? return normalizer.call response end response.as T end end ================================================ FILE: src/components/console/src/question/choice.cr ================================================ require "./abstract_choice" # A question whose answer _MUST_ be within a set of predefined answers. # If the user enters an invalid answer, an error is displayed and they are prompted again. # # ``` # question = ACON::Question::Choice.new "What is your favorite color?", {"red", "blue", "green"} # # helper = self.helper ACON::Helper::Question # color = helper.ask input, output, question # ``` # # This would display something like the following: # # ```sh # What is your favorite color? # [0] red # [1] blue # [2] green # > # ``` # # The user would be able to enter the name of the color, or the index associated with it. E.g. `blue` or `2` for `green`. # If a `Hash` is used as the choices, the key of each choice is used instead of its index. # # Similar to `ACON::Question`, the third argument can be set to set the default choice. # This value can also either be the actual value, or the index/key of the related choice. # # ``` # question = ACON::Question::Choice.new "What is your favorite color?", {"c1" => "red", "c2" => "blue", "c3" => "green"}, "c2" # # helper = self.helper ACON::Helper::Question # color = helper.ask input, output, question # ``` # # Which would display something like : # # ```sh # What is your favorite color? # [c1] red # [c2] blue # [c3] green # > # ``` class Athena::Console::Question::Choice(T) < Athena::Console::Question::AbstractChoice(T, T?) protected def default_validator(answer : T?) : T? self.selected_choices(answer).first? end protected def parse_answers(answer : T?) : Array(String) [answer || ""] end end ================================================ FILE: src/components/console/src/question/confirmation.cr ================================================ # Allows prompting the user to confirm an action. # # ``` # question = ACON::Question::Confirmation.new "Continue with this action?", false # helper = self.helper ACON::Helper::Question # # if !helper.ask input, output, question # return ACON::Command::Status::SUCCESS # end # # # ... # ``` # # In this example the user will be asked if they wish to `Continue with this action`. # The `#ask` method will return `true` if the user enters anything starting with `y`, otherwise `false`. class Athena::Console::Question::Confirmation < Athena::Console::Question(Bool) @true_answer_regex : Regex # Creates a new instance of self with the provided *question* string. # The *default* parameter represents the value to return if no valid input was entered. # The *true_answer_regex* can be used to customize the pattern used to determine if the user's input evaluates to `true`. def initialize(question : String, default : Bool = true, @true_answer_regex : Regex = /^y/i) super question, default self.normalizer = ->default_normalizer(String | Bool) end private def default_normalizer(answer : String | Bool) : Bool if answer.is_a? Bool return answer end answer_is_true = answer.matches? @true_answer_regex if false == @default return !answer.blank? && answer_is_true end answer.empty? || answer_is_true end end ================================================ FILE: src/components/console/src/question/multiple_choice.cr ================================================ require "./abstract_choice" # Similar to `ACON::Question::Choice`, but allows for more than one answer to be selected. # This question accepts a comma separated list of answers. # # ``` # question = ACON::Question::MultipleChoice.new "What is your favorite color?", {"red", "blue", "green"} # # helper = self.helper ACON::Helper::Question # answer = helper.ask input, output, question # ``` # # This question is also similar to `ACON::Question::Choice` in that you can provide either the index, key, or value of the choice. # For example submitting `green,0` would result in `["green", "red"]` as the value of `answer`. class Athena::Console::Question::MultipleChoice(T) < Athena::Console::Question::AbstractChoice(T, Array(T)) protected def default_validator(answer : T?) : Array(T) self.selected_choices answer end protected def parse_answers(answer : T?) : Array(String) answer.try(&.split(',')) || [""] end end ================================================ FILE: src/components/console/src/question/question.cr ================================================ require "./base" # This namespaces contains various questions that can be asked via the `ACON::Helper::Question` helper or `ART::Style::Athena` style. # # This class can also be used to ask the user for more information in the most basic form, a simple question and answer. # # ## Usage # # ``` # question = ACON::Question(String?).new "What is your name?", nil # # helper = self.helper ACON::Helper::Question # name = helper.ask input, output, question # ``` # # This will prompt to user to enter their name. If they do not enter valid input, the default value of `nil` will be used. # The default can be customized, ideally with sane defaults to make the UX better. # # ### Trimming the Answer # # By default the answer is [trimmed](https://crystal-lang.org/api/String.html#strip%3AString-instance-method) in order to remove leading and trailing white space. # The `ACON::Question::Base#trimmable=` method can be used to disable this if you need the input as is. # # ``` # question = ACON::Question(String?).new "What is your name?", nil # question.trimmable = false # # helper = self.helper ACON::Helper::Question # name_with_whitespace_and_newline = helper.ask input, output, question # ``` # # ### Multiline Input # # The question helper will stop reading input when it receives a newline character. I.e. the user presses the `ENTER` key. # However in some cases you may want to allow for an answer that spans multiple lines. # The `ACON::Question::Base#multi_line=` method can be used to enable multi line mode. # # ``` # question = ACON::Question(String?).new "Tell me a story.", nil # question.multi_line = true # ``` # # Multiline questions stop reading user input after receiving an end-of-transmission control character. (`Ctrl+D` on Unix systems). # # ### Hiding User Input # # If your question is asking for sensitive information, such as a password, you can set a question to hidden. # This will make it so the input string is not displayed on the terminal, which is equivalent to how password are handled on Unix systems. # # ``` # question = ACON::Question(String?).new "What is your password?.", nil # question.hidden = true # ``` # # WARNING: If no method to hide the response is available on the underlying system/input, it will fallback and allow the response to be seen. # If having the hidden response hidden is vital, you _MUST_ set `ACON::Question::Base#hidden_fallback=` to `false`; which will # raise an exception instead of allowing the input to be visible. # # ### Normalizing the Answer # # The answer can be "normalized" before being validated to fix any small errors or tweak it as needed. # For example, you could normalize the casing of the input: # # ``` # question = ACON::Question(String?).new "Enter your name.", nil # question.normalizer do |input| # input.try &.downcase # end # ``` # # It is possible for *input* to be `nil` in this case, so that need to also be handled in the block. # The block should return a value of the same type of the generic, in this case `String?`. # # NOTE: The normalizer is called first and its return value is used as the input of the validator. # If the answer is invalid do not raise an exception in the normalizer and let the validator handle it. # # ### Validating the Answer # # If the answer to a question needs to match some specific requirements, you can register a question validator to check the validity of the answer. # This callback should raise an exception if the input is not valid, such as `ArgumentError`. Otherwise, it must return the input value. # # ``` # question = ACON::Question(String?).new "Enter your name.", nil # question.validator do |input| # next input if input.nil? || !input.starts_with? /^\d+/ # # raise ArgumentError.new "Invalid name. Cannot start with numeric digits." # end # ``` # # In this example, we are asserting that the user's name does not start with numeric digits. # If the user entered `123Jim`, they would be told it is an invalid answer and prompted to answer the question again. # By default the user would have an unlimited amount of retries to get it right, but this can be customized via `ACON::Question::Base#max_attempts=`. # # ### Autocompletion # # TODO: Implement this. class Athena::Console::Question(T) include Athena::Console::Question::Base(T) # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. property validator : Proc(T, T)? = nil # Sets the validator callback to this block. # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. def validator(&@validator : T -> T) : Nil end end ================================================ FILE: src/components/console/src/spec/expectations/command_is_successful.cr ================================================ # :nodoc: struct Athena::Console::Spec::Expectations::CommandIsSuccessful def match(actual_value : ::ACON::Command::Status?) : Bool ACON::Command::Status::SUCCESS == actual_value end def failure_message(actual_value : ::ACON::Command::Status?) : String "The command was unsuccessful" end def negative_failure_message(actual_value : ::ACON::Command::Status?) : String "The command was unsuccessful" end end ================================================ FILE: src/components/console/src/spec.cr ================================================ require "./spec/expectations/*" # Provides helper types for testing `ACON::Command` and `ACON::Application`s. module Athena::Console::Spec # Contains common logic shared by both `ACON::Spec::CommandTester` and `ACON::Spec::ApplicationTester`. module Tester @capture_stderr_separately : Bool = false # Returns the `ACON::Output::Interface` being used by the tester. getter! output : ACON::Output::Interface # Sets an array of values that will be used as the input to the command. # `RETURN` is automatically assumed after each input. setter inputs : Array(String) = [] of String # Returns the output resulting from running the command. # Raises if called before executing the command. def display(normalize : Bool = false) : String raise ACON::Exception::Logic.new "Output not initialized. Did you execute the command before requesting the display?" unless output = @output output = output.to_s if normalize output = output.gsub EOL, "\n" end output end # Returns the error output resulting from running the command. # Raises if `capture_stderr_separately` was not set to `true`. def error_output(normalize : Bool = false) : String raise ACON::Exception::Logic.new "The error output is not available when the test is ran without 'capture_stderr_separately' set." unless @capture_stderr_separately output = self.output.as(ACON::Output::ConsoleOutput).error_output.to_s if normalize output = output.gsub EOL, "\n" end output end # Helper method to setting the `#inputs=` property. def inputs(*args : String) : Nil @inputs = args.to_a end abstract def status : ACON::Command::Status? # Asserts that the return `#status` is successful. def assert_command_is_successful(message : String = "", *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.status.should ACON::Spec::Expectations::CommandIsSuccessful.new, file: file, line: line, failure_message: message.presence end # Asserts that the return `#status` is _NOT_ successful. def assert_command_is_not_successful(message : String = "", *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.status.should_not ACON::Spec::Expectations::CommandIsSuccessful.new, file: file, line: line, failure_message: message.presence end protected def init_output( decorated : Bool? = nil, interactive : Bool? = nil, verbosity : ACON::Output::Verbosity? = nil, @capture_stderr_separately : Bool = false, ) : Nil if !@capture_stderr_separately @output = ACON::Output::IO.new IO::Memory.new decorated.try do |d| self.output.decorated = d end verbosity.try do |v| self.output.verbosity = v end else @output = ACON::Output::ConsoleOutput.new( verbosity || ACON::Output::Verbosity::NORMAL, decorated ) error_output = ACON::Output::IO.new IO::Memory.new error_output.formatter = self.output.formatter error_output.verbosity = self.output.verbosity error_output.decorated = self.output.decorated? self.output.as(ACON::Output::ConsoleOutput).stderr = error_output self.output.as(ACON::Output::IO).io = IO::Memory.new end end private def create_input_stream(inputs : Array(String)) : IO input_stream = IO::Memory.new inputs.each do |input| input_stream << "#{input}#{EOL}" end input_stream.rewind input_stream end end # Functionally similar to `ACON::Spec::CommandTester`, but used for testing entire `ACON::Application`s. # # Can be useful if your project extends the base application in order to customize it in some way. # # NOTE: Be sure to set `ACON::Application#auto_exit=` to `false`, when testing an entire application. struct ApplicationTester include Tester # Returns the `ACON::Application` instance being tested. getter application : ACON::Application # Returns the `ACON::Input::Interface` being used by the tester. getter! input : ACON::Input::Interface # Returns the `ACON::Command::Status` of the command execution, or `nil` if it has not yet been executed. getter status : ACON::Command::Status? = nil def initialize(@application : ACON::Application); end # Runs the application, with the provided *input* being used as the input of `ACON::Application#run`. # # Custom values for *decorated*, *interactive*, and *verbosity* can also be provided and will be forwarded to their respective types. # *capture_stderr_separately* makes it so output to `STDERR` is captured separately, in case you wanted to test error output. # Otherwise both error and normal output are captured via `ACON::Spec::Tester#display`. def run( decorated : Bool = false, interactive : Bool? = nil, capture_stderr_separately : Bool = false, verbosity : ACON::Output::Verbosity? = nil, **input : _, ) self.run input.to_h.transform_keys(&.to_s), decorated: decorated, interactive: interactive, capture_stderr_separately: capture_stderr_separately, verbosity: verbosity end # :ditto: def run( input : Hash(String, _) = Hash(String, String).new, *, decorated : Bool? = nil, interactive : Bool? = nil, capture_stderr_separately : Bool = false, verbosity : ACON::Output::Verbosity? = nil, ) : ACON::Command::Status @input = ACON::Input::Hash.new input interactive.try do |i| self.input.interactive = i end unless (inputs = @inputs).empty? self.input.stream = self.create_input_stream inputs end self.init_output( decorated: decorated, interactive: interactive, capture_stderr_separately: capture_stderr_separately, verbosity: verbosity ) @status = @application.run self.input, self.output end end # Allows testing the logic of an `ACON::Command`, without needing to create and run a binary. # # Say we have the following command: # # ``` # @[ACONA::AsCommand("add", description: "Sums two numbers, optionally making making the sum negative")] # class AddCommand < ACON::Command # protected def configure : Nil # self # .argument("value1", :required, "The first value") # .argument("value2", :required, "The second value") # .option("negative", description: "If the sum should be made negative") # end # # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # sum = input.argument("value1", Int32) + input.argument("value2", Int32) # # sum = -sum if input.option "negative", Bool # # output.puts "The sum of values is: #{sum}" # # ACON::Command::Status::SUCCESS # end # end # ``` # # We can use `ACON::Spec::CommandTester` to assert it is working as expected. # # ``` # require "spec" # require "athena-spec" # # describe AddCommand do # describe "#execute" do # it "without negative option" do # tester = ACON::Spec::CommandTester.new AddCommand.new # tester.execute value1: 10, value2: 7 # tester.display.should eq "The sum of the values is: 17\n" # end # # it "with negative option" do # tester = ACON::Spec::CommandTester.new AddCommand.new # tester.execute value1: -10, value2: 5, "--negative": nil # tester.display.should eq "The sum of the values is: 5\n" # end # end # end # ``` # # ### Commands with User Input # # A command that are asking `ACON::Question`s can also be tested: # # ``` # @[ACONA::AsCommand("question")] # class QuestionCommand < ACON::Command # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # helper = self.helper ACON::Helper::Question # # question = ACON::Question(String).new "What is your name?", "None" # output.puts "Your name is: #{helper.ask input, output, question}" # # ACON::Command::Status::SUCCESS # end # end # ``` # # ``` # require "spec" # require "./src/spec" # # describe QuestionCommand do # describe "#execute" do # it do # command = QuestionCommand.new # command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new # tester = ACON::Spec::CommandTester.new command # tester.inputs "Jim" # tester.execute # tester.display.should eq "What is your name?Your name is: Jim\n" # end # end # end # ``` # # Because we are not in the context of an `ACON::Application`, we need to manually set the `ACON::Helper::HelperSet` # in order to make the command aware of `ACON::Helper::Question`. After that we can use the `ACON::Spec::Tester#inputs` method # to set the inputs our test should use when prompted. # # Multiple inputs can be provided if there are multiple questions being asked. struct CommandTester include Tester # Returns the `ACON::Input::Interface` being used by the tester. getter! input : ACON::Input::Interface # Returns the `ACON::Command::Status` of the command execution, or `nil` if it has not yet been executed. getter status : ACON::Command::Status? = nil def initialize(@command : ACON::Command); end # Executes the command, with the provided *input* being passed to the command. # # Custom values for *decorated*, *interactive*, and *verbosity* can also be provided and will be forwarded to their respective types. # *capture_stderr_separately* makes it so output to `STDERR` is captured separately, in case you wanted to test error output. # Otherwise both error and normal output are captured via `ACON::Spec::Tester#display`. def execute( decorated : Bool = false, interactive : Bool? = nil, capture_stderr_separately : Bool = false, verbosity : ACON::Output::Verbosity? = nil, **input : _, ) self.execute input.to_h.transform_keys(&.to_s), decorated: decorated, interactive: interactive, capture_stderr_separately: capture_stderr_separately, verbosity: verbosity end # :ditto: def execute( input : Hash(String, _) = Hash(String, String).new, *, decorated : Bool = false, interactive : Bool? = nil, capture_stderr_separately : Bool = false, verbosity : ACON::Output::Verbosity? = nil, ) : ACON::Command::Status if !input.has_key?("command") && (application = @command.application?) && application.definition.has_argument?("command") input = input.merge({"command" => @command.name}) end @input = ACON::Input::Hash.new input self.input.stream = self.create_input_stream @inputs interactive.try do |i| self.input.interactive = i end self.init_output( decorated: decorated, interactive: interactive, capture_stderr_separately: capture_stderr_separately, verbosity: verbosity ) @status = @command.run self.input, self.output end end struct CommandCompletionTester def initialize(@command : ACON::Command); end def complete(*input : String) : Array(String) self.complete input end def complete(input : Enumerable(String)) : Array(String) completion_input = ACON::Completion::Input.from_tokens input.to_a, (input.size - 1).clamp(0, nil) completion_input.bind @command.definition suggestions = ACON::Completion::Suggestions.new @command.complete completion_input, suggestions options = [] of String suggestions.suggested_options.each do |option| options << "--#{option.name}" end options.concat suggestions.suggested_values.map(&.to_s) end end end ================================================ FILE: src/components/console/src/style/athena.cr ================================================ require "./output" # Default implementation of `ACON::Style::Interface` that provides a slew of helpful methods for formatting output. # # Uses `ACON::Helper::AthenaQuestion` to improve the appearance of questions. # # ``` # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status # style = ACON::Style::Athena.new input, output # # style.title "Some Fancy Title" # # # ... # # ACON::Command::Status::SUCCESS # end # ``` class Athena::Console::Style::Athena < Athena::Console::Style::Output private MAX_LINE_LENGTH = 120 protected getter question_helper : ACON::Helper::Question { ACON::Helper::AthenaQuestion.new } @input : ACON::Input::Interface @buffered_output : ACON::Output::SizedBuffer @line_length : Int32 @progress_bar : ACON::Helper::ProgressBar? = nil def initialize(@input : ACON::Input::Interface, output : ACON::Output::Interface) width = ACON::Terminal.new.width || MAX_LINE_LENGTH @buffered_output = ACON::Output::SizedBuffer.new {{flag?(:windows) ? 4 : 2}}, output.verbosity, false, output.formatter.dup @line_length = Math.min(width - {{flag?(:windows) ? 1 : 0}}, MAX_LINE_LENGTH) super output end # :inherit: def ask(question : String, default : String?) self.ask ACON::Question(String?).new question, default end # :ditto: def ask(question : ACON::Question::Base) if @input.interactive? self.auto_prepend_block end answer = self.question_helper.ask @input, self, question if @input.interactive? self.new_line @buffered_output.print EOL end answer end # :inherit: def ask_hidden(question : String) question = ACON::Question(String?).new question, nil question.hidden = true self.ask question end # Helper method for outputting blocks of *messages* that powers the `#caution`, `#success`, `#note`, etc. methods. # It includes various optional parameters that can be used to print customized blocks. # # If *type* is provided, its value will be printed within `[]`. E.g. `[TYPE]`. # # If *style* is provided, each of the *messages* will be printed in that style. # # *prefix* represents what each of the *messages* should be prefixed with. # # If *padding* is `true`, empty lines will be added before/after the block. # # If *escape* is `true`, each of the *messages* will be escaped via `ACON::Formatter::Output.escape`. def block(messages : String | Enumerable(String), type : String? = nil, style : String? = nil, prefix : String = " ", padding : Bool = false, escape : Bool = true) : Nil messages = messages.is_a?(Enumerable(String)) ? messages : {messages} self.auto_prepend_block self.puts self.create_block(messages, type, style, prefix, padding, escape) self.new_line end # :inherit: # # ```text # ! # ! [CAUTION] Some Message # ! # ``` # # White text on a 3 line red background block with an empty line above/below the block. def caution(messages : String | Enumerable(String)) : Nil self.block messages, "CAUTION", "fg=white;bg=red", " ! ", true end # :inherit: # # ```text # ----- ------- # Foo Bar # ----- ------- # Biz Baz # 12 false # ----- ------- # # ``` def table(headers : Enumerable, rows : Enumerable) : Nil self.create_table .headers(headers) .rows(rows) .render self.new_line end # Sames as `#table`, but horizontal def horizontal_table(headers : Enumerable, rows : Enumerable) : Nil self.create_table .headers(headers) .rows(rows) .horizontal .render self.new_line end # Sames as `#table`, but vertical def vertical_table(headers : Enumerable, rows : Enumerable) : Nil self.create_table .headers(headers) .rows(rows) .vertical .render self.new_line end # Formats a list of key/value pairs horizontally. # # TODO: `Mappable` when/if https://github.com/crystal-lang/crystal/issues/10886 is implemented. def definition_list(*rows : String | ACON::Helper::Table::Separator | Enumerable({K, V})) : Nil forall K, V table_headers = [] of String | ACON::Helper::Table::Cell table_row = [] of String | ACON::Helper::Table::Cell | Nil rows.each do |row| case row in String table_headers << ACON::Helper::Table::Cell.new row, colspan: 2 table_row << nil in ACON::Helper::Table::Cell table_headers << row table_row << row in Enumerable table_headers << row.first_key.to_s table_row << row.first_value.to_s end end self.horizontal_table table_headers, {table_row} end # Creates and returns an Athena styled `ACON::Helper::Table` instance. def create_table : ACON::Helper::Table style = ACON::Helper::Table.style_definition("suggested").clone style.cell_header_format "%s" ACON::Helper::Table.new( (output = @output).is_a?(ACON::Output::ConsoleOutputInterface) ? output.section : @output ) .style(style) end # :inherit: def choice(question : String, choices : Indexable | Hash, default = nil) self.ask ACON::Question::Choice.new question, choices, default end # :inherit: # # ```text # // Some Message # ``` # # White text with one empty line above/below the message(s). def comment(messages : String | Enumerable(String)) : Nil self.block messages, prefix: " // ", escape: false end # :inherit: def confirm(question : String, default : Bool = true) : Bool self.ask ACON::Question::Confirmation.new question, default end # :inherit: # # ```text # [ERROR] Some Message # ``` # # White text on a 3 line red background block with an empty line above/below the block. def error(messages : String | Enumerable(String)) : Nil self.block messages, "ERROR", "fg=white;bg=red", padding: true end # Returns a new instance of `self` that outputs to the error output. def error_style : self self.class.new @input, self.error_output end # :inherit: # # ```text # [INFO] Some Message # ``` # # Green text with two empty lines above/below the message(s). def info(messages : String | Enumerable(String)) : Nil self.block messages, "INFO", "fg=green", padding: true end # :inherit: # # ```text # * Item 1 # * Item 2 # * Item 3 # ``` # # White text with one empty line above/below the list. def listing(elements : Enumerable) : Nil self.auto_prepend_text elements.each do |element| self.puts " * #{element}" end self.new_line end # :ditto: def listing(*elements : String) : Nil self.listing elements end # :inherit: def new_line(count : Int32 = 1) : Nil super @buffered_output.print EOL * count end # :inherit: # # ```text # ! [NOTE] Some Message # ``` # # Green text with one empty line above/below the message(s). def note(messages : String | Enumerable(String)) : Nil self.block messages, "NOTE", "fg=yellow", " ! " end # :inherit: def puts(messages : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil messages = messages.is_a?(String) ? {messages} : messages messages.each do |message| super message, verbosity, output_type self.write_buffer message, true, verbosity, output_type end end # :inherit: def print(messages : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil messages = messages.is_a?(String) ? {messages} : messages messages.each do |message| super message, verbosity, output_type self.write_buffer message, false, verbosity, output_type end end # :inherit: # # ```text # Some Message # ------------ # ``` # # Orange text with one empty line above/below the section. def section(message : String) : Nil self.auto_prepend_block self.puts "#{ACON::Formatter::Output.escape_trailing_backslash message}" self.puts %(#{"-" * ACON::Helper.width(ACON::Helper.remove_decoration(self.formatter, message))}) self.new_line end # :inherit: # # ```text # [OK] Some Message # ``` # # Black text on a 3 line green background block with an empty line above/below the block. def success(messages : String | Enumerable(String)) : Nil self.block messages, "OK", "fg=black;bg=green", padding: true end # def table(headers : Enumerable, rows : Enumerable(Enumerable)) : Nil # end # :inherit: # # Same as `#puts` but indented one space and an empty line above the message(s). def text(messages : String | Enumerable(String)) : Nil self.auto_prepend_text messages = messages.is_a?(Enumerable(String)) ? messages : {messages} messages.each do |message| self.puts " #{message}" end end # :inherit: # # ```text # Some Message # ============ # ``` # # Orange text with one empty line above/below the title. def title(message : String) : Nil self.auto_prepend_block self.puts "#{ACON::Formatter::Output.escape_trailing_backslash message}" self.puts %(#{"=" * ACON::Helper.width(ACON::Helper.remove_decoration(self.formatter, message))}) self.new_line end # :inherit: # # ```text # [WARNING] Some Message # ``` # # Black text on a 3 line orange background block with an empty line above/below the block. def warning(messages : String | Enumerable(String)) : Nil self.block messages, "WARNING", "fg=black;bg=yellow", padding: true end # :inherit: def progress_start(max : Int32? = nil) : Nil @progress_bar = self.create_progress_bar max self.progress_bar.start end # :inherit: def progress_advance(by step : Int32 = 1) : Nil self.progress_bar.advance step end # :inherit: def progress_finish : Nil self.progress_bar.finish self.new_line 2 @progress_bar = nil end def create_progress_bar(max : Int32? = nil) : ACON::Helper::ProgressBar bar = super(max) {% if !flag?(:windows) || env("TERM_PROGRAM") == "Hyper" %} bar.empty_bar_character = "░" # light shade character \u2591 bar.progress_character = "" bar.bar_character = "▓" # dark shade character \u2593 {% end %} bar end def progress_iterate(enumerable : Enumerable(T), max : Int32? = nil, & : T -> Nil) : Nil forall T self.create_progress_bar.iterate(enumerable) do |value| yield value end self.new_line 2 end private def auto_prepend_block : Nil chars = @buffered_output.fetch.gsub EOL, "\n" if chars.empty? return self.new_line end self.new_line 2 - chars.count '\n' end private def auto_prepend_text : Nil fetched = @buffered_output.fetch if !fetched.empty? && !fetched.ends_with? "\n" self.new_line end end private def create_block(messages : Enumerable(String), type : String? = nil, style : String? = nil, prefix : String = " ", padding : Bool = false, escape : Bool = true) : Array(String) indent_length = 0 prefix_length = ACON::Helper.width ACON::Helper.remove_decoration self.formatter, prefix lines = [] of String unless type.nil? type = "[#{type}] " indent_length = ACON::Helper.width type line_indentation = " " * indent_length end output_wrapper = ACON::Helper::OutputWrapper.new messages.each_with_index do |message, idx| message = ACON::Formatter::Output.escape message if escape lines.concat output_wrapper.wrap(message, @line_length - prefix_length - indent_length, EOL).split EOL lines << "" if messages.size > 1 && idx < (messages.size - 1) end first_line_index = 0 if padding && self.decorated? first_line_index = 1 lines.unshift "" lines << "" end lines.map_with_index do |line, idx| unless type.nil? line = first_line_index == idx ? "#{type}#{line}" : "#{line_indentation}#{line}" end line = "#{prefix}#{line}" line += " " * Math.max @line_length - ACON::Helper.width(ACON::Helper.remove_decoration(self.formatter, line)), 0 if style line = "<#{style}>#{line}" end line end end private def write_buffer(message : String | Enumerable(String), new_line : Bool, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil @buffered_output.write message, new_line, verbosity, output_type end # Used in specs protected def progress_bar : ACON::Helper::ProgressBar @progress_bar || raise ACON::Exception::Runtime.new "The ProgressBar is not started." end end ================================================ FILE: src/components/console/src/style/interface.cr ================================================ # Represents a "style" that provides a way to abstract _how_ to format console input/output data # such that you can reduce the amount of code needed, and to standardize the appearance. # # See `ACON::Style::Athena`. # # ## Custom Styles # # Custom styles can also be created by implementing this interface, and optionally extending from `ACON::Style::Output` # which makes the style an `ACON::Output::Interface` as well as defining part of this interface. # Then you could simply instantiate your custom style within a command as you would `ACON::Style::Athena`. module Athena::Console::Style::Interface # Helper method for asking `ACON::Question` questions. abstract def ask(question : String, default : _) # Helper method for asking hidden `ACON::Question` questions. abstract def ask_hidden(question : String) # Formats and prints the provided *messages* within a caution block. abstract def caution(messages : String | Enumerable(String)) : Nil # Formats and prints the provided *messages* within a comment block. abstract def comment(messages : String | Enumerable(String)) : Nil # Helper method for asking `ACON::Question::Confirmation` questions. abstract def confirm(question : String, default : Bool = true) : Bool # Formats and prints the provided *messages* within a error block. abstract def error(messages : String | Enumerable(String)) : Nil # Helper method for asking `ACON::Question::Choice` questions. abstract def choice(question : String, choices : Indexable | Hash, default = nil) # Formats and prints the provided *messages* within a info block. abstract def info(messages : String | Enumerable(String)) : Nil # Formats and prints a bulleted list containing the provided *elements*. abstract def listing(elements : Enumerable) : Nil # Prints *count* empty new lines. abstract def new_line(count : Int32 = 1) : Nil # Formats and prints the provided *messages* within a note block. abstract def note(messages : String | Enumerable(String)) : Nil # Creates a section header with the provided *message*. abstract def section(message : String) : Nil # Formats and prints the provided *messages* within a success block. abstract def success(messages : String | Enumerable(String)) : Nil # Formats and prints the provided *messages* as text. abstract def text(messages : String | Enumerable(String)) : Nil # Formats and prints *message* as a title. abstract def title(message : String) : Nil # Formats and prints a table based on the provided *headers* and *rows*, followed by a new line. abstract def table(headers : Enumerable, rows : Enumerable) : Nil # Starts an internal `ACON::Helper::ProgressBar`, optionally with the provided *max* amount of steps. abstract def progress_start(max : Int32? = nil) : Nil # Advances the internal `ACON::Helper::ProgressBar` *by* the provided amount of steps. abstract def progress_advance(by step : Int32 = 1) : Nil # Completes the internal `ACON::Helper::ProgressBar`. abstract def progress_finish : Nil # Formats and prints the provided *messages* within a warning block. abstract def warning(messages : String | Enumerable(String)) : Nil end ================================================ FILE: src/components/console/src/style/output.cr ================================================ require "./interface" # Base implementation of `ACON::Style::Interface` and `ACON::Output::Interface` that provides logic common to all styles. abstract class Athena::Console::Style::Output include Athena::Console::Style::Interface include Athena::Console::Output::Interface @output : ACON::Output::Interface def initialize(@output : ACON::Output::Interface); end # See `ACON::Output::Interface#decorated?`. def decorated? : Bool @output.decorated? end # See `ACON::Output::Interface#decorated=`. def decorated=(decorated : Bool) : Nil @output.decorated = decorated end # See `ACON::Output::Interface#formatter`. def formatter : ACON::Formatter::Interface @output.formatter end # See `ACON::Output::Interface#formatter=`. def formatter=(formatter : ACON::Formatter::Interface) : Nil @output.formatter = formatter end # :inherit: def new_line(count : Int32 = 1) : Nil @output.print EOL * count end # Creates and returns an `ACON::Helper::ProgressBar`, optionally with the provided *max* amount of steps. def create_progress_bar(max : Int32? = nil) : ACON::Helper::ProgressBar ACON::Helper::ProgressBar.new @output, max end # See `ACON::Output::Interface#puts`. def puts(message, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil @output.puts message, verbosity, output_type end # See `ACON::Output::Interface#print`. def print(message, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil @output.print message, verbosity, output_type end # See `ACON::Output::Interface#verbosity`. def verbosity : ACON::Output::Verbosity @output.verbosity end # See `ACON::Output::Interface#verbosity=`. def verbosity=(verbosity : ACON::Output::Verbosity) : Nil @output.verbosity = verbosity end protected def error_output : ACON::Output::Interface unless (output = @output).is_a? ACON::Output::ConsoleOutputInterface return @output end output.error_output end end ================================================ FILE: src/components/console/src/terminal.cr ================================================ require "./ext/terminal" # :nodoc: struct Athena::Console::Terminal @@width : Int32? = nil @@height : Int32? = nil @@stty : Bool? = nil def self.has_stty_available? : Bool @@stty.try { |stty| return stty } @@stty = !Process.find_executable("stty").nil? end def width : Int32 if env_width = ENV["COLUMNS"]? return env_width.to_i end if @@width.nil? self.class.init_dimensions end @@width || 80 end def height : Int32 if env_height = ENV["LINES"]? return env_height.to_i end if @@height.nil? self.class.init_dimensions end @@height || 50 end def size : {Int32, Int32} return self.width, self.height end private def self.check_size(size) : Bool if size && (cols = size[0]) && (rows = size[1]) && cols != 0 && rows != 0 @@width = cols @@height = rows return true end false end {% if flag?(:win32) %} protected def self.init_dimensions : Nil return if check_size(size_from_screen_buffer) return if check_size(size_from_ansicon) end # Detect terminal size Windows `GetConsoleScreenBufferInfo`. private def self.size_from_screen_buffer return unless LibC.GetConsoleScreenBufferInfo(LibC.GetStdHandle(LibC::STDOUT_HANDLE), out csbi) cols = csbi.srWindow.right - csbi.srWindow.left + 1 rows = csbi.srWindow.bottom - csbi.srWindow.top + 1 {cols.to_i32, rows.to_i32} end # Detect terminal size from Windows ANSICON private def self.size_from_ansicon return unless ENV["ANSICON"]?.to_s =~ /\((.*)x(.*)\)/ rows, cols = [$2, $1].map(&.to_i) {cols, rows} end {% else %} protected def self.init_dimensions : Nil return if self.check_size(self.size_from_ioctl(0)) # STDIN return if self.check_size(self.size_from_ioctl(1)) # STDOUT return if self.check_size(self.size_from_ioctl(2)) # STDERR return if self.check_size(self.size_from_tput) return if self.check_size(self.size_from_stty) end # Read terminal size from Unix ioctl private def self.size_from_ioctl(fd) winsize = uninitialized LibC::Winsize ret = LibC.ioctl(fd, LibC::TIOCGWINSZ, pointerof(winsize)) return if ret < 0 {winsize.ws_col.to_i32, winsize.ws_row.to_i32} end # Detect terminal size from tput utility private def self.size_from_tput return unless STDOUT.tty? lines = `tput lines`.to_i? cols = `tput cols`.to_i? {cols, lines} rescue nil end # Detect terminal size from stty utility private def self.size_from_stty return unless STDOUT.tty? parts = `stty size`.split(/\s+/) return unless parts.size > 1 lines, cols = parts.map(&.to_i?) {cols, lines} rescue nil end {% end %} end ================================================ FILE: src/components/contracts/CHANGELOG.md ================================================ # Changelog ## [0.1.0] - 2025-08-02 _Initial release._ [0.1.0]: https://github.com/athena-framework/contracts/releases/tag/v0.1.0 ================================================ FILE: src/components/contracts/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/contracts/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2025 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/contracts/README.md ================================================ # Contracts [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/contracts.svg)](https://github.com/athena-framework/contracts/releases) A set of abstractions extracted out of the Athena components. ## Getting Started Checkout the [Documentation](https://athenaframework.org/Contracts). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/contracts/docs/README.md ================================================ A set of abstractions extracted out of the Athena components. Can be used to build on semantics that the Athena components proved useful. ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-contracts: github: athena-framework/contracts version: ~> 0.1.0 ``` ## Usage The [Athena::Contracts](/Contracts/top_level/) component provides types and interfaces to achieve loose coupling and interoperability. The intended use case is that other components, or third party libraries, can depend upon the `contracts` component and use its interfaces. Then, the code could be usable with any implementation that is also based on them. It could be an Athena component, or another one provided by the greater Crystal community. ================================================ FILE: src/components/contracts/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Contracts site_url: https://athenaframework.org/Contracts/ repo_url: https://github.com/athena-framework/contracts nav: - Introduction: README.md - Back to Manual: project://. - API: - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-contracts/src/athena-contracts.cr source_locations: lib/athena-contracts: https://github.com/athena-framework/contracts/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/contracts/shard.yml ================================================ name: athena-contracts version: 0.1.0 crystal: ~> 1.4 license: MIT repository: https://github.com/athena-framework/contracts documentation: https://athenaframework.org/Contracts description: | A set of abstractions extracted out of the Athena components. authors: - George Dietrich ================================================ FILE: src/components/contracts/spec/.gitkeep ================================================ ================================================ FILE: src/components/contracts/src/alias.cr ================================================ # Convenience alias to make referencing `Athena::Contracts` types easier. alias ACTR = Athena::Contracts ================================================ FILE: src/components/contracts/src/athena-contracts.cr ================================================ # Main entrypoint that requires _all_ contracts. # Does _not_ include common code as those are required by the underlying component, # for docs, tests, etc. require "./event_dispatcher" require "./alias" # A set of robust/battle-tested types and interfaces to achieve loose coupling and interoperability. module Athena::Contracts VERSION = "0.1.0" # Contracts that relate to the [Athena::EventDispatcher](/EventDispatcher/) component. module EventDispatcher; end end ================================================ FILE: src/components/contracts/src/contracts/event_dispatcher/event.cr ================================================ require "./stoppable_event" # An event consists of a subclass of this type, usually with extra context specific information. # The metaclass of the event type is used as a unique identifier, which generally should end in a verb that indicates what action has been taken. # # ``` # # Define a custom event # class ExceptionRaisedEvent < ACTR::EventDispatcher::Event # getter exception : Exception # # def initialize(@exception : Exception); end # end # # # Dispatch a custom event # exception = ArgumentError.new "Value cannot be negative" # dispatcher.dispatch ExceptionRaisedEvent.new exception # ``` # # Abstract event classes may also be used to share common data/methods between a group of related events. # However they cannot be used as a catchall to listen on all events that extend it. # # ## Stopping Propagation # # In some cases it may make sense for a listener to prevent any other listeners from being called for a specific event. # In order to do this, the listener needs a way to tell the dispatcher that it should stop propagation, i.e. do not notify any more listeners. # The base event type includes `ACTR::EventDispatcher::StoppableEvent` that enables this behavior. # Checkout the related module for more information. abstract class Athena::Contracts::EventDispatcher::Event include Athena::Contracts::EventDispatcher::StoppableEvent end ================================================ FILE: src/components/contracts/src/contracts/event_dispatcher/interface.cr ================================================ # Represents the most basic interface that event dispatchers must implement. # Can be further extended to provide additional functionality. # # All dispatchers: # # * _MUST_ call listeners synchronously # * _MUST_ return the same even object it was originally passed. # * _MUST NOT_ return until all listeners have executed. # * _MUST_ handle the case where the provided *event* is a `ACTR::EventDispatcher::StoppableEvent`. module Athena::Contracts::EventDispatcher::Interface # Dispatches the provided *event* to all listeners listening on that event. abstract def dispatch(event : ACTR::EventDispatcher::Event) : ACTR::EventDispatcher::Event end ================================================ FILE: src/components/contracts/src/contracts/event_dispatcher/stoppable_event.cr ================================================ # An `ACTR::EventDispatcher::Event` whose processing may be interrupted when the event has been handled. # # `ACTR::EventDispatcher::Interface` implementations *MUST* check to determine if an `ACTR::EventDispatcher::Event` is marked as stopped after each listener is called. # If it is, then the dispatcher should return immediately without calling any further listeners. module Athena::Contracts::EventDispatcher::StoppableEvent @propagation_stopped : Bool = false # If future listeners should be executed. def propagate? : Bool !@propagation_stopped end # Prevent future listeners from executing once any listener calls `#stop_propagation`. def stop_propagation : Nil @propagation_stopped = true end end ================================================ FILE: src/components/contracts/src/event_dispatcher.cr ================================================ require "./alias" require "./contracts/event_dispatcher/*" ================================================ FILE: src/components/dependency_injection/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/dependency_injection/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/dependency_injection/CHANGELOG.md ================================================ # Changelog ## [0.4.5] - 2026-04-19 ### Changed - Improve compile time error messages ([#646]) (George Dietrich) - Reduce the amount of ivars within `ADI::ServiceContainer` ([#649]) (George Dietrich) ### Added - Add ability to define schema configuration maps; with arbitrary keys, but structured values ([#641]) (George Dietrich) - Add ability to define re-usable schema object types ([#641]) (George Dietrich) - Add ability for aliases to take constructor parameter names into account ([#660]) (George Dietrich) - Add support for nested `>>` doc markup when using `object_schema` ([#684]) (George Dietrich) ### Fixed - Fix global extension schema `Enum` types not retaining their `::` prefix ([#639]) (George Dietrich) - Fix falsey binding values not resolving ([#647]) (George Dietrich) - Fix issue with using multiple extensions when one has a nested schema ([#658]) (George Dietrich) - Fix service argument validation errors overriding schema validation errors ([#659]) (George Dietrich) - Fix enum typed `object_schema` types not allowing symbol/number values ([#661]) (George Dietrich) - Fix compile time error when inadvertently using a type name that conflicts with an internal component type ([#678]) (George Dietrich) - Fix being unable to link to non top-level types within a nested properties' `>>` doc markup ([#684]) (George Dietrich) [0.4.5]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.5 [#646]: https://github.com/athena-framework/athena/pull/646 [#649]: https://github.com/athena-framework/athena/pull/649 [#641]: https://github.com/athena-framework/athena/pull/641 [#660]: https://github.com/athena-framework/athena/pull/660 [#684]: https://github.com/athena-framework/athena/pull/684 [#639]: https://github.com/athena-framework/athena/pull/639 [#647]: https://github.com/athena-framework/athena/pull/647 [#658]: https://github.com/athena-framework/athena/pull/658 [#659]: https://github.com/athena-framework/athena/pull/659 [#661]: https://github.com/athena-framework/athena/pull/661 [#678]: https://github.com/athena-framework/athena/pull/678 ## [0.4.4] - 2025-09-04 ### Changed - Relax DI argument validation for string parameters ([#548]) (George Dietrich) [0.4.4]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.4 [#548]: https://github.com/athena-framework/athena/pull/548 ## [0.4.3] - 2025-02-08 ### Changed - **Breaking:** prevent auto registering of already registered services ([#520]) (George Dietrich) ### Fixed - Ensure all array values have proper `#of` type ([#508]) (George Dietrich) [0.4.3]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.3 [#508]: https://github.com/athena-framework/athena/pull/508 [#520]: https://github.com/athena-framework/athena/pull/520 ## [0.4.2] - 2025-01-26 _Administrative release, no functional changes_ [0.4.2]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.2 ## [0.4.1] - 2024-07-31 ### Changed - **Breaking:** single implementation aliases are now explicit ([#408]) (George Dietrich) ### Fixed - Fix default/nil values related to `object_of` and `array_of` being unavailable in bundle extensions ([#432]) (George Dietrich) [0.4.1]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.1 [#408]: https://github.com/athena-framework/athena/pull/408 [#432]: https://github.com/athena-framework/athena/pull/432 ## [0.4.0] - 2024-04-09 ### Changed - **Breaking:** remove `Clock`, `Console`, and `EventDispatcher` built-in integrations ([#337]) (George Dietrich) - **Breaking:** major internal refactor ([#337], [#378]) (George Dietrich) - **Breaking:** replace `ADI.auto_configure` with [ADI::Autoconfigure](https://athenaframework.org/DependencyInjection/Autoconfigure/) ([#387]) (George Dietrich) - **Breaking:** replace `alias` `ADI::Register` field with [ADI::AsAlias](https://athenaframework.org/DependencyInjection/AsAlias/) ([#389]) (George Dietrich) - Integrate website into monorepo ([#365]) (George Dietrich) ### Added - Add ability to easily extend/customize the container ([#337], [#348], [#371], [#372], [#373], [#374], [#377], [#379], [#382], [#383]) (George Dietrich) - Add ability to define method calls that should be made during service instantiation ([#384]) (George Dietrich) - Add new [ADI::AutoconfigureTag](https://athenaframework.org/DependencyInjection/AutoconfigureTag/) and [ADI::TaggedIterator](https://athenaframework.org/DependencyInjection/TaggedIterator/) to make working with tagged services easier ([#387]) (George Dietrich) - Add `ADI.configuration_annotation` to `Athena::DependencyInjection` from `Athena::Config` ([#392]) (George Dietrich) [0.4.0]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.4.0 [#337]: https://github.com/athena-framework/athena/pull/337 [#348]: https://github.com/athena-framework/athena/pull/348 [#365]: https://github.com/athena-framework/athena/pull/365 [#371]: https://github.com/athena-framework/athena/pull/371 [#372]: https://github.com/athena-framework/athena/pull/372 [#373]: https://github.com/athena-framework/athena/pull/373 [#374]: https://github.com/athena-framework/athena/pull/374 [#377]: https://github.com/athena-framework/athena/pull/377 [#378]: https://github.com/athena-framework/athena/pull/378 [#379]: https://github.com/athena-framework/athena/pull/379 [#382]: https://github.com/athena-framework/athena/pull/382 [#383]: https://github.com/athena-framework/athena/pull/383 [#384]: https://github.com/athena-framework/athena/pull/384 [#387]: https://github.com/athena-framework/athena/pull/387 [#389]: https://github.com/athena-framework/athena/pull/389 [#392]: https://github.com/athena-framework/athena/pull/392 ## [0.3.8] - 2023-12-16 ### Fixed - Avoid depending directly on Crystal macro types ([#335]) (George Dietrich) [0.3.8]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.8 [#335]: https://github.com/athena-framework/athena/pull/335 ## [0.3.7] - 2023-10-09 ### Added - Add integration between `Athena::DependencyInjection` and the `Athena::Clock` component ([#318]) (George Dietrich) [0.3.7]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.7 [#318]: https://github.com/athena-framework/athena/pull/318 ## [0.3.6] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) [0.3.6]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.6 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.3.5] - 2023-02-04 ### Added - Add better integration between `Athena::DependencyInjection` and the `Athena::Console` and `Athena::EventDispatcher` components ([#259]) (George Dietrich) [0.3.5]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.5 [#259]: https://github.com/athena-framework/athena/pull/259 ## [0.3.4] - 2023-01-07 ### Changed - Refactor various internal logic (George Dietrich) [0.3.4]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.4 ## [0.3.3] - 2022-05-14 _First release a part of the monorepo._ ### Changed - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Added - Add getting started documentation to API docs ([#172]) (George Dietrich) [0.3.3]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.3 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.3.2] - 2021-10-30 ### Changed - Unused services are now excluded from the container ([#30]) (George Dietrich) [0.3.2]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.2 [#30]: https://github.com/athena-framework/dependency-injection/pull/30 ## [0.3.1] - 2021-03-28 ### Fixed - Fix error with untyped parameters with default values injecting ([#28]) (George Dietrich) [0.3.1]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.1 [#28]: https://github.com/athena-framework/dependency-injection/pull/28 ## [0.3.0] - 2021-03-20 ### Added - Allow injecting [configuration](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--configuration) into services ([#27]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.3.0 [#27]: https://github.com/athena-framework/dependency-injection/pull/27 ## [0.2.6] - 2021-03-15 ### Added - Allow using the `ADI::Inject` annotation on class methods to create [factories](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--factories) ([#25]) (George Dietrich) [0.2.6]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.6 [#25]: https://github.com/athena-framework/dependency-injection/pull/25 ## [0.2.5] - 2021-01-30 ### Changed - Migrate documentation to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#23], [#24]) (George Dietrich) [0.2.5]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.5 [#23]: https://github.com/athena-framework/dependency-injection/pull/23 [#24]: https://github.com/athena-framework/dependency-injection/pull/24 ## [0.2.4] - 2021-01-29 ### Added - Add dependency on `athena-framework/config` ([#20]) (George Dietrich) - Add support for injecting [parameters](https://athenaframework.org/architecture/config/#parameters) into a service ([#20]) (George Dietrich) - Add support for [service proxies](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--service-proxies) ([#21]) (George Dietrich) ### Removed - Remove the `lazy` `ADI::Register` field. All services are lazy by default now ([#21]) (George Dietrich) ### Fixed - Fix issue building documentation ([#22]) (George Dietrich) [0.2.4]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.4 [#20]: https://github.com/athena-framework/dependency-injection/pull/20 [#21]: https://github.com/athena-framework/dependency-injection/pull/21 [#22]: https://github.com/athena-framework/dependency-injection/pull/22 ## [0.2.3] - 2020-12-24 ### Fixed - Fix error when a parameter has a default value after an array parameter ([#19]) (George Dietrich) [0.2.3]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.3 [#19]: https://github.com/athena-framework/dependency-injection/pull/19 ## [0.2.2] - 2020-12-03 ### Changed - Update `crystal` version to allow version greater than `1.0.0` ([#18]) (George Dietrich) [0.2.2]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.2 [#18]: https://github.com/athena-framework/dependency-injection/pull/18 ## [0.2.1] - 2020-11-14 ### Added - Add a mock container instance to allow mocking services ([#15]) (George Dietrich) - Add ability to customize the type of a service within the container ([#15]) (George Dietrich) - Add support for [factory pattern](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--factories) constructors ([#16]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.1 [#15]: https://github.com/athena-framework/dependency-injection/pull/15 [#16]: https://github.com/athena-framework/dependency-injection/pull/16 ## [0.2.0] - 2020-06-09 _Major refactor of the component._ ### Added - Add concept of [aliasing services](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--aliasing-services) ([#10]) (George Dietrich) - Add concept of [binding values](https://athenaframework.org/DependencyInjection/#Athena::DependencyInjection:bind(key,value)) ([#10]) (George Dietrich) - Add concept of [auto configuration](https://athenaframework.org/DependencyInjection/#Athena::DependencyInjection:auto_configure(type,options)) ([#10]) (George Dietrich) - Add [ADI::Inject](https://athenaframework.org/DependencyInjection/Inject/) annotation ([#10]) (George Dietrich) - Add support for [generic services](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--generic-services) ([#10]) (George Dietrich) ### Changed - **Breaking:** manually provided arguments now need to be prefixed with a `_` ([#10]) (George Dietrich) - **Breaking:** service names are now based on the `FQN` of the type, downcase underscored by default ([#10]) (George Dietrich) - Updated [optional services](https://athenaframework.org/DependencyInjection/Register/#Athena::DependencyInjection::Register--optional-services) to now be based on the type/default value of the parameter ([#10]) (George Dietrich) - Service dependencies are now resolved automatically, removes need to manually provide them ([#10]) (George Dietrich) ### Removed - **Breaking:** remove the `ADI::Service` module ([#10]) (George Dietrich) - **Breaking:** remove the `ADI::Injectable` module ([#10]) (George Dietrich) - **Breaking:** remove the `@?` syntax ([#10]) (George Dietrich) - **Breaking:** remove the `#get`, `#has`, `#resolve`, `#tagged`, and `#tags` methods from `ADI::ServiceContainer` ([#10]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.2.0 [#10]: https://github.com/athena-framework/dependency-injection/pull/10 ## [0.1.3] - 2020-04-06 ### Fixed - Fix an edge case by checking includers via `<=` ([#7]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.1.3 [#7]: https://github.com/athena-framework/dependency-injection/pull/7 ## [0.1.2] - 2020-02-22 ### Changed - Change type resolution logic to operate at compile time instead of runtime ([#6]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.1.2 [#6]: https://github.com/athena-framework/dependency-injection/pull/6 ## [0.1.1] - 2020-02-06 ### Added - Add the ability to redefine services ([#4]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.1.1 [#4]: https://github.com/athena-framework/dependency-injection/pull/4 ## [0.1.0] - 2020-01-31 _Initial release._ [0.1.0]: https://github.com/athena-framework/dependency-injection/releases/tag/v0.1.0 ================================================ FILE: src/components/dependency_injection/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/dependency_injection/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2021 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/dependency_injection/README.md ================================================ # Dependency Injection [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/dependency-injection.svg)](https://github.com/athena-framework/dependency-injection/releases) Robust dependency injection service container framework. ## Getting Started Checkout the [Documentation](https://athenaframework.org/DependencyInjection). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/dependency_injection/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.4.1 ### Single implementation aliases are now explicit Previously if you had a service that implemented an interface (module), that interface would auto resolve to that service if there was only the one implementation. This implicit aliasing of the interface was removed and now requires an explicit aliasing. Before: ```crystal module SomeInterface; end @[ADI::Register] class Foo include SomeInterface end @[ADI::Register(public: true)] record Bar, a : SomeInterface ``` After: ```crystal module SomeInterface; end @[ADI::Register] @[ADI::AsAlias] class Foo include SomeInterface end @[ADI::Register(public: true)] record Bar, a : SomeInterface ``` If the service only implements a single interface, you just need to apply the [`@[ADI::AsAlias]`](https://athenaframework.org/DependencyInjection/AsAlias/) annotation to the service. If it implements more than one interface, you'll need an annotation for each one. See the API docs for more information on how to use the annotation. ## Upgrade to 0.4.0 _The component went through a large internal refactor as part of this release. Please create an issue or PR if you find a breaking change not captured here._ ### Remove built-in integrations The built-in `Clock`, `Console`, and `EventDispatcher` integrations have been removed. The Athena Framework is now responsible for the integration of all the components. If you were using one of these components with the `DependencyInjection` component outside of the framework, you will need to manually handle wiring things up. ### Remove `ADI.auto_configure` The `ADI.auto_configure` macro has been replaced with the `ADI::Autoconfigure` annotation. Before: ```crystal module ConfigInterface; end ADI.auto_configure ConfigInterface, {tags: ["config"]} ``` After: ```crystal @[ADI::Autoconfigure(tags: ["config"])] module ConfigInterface; end ``` See the [API Docs](https://athenaframework.org/DependencyInjection/Autoconfigure/) for more information. ### Remove `alias` field of `ADI::Register` Service aliases are no longer defined via the `alias` field as part of the `ADI::Register` annotation. Instead, they are now handled via the new `ADI::AsAlias` annotation. Before: ```crystal module TransformerInterface abstract def transform(value : String) : String end @[ADI::Register(alias: TransformerInterface)] struct ShoutTransformer include TransformerInterface # ... end ``` After: ```crystal module TransformerInterface abstract def transform(value : String) : String end @[ADI::Register] @[ADI::AsAlias(TransformerInterface)] struct ShoutTransformer include TransformerInterface # ... end ``` See the [API Docs](https://athenaframework.org/DependencyInjection/AsAlias/) for more information. ================================================ FILE: src/components/dependency_injection/docs/README.md ================================================ The `Athena::DependencyInjection` component provides a robust dependency injection service container framework. Some of the reasoning for how this can/would be useful is called out in the [Why Athena?](/why_athena) page. ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-dependency_injection: github: athena-framework/dependency-injection version: ~> 0.4.0 ``` ## Usage A special class called the `ADI::ServiceContainer` (SC) stores useful objects, aka services, that can be shared throughout the project. The SC is lazily initialized on fibers; this allows the SC to be accessed anywhere within the project. The [ADI.container](/DependencyInjection/top_level/#Athena::DependencyInjection.container) method will return the SC for the current fiber. If you are a user of a project/framework making use of this component, checkout [ADI::Register](/DependencyInjection/Register/) as most of all the information you need is documented there. Otherwise, if you are the creator/maintainer of a project wishing to integrate this component, the best way to integrate/use this component depends on the execution flow of your application, and how it uses [Fibers](https://crystal-lang.org/api/Fiber.html). Since each fiber has its own container instance, if your application only uses Crystal's main fiber and is short lived, then you most likely only need to set up your services and expose one of them as [public](/DependencyInjection/Register/#Athena::DependencyInjection::Register--optional-arguments) to serve as the entry point. If your application is meant to be long lived, such as using a [HTTP::Server](https://crystal-lang.org/api/HTTP/Server.html), then you will want to ensure that each fiber is truly independent from one another, with them not being reused or sharing state external to the container. An example of this is how `HTTP::Server` reuses fibers for `connection: keep-alive` requests. Because of this, or in cases similar to, you may want to manually reset the container via `Fiber.current.container = ADI::ServiceContainer.new`. ================================================ FILE: src/components/dependency_injection/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Dependency Injection site_url: https://athenaframework.org/DependencyInjection/ repo_url: https://github.com/athena-framework/dependency-injection nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-dependency_injection/src/athena-dependency_injection.cr source_locations: lib/athena-dependency_injection: https://github.com/athena-framework/dependency-injection/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/dependency_injection/shard.yml ================================================ name: athena-dependency_injection version: 0.4.5 crystal: ~> 1.19 license: MIT repository: https://github.com/athena-framework/dependency-injection documentation: https://athenaframework.org/DependencyInjection description: | Robust dependency injection service container framework. authors: - George Dietrich ================================================ FILE: src/components/dependency_injection/spec/abstract_bundle_spec.cr ================================================ require "./spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, <<-CR, line: line require "./spec_helper.cr" #{code} CR end describe ADI::AbstractBundle do describe "compiler errors", tags: "compiled" do it "when the bundle does not inherit from ADI::AbstractBundle" do assert_compile_time_error "The provided bundle 'String' be inherit from 'ADI::AbstractBundle'.", <<-CR ADI.register_bundle String CR end it "when the bundle does not provide its name" do assert_compile_time_error "Unable to determine extension name. It was not provided as the first positional argument nor via the 'name' field.", <<-CR @[ADI::Bundle] struct MyBundle < ADI::AbstractBundle end ADI.register_bundle MyBundle CR end end end ================================================ FILE: src/components/dependency_injection/spec/athena-dependency_injection_spec.cr ================================================ require "./spec_helper" @[ADI::Register(public: true)] class ValueStore property value : Int32 = 1 end describe Athena::DependencyInjection do describe "compiler errors", tags: "compiled" do it "errors when passing a non-module to add_compiler_pass" do ASPEC::Methods.assert_compile_time_error "Pass type must be a module.", <<-CR require "./spec_helper.cr" ADI.add_compiler_pass String CR end end describe ".container" do it "returns a container" do ADI.container.should be_a ADI::ServiceContainer end it "returns a fiber specific container" do channel = Channel(Int32).new container = ADI.container spawn do inner_container = ADI.container inner_container.value_store.value = 2 channel.send inner_container.value_store.value end channel.receive.should_not eq container.value_store.value end end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/auto_wire_spec.cr ================================================ require "../spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "../spec_helper.cr") end module AutoWireInterface; end @[ADI::Register] record AutoWireOne do include AutoWireInterface end @[ADI::Register] record AutoWireTwo do include AutoWireInterface end @[ADI::Register(public: true)] record AutoWireService, auto_wire_two : AutoWireInterface module SameInstanceAliasInterface; end @[ADI::Register] @[ADI::AsAlias] class SameInstancePrimary include SameInstanceAliasInterface end @[ADI::Register(public: true)] record SameInstanceClient, a : SameInstancePrimary, b : SameInstanceAliasInterface # Named alias tests module NamedAliasInterface; end @[ADI::Register] @[ADI::AsAlias(NamedAliasInterface, name: "file_logger")] class FileLoggerImpl include NamedAliasInterface end @[ADI::Register] @[ADI::AsAlias(NamedAliasInterface, name: "console_logger")] class ConsoleLoggerImpl include NamedAliasInterface end @[ADI::Register(public: true)] record NamedAliasService, file_logger : NamedAliasInterface, console_logger : NamedAliasInterface # Fallback alias tests module FallbackInterface; end @[ADI::Register] @[ADI::AsAlias(FallbackInterface, name: "specific")] class SpecificImpl include FallbackInterface end @[ADI::Register] @[ADI::AsAlias(FallbackInterface)] class DefaultImpl include FallbackInterface end @[ADI::Register(public: true)] record FallbackService, specific : FallbackInterface, other : FallbackInterface describe ADI::ServiceContainer do describe "compiler errors", tags: "compiled" do it "does not resolve an un-aliased interface when there is only 1 implementation" do assert_compile_time_error "Failed to resolve argument for service 'bar' (Bar).", <<-CR module SomeInterface; end @[ADI::Register] class Foo include SomeInterface end @[ADI::Register(public: true)] record Bar, a : SomeInterface ADI.container.bar CR end end it "resolves the service with a matching constructor name" do ADI.container.auto_wire_service.auto_wire_two.should be_a AutoWireTwo end it "resolves aliases to the same underlying instance" do service = ADI.container.same_instance_client service.a.should be service.b end it "resolves named aliases by parameter name" do service = ADI.container.named_alias_service service.file_logger.should be_a FileLoggerImpl service.console_logger.should be_a ConsoleLoggerImpl end it "falls back to type-only alias when no named match" do service = ADI.container.fallback_service service.specific.should be_a SpecificImpl service.other.should be_a DefaultImpl end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/define_getters_spec.cr ================================================ require "../spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "../spec_helper.cr") end private def assert_compiles(code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compiles code, line: line, preamble: %(require "../spec_helper.cr") end module PublicStringAliasInterface; end @[ADI::Register] @[ADI::AsAlias("bar_string_alias", public: true)] class PublicStringAlias include PublicStringAliasInterface end module TypedGetterAliasInterface; end @[ADI::Register] @[ADI::AsAlias(public: true)] class TypedGetterAlias include TypedGetterAliasInterface end module ArrayServiceInterface; end @[ADI::Register] struct ArrayOne include ArrayServiceInterface end @[ADI::Register] struct ArrayTwo include ArrayServiceInterface end @[ADI::Register] struct ArrayThree include ArrayServiceInterface end @[ADI::Register(_services: ["@array_one", "@array_three"], public: true)] record ImplicitArrayClient, services : Array(ArrayServiceInterface) @[ADI::Register(public: true)] record ExplicitArrayClient, services : Array(ArrayServiceInterface) = [] of ArrayServiceInterface describe ADI::ServiceContainer::DefineGetters, tags: "compiled" do describe "compiler errors" do describe "aliases" do it "does not expose named getter for non-public string aliases" do assert_compile_time_error "undefined method 'bar' for Athena::DependencyInjection::ServiceContainer", <<-'CR' module SomeInterface; end @[ADI::Register] @[ADI::AsAlias("bar")] class Foo include SomeInterface end ADI.container.bar CR end it "does not expose typed getter for non-public typed aliases" do assert_compile_time_error "undefined method 'get' for Athena::DependencyInjection::ServiceContainer", <<-'CR' module SomeInterface; end @[ADI::Register] @[ADI::AsAlias] class Foo include SomeInterface end ADI.container.get SomeInterface CR end end end it "compiles when a ServiceContainer type conflicts with internal ADI types" do assert_compiles <<-CR @[ADI::Register(public: true)] class ServiceContainer::Foo end CR end describe "aliases" do it "exposes named getter for public string alias" do ADI.container.bar_string_alias.should be_a PublicStringAlias end it "exposes typed getter for public typed alias" do ADI.container.get(TypedGetterAliasInterface).should be_a TypedGetterAlias end it "implicitly applies `of Type` restrictions to array values" do ADI.container.implicit_array_client.services.should eq [ArrayOne.new, ArrayThree.new] end it "does not apply `of Type` restriction to values that already explicitly have one" do ADI.container.explicit_array_client.services.should be_empty end end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/inline_service_definitions_spec.cr ================================================ require "../spec_helper" private def assert_compiles(code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compiles code, line: line, preamble: %(require "../spec_helper.cr") end # 1. Basic inlining: single-use private service @[ADI::Register] class BasicInlinedDep def value 42 end end @[ADI::Register(public: true)] class BasicInlineClient def initialize(@dep : BasicInlinedDep) end def value @dep.value end end # 2. Nested inlining: chain of inlined services @[ADI::Register] class NestedInlineDep1 def value 1 end end @[ADI::Register] class NestedInlineDep2 def initialize(@dep : NestedInlineDep1) end def value @dep.value + 1 end end @[ADI::Register(public: true)] class NestedInlineClient def initialize(@dep : NestedInlineDep2) end def value @dep.value end end # 3. Public alias target - should NOT be inlined module InlineTestAliasInterface; end @[ADI::Register] @[ADI::AsAlias("inline_test_alias", public: true)] class InlineAliasTargetService include InlineTestAliasInterface def value 100 end end @[ADI::Register(public: true)] class InlineAliasClient def initialize(@dep : InlineAliasTargetService) end def value @dep.value end end # 4. Proxy target - should NOT be inlined @[ADI::Register] class InlineProxyTargetService def value 200 end end @[ADI::Register(public: true)] class InlineProxyClient def initialize(@proxy : ADI::Proxy(InlineProxyTargetService)) end def value @proxy.value end end # 5. Public service - should NOT be inlined @[ADI::Register(public: true)] class InlinePublicDepService def value 300 end end @[ADI::Register(public: true)] class InlinePublicDepClient def initialize(@dep : InlinePublicDepService) end def value @dep.value end end # 6. Multiple references - should NOT be inlined @[ADI::Register] class InlineMultiRefService def value 400 end end @[ADI::Register(public: true)] class InlineMultiRefClient1 def initialize(@dep : InlineMultiRefService) end def value @dep.value end end @[ADI::Register(public: true)] class InlineMultiRefClient2 def initialize(@dep : InlineMultiRefService) end def value @dep.value + 1 end end # 7. Factory method inlining @[ADI::Register(factory: "create")] class FactoryInlineService getter value : Int32 def initialize(@value : Int32) end def self.create new(500) end end @[ADI::Register(public: true)] class FactoryInlineClient def initialize(@dep : FactoryInlineService) end def value @dep.value end end # 8. Calls argument inlining @[ADI::Register] class CallsInlineService def value 600 end end @[ADI::Register(public: true, calls: [{"set_service", {calls_inline_service}}])] class CallsInlineClient getter service : CallsInlineService? def set_service(@service : CallsInlineService) end end # 9. Inlined service with array parameter containing inlined services # This exercises the array handling code paths in inline_service_definitions.cr module InlineArrayInterface abstract def value : Int32 end @[ADI::Register] class ArrayLeafService1 include InlineArrayInterface def value : Int32 10 end end @[ADI::Register] class ArrayLeafService2 include InlineArrayInterface def value : Int32 20 end end # This service is private + single-use, so it gets inlined into ArrayParentClient # It takes an array of inlined services, exercising array handling code paths @[ADI::Register(_items: ["@array_leaf_service1", "@array_leaf_service2"])] class ArrayMiddleService def initialize(@items : Array(InlineArrayInterface)) end def total @items.sum(&.value) end end @[ADI::Register(public: true)] class ArrayParentClient def initialize(@middle : ArrayMiddleService) end def total @middle.total end end # 10. Inlined service with calls: containing inlined service args # This exercises the calls argument handling code paths @[ADI::Register] class CallsLeafService def value 700 end end # This service is private + single-use, gets inlined into CallsParentClient # It uses calls: with an inlined service arg @[ADI::Register(calls: [{"set_leaf", {calls_leaf_service}}])] class CallsMiddleService getter leaf : CallsLeafService? def set_leaf(@leaf : CallsLeafService) end def value @leaf.not_nil!.value end end @[ADI::Register(public: true)] class CallsParentClient def initialize(@middle : CallsMiddleService) end def value @middle.value end end # 11. Service with no-arg method call # This exercises the no-args branch in define_getters.cr @[ADI::Register(public: true, calls: [{"init"}])] class NoArgCallClient getter initialized = false def init @initialized = true end end # 12. Inlined service with array containing mix of inlined and non-inlined services @[ADI::Register(public: true)] class MixedArrayPublicService include InlineArrayInterface def value : Int32 100 end end @[ADI::Register] class MixedArrayInlinedService include InlineArrayInterface def value : Int32 50 end end # This service is private + single-use, so gets inlined # Its array has a mix: one inlined service, one public (non-inlined) service @[ADI::Register(_items: ["@mixed_array_inlined_service", "@mixed_array_public_service"])] class MixedArrayMiddleService def initialize(@items : Array(InlineArrayInterface)) end def total @items.sum(&.value) end end @[ADI::Register(public: true)] class MixedArrayClient def initialize(@middle : MixedArrayMiddleService) end def total @middle.total end end # 13. Out-of-order dependency to exercise re-queue logic # Define the dependent service BEFORE its dependency to test re-queue # OrderingMiddleService depends on OrderingDepService but is defined first @[ADI::Register] class OrderingMiddleService def initialize(@dep : OrderingDepService) end def value @dep.value end end @[ADI::Register] class OrderingDepService def value 999 end end @[ADI::Register(public: true)] class OrderingTestClient def initialize(@middle : OrderingMiddleService) end def value @middle.value end end # 14. Out-of-order array element dependency # ArrayOrderingMiddle depends on ArrayOrderingDep via array, but is defined first @[ADI::Register(_items: ["@array_ordering_dep"])] class ArrayOrderingMiddle def initialize(@items : Array(InlineArrayInterface)) end def total @items.sum(&.value) end end @[ADI::Register] class ArrayOrderingDep include InlineArrayInterface def value : Int32 777 end end @[ADI::Register(public: true)] class ArrayOrderingClient def initialize(@middle : ArrayOrderingMiddle) end def total @middle.total end end # 15. Out-of-order call arg dependency # CallOrderingMiddle has call with arg CallOrderingDep, but is defined first @[ADI::Register(calls: [{"set_dep", {call_ordering_dep}}])] class CallOrderingMiddle getter dep : CallOrderingDep? def set_dep(@dep : CallOrderingDep) end def value @dep.not_nil!.value end end @[ADI::Register] class CallOrderingDep def value 888 end end @[ADI::Register(public: true)] class CallOrderingClient def initialize(@middle : CallOrderingMiddle) end def value @middle.value end end # 16. Inlined service with call arg that is NOT an inlined service @[ADI::Register(public: true)] class NonInlinedCallArgService def value 800 end end # This service is private + single-use, gets inlined # Its call arg references a PUBLIC service (not inlined) @[ADI::Register(calls: [{"set_dep", {non_inlined_call_arg_service}}])] class NonInlinedCallArgMiddleService getter dep : NonInlinedCallArgService? def set_dep(@dep : NonInlinedCallArgService) end def value @dep.not_nil!.value end end @[ADI::Register(public: true)] class NonInlinedCallArgClient def initialize(@middle : NonInlinedCallArgMiddleService) end def value @middle.value end end describe ADI::ServiceContainer::InlineServiceDefinitions do it "compiles when a ServiceContainer type conflicts with internal ADI types", tags: "compiled" do assert_compiles <<-CR @[ADI::Register] class ServiceContainer::Foo end @[ADI::Register(public: true)] class BasicInlineClient def initialize(@dep : ::ServiceContainer::Foo) end end ADI.container.basic_inline_client CR end it "inlines single-use private services" do ADI.container.basic_inline_client.value.should eq 42 end it "supports nested inlined services" do ADI.container.nested_inline_client.value.should eq 2 end it "works with public alias targets" do ADI.container.inline_alias_client.value.should eq 100 end it "works with proxy targets" do ADI.container.inline_proxy_client.value.should eq 200 end it "works with public services as dependencies" do ADI.container.inline_public_dep_client.value.should eq 300 end it "works with multi-referenced services" do ADI.container.inline_multi_ref_client1.value.should eq 400 ADI.container.inline_multi_ref_client2.value.should eq 401 end it "supports factory methods for inlined services" do ADI.container.factory_inline_client.value.should eq 500 end it "supports inlined services passed via calls" do ADI.container.calls_inline_client.service.not_nil!.value.should eq 600 end it "supports inlined services with array parameters containing inlined services" do ADI.container.array_parent_client.total.should eq 30 end it "supports inlined services with calls using inlined service args" do ADI.container.calls_parent_client.value.should eq 700 end it "supports method calls without arguments" do ADI.container.no_arg_call_client.initialized.should be_true end it "supports inlined services with mixed array params (inlined and non-inlined)" do ADI.container.mixed_array_client.total.should eq 150 end it "supports inlined services with non-inlined call args" do ADI.container.non_inlined_call_arg_client.value.should eq 800 end it "handles out-of-order dependency processing" do ADI.container.ordering_test_client.value.should eq 999 end it "handles out-of-order array element dependencies" do ADI.container.array_ordering_client.total.should eq 777 end it "handles out-of-order call arg dependencies" do ADI.container.call_ordering_client.value.should eq 888 end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/merge_configs_spec.cr ================================================ require "../spec_helper" describe ADI::ServiceContainer::MergeConfigs do it "deep merges consecutive `ADI.configure` call", tags: "compiled" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema property default_locale : String module Cors include ADI::Extension::Schema module Defaults include ADI::Extension::Schema property allow_credentials : Bool = false property allow_origin : Array(String) = [] of String end end end ADI.register_extension "test", Schema ADI.configure({ test: { cors: { defaults: { allow_credentials: false, allow_origin: ["*"] of String, }, }, }, }) ADI.configure({ test: { cors: { defaults: { allow_credentials: true, }, }, }, }) ADI.configure({ test: { default_locale: "en", }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["default_locale"] == "en" }}, "Expected default_locale to be en") ASPEC.compile_time_assert(\{{ config["cors"]["defaults"]["allow_credentials"] == true }}, "Expected allow_credentials to be true") ASPEC.compile_time_assert(\{{ config["cors"]["defaults"]["allow_origin"].size == 1 }}, "Expected allow_origin size to be 1") ASPEC.compile_time_assert(\{{ config["cors"]["defaults"]["allow_origin"][0] == "*" }}, "Expected allow_origin[0] to be *") end end CR end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/merge_extension_config_spec.cr ================================================ require "../spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "../spec_helper.cr"), postamble: "ADI::ServiceContainer.new" end describe ADI::ServiceContainer::MergeExtensionConfig, tags: "compiled" do describe "compiler errors" do describe "root level" do it "errors if a required configuration value has not been provided" do assert_compile_time_error "Required configuration property 'test.id : Int32' must be provided.", <<-'CR' module Schema include ADI::Extension::Schema property id : Int32 property name : String end ADI.register_extension "test", Schema ADI.configure({ test: { name: "Fred" } }) CR end it "errors if there is a collection type mismatch" do assert_compile_time_error "Expected configuration value 'test.foo' to be a 'Array(Int32)', but got 'Array(String)'.", <<-'CR' module Schema include ADI::Extension::Schema property foo : Array(Int32) end ADI.register_extension "test", Schema ADI.configure({ test: { foo: [] of String } }) CR end it "errors if there is a type mismatch within an array" do assert_compile_time_error "Expected configuration value 'test.foo[0]' to be a 'Int32', but got 'UInt64'.", <<-'CR' module Schema include ADI::Extension::Schema property foo : Array(Int32) end ADI.register_extension "test", Schema ADI.configure({ test: { foo: [10_u64] of Int32 } }) CR end it "errors if a configuration value not found in the schema is encountered" do assert_compile_time_error "Unexpected property 'test.name'.", <<-'CR' module Schema include ADI::Extension::Schema property id : Int64 end ADI.register_extension "test", Schema ADI.configure({ test: { id: 10, name: "Fred" } }) CR end end describe "nested level" do it "errors if a configuration value has the incorrect type" do assert_compile_time_error "Required configuration property 'test.sub_config.defaults.id : Int32' must be provided.", <<-'CR' module Schema include ADI::Extension::Schema module SubConfig include ADI::Extension::Schema module Defaults include ADI::Extension::Schema property name : String property id : Int32 end end end ADI.register_extension "test", Schema ADI.configure({ test: { sub_config: { defaults: { name: "Fred" } } } }) CR end it "errors if there is a collection type mismatch" do assert_compile_time_error "Expected configuration value 'test.sub_config.defaults.foo' to be a 'Array(Int32)', but got 'Array(String)'.", <<-'CR' module Schema include ADI::Extension::Schema module SubConfig include ADI::Extension::Schema module Defaults include ADI::Extension::Schema property foo : Array(Int32) end end end ADI.register_extension "test", Schema ADI.configure({ test: { sub_config: { defaults: { foo: [] of String } } } }) CR end it "errors if there is a type mismatch within an array" do assert_compile_time_error "Expected configuration value 'test.sub_config.defaults.foo[1]' to be a 'Int32', but got 'UInt64'.", <<-'CR' module Schema include ADI::Extension::Schema module SubConfig include ADI::Extension::Schema module Defaults include ADI::Extension::Schema property foo : Array(Int32) end end end ADI.register_extension "test", Schema ADI.configure({ test: { sub_config: { defaults: { foo: [1, 10_u64] of Int32 } } } }) CR end it "errors if there is a type mismatch within an array without type hint" do assert_compile_time_error "Expected configuration value 'test.sub_config.defaults.foo[1]' to be a 'Int32', but got 'UInt64'.", <<-'CR' module Schema include ADI::Extension::Schema module SubConfig include ADI::Extension::Schema module Defaults include ADI::Extension::Schema property foo : Array(Int32) end end end ADI.register_extension "test", Schema ADI.configure({ test: { sub_config: { defaults: { foo: [1, 10_u64] } } } }) CR end it "errors if there is a type mismatch within an array using NoReturn schema default" do assert_compile_time_error "Expected configuration value 'test.sub_config.defaults.foo[1]' to be a 'Int32', but got 'UInt64'.", <<-'CR' module Schema include ADI::Extension::Schema module SubConfig include ADI::Extension::Schema module Defaults include ADI::Extension::Schema property foo : Array(Int32) end end end ADI.register_extension "test", Schema ADI.configure({ test: { sub_config: { defaults: { foo: [1, 10_u64] } } } }) CR end it "errors if a configuration value not found in the schema is encountered" do assert_compile_time_error "Unexpected property 'test.sub_config.defaults.name'.", <<-'CR' module Schema include ADI::Extension::Schema module SubConfig include ADI::Extension::Schema module Defaults include ADI::Extension::Schema property id : Int32 end end end ADI.register_extension "test", Schema ADI.configure({ test: { sub_config: { defaults: { id: 10, name: "Fred" } } } }) CR end end it "errors if a configuration value has the incorrect type" do assert_compile_time_error "Extension 'foo' is configured, but no extension with that name has been registered.", <<-'CR' ADI.configure({ foo: { id: 1 } }) CR end it "errors if nothing is configured, but a property is required" do assert_compile_time_error "Required configuration property 'test.id : Int32' must be provided.", <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema property id : Int32 end ADI.register_extension "test", Schema CR end end it "extension configuration value resolution" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Color Red Green Blue end module Schema include ADI::Extension::Schema ID = 10.0 property id : Int32 property float : Float64 = Schema::ID property name : String = "fred" property nilable : String? property color_type : Color property color_sym : Color property color_default : Color = :green property color_global : ::Color = :red property value : Hash(String, String) property regex : Regex end ADI.register_extension "blah", Schema ADI.configure({ blah: { id: 123, color_type: Color::Red, color_sym: :blue, value: {"id" => "10", "name" => "fred"}, regex: /foo/ }, }) macro finished macro finished \{% config = ADI::CONFIG["blah"] %} ASPEC.compile_time_assert(\{{ config["id"] == 123 }}, "Expected id to be 123") ASPEC.compile_time_assert(\{{ config["name"] == "fred" }}, "Expected name to be fred") ASPEC.compile_time_assert(\{{ config["float"] == 10.0 }}, "Expected float to be 10.0") ASPEC.compile_time_assert(\{{ config["nilable"].nil? }}, "Expected nilable to be nil") ASPEC.compile_time_assert(\{{ config["color_type"].stringify == "Color.new(0)" }}, "Expected color_type to be Color.new(0)") ASPEC.compile_time_assert(\{{ config["color_sym"].stringify == "Color.new(:blue)" }}, "Expected color_sym to be Color.new(:blue)") ASPEC.compile_time_assert(\{{ config["color_default"].stringify == "Color.new(:green)" }}, "Expected color_default to be Color.new(:green)") ASPEC.compile_time_assert(\{{ config["color_global"].stringify == "::Color.new(:red)" }}, "Expected color_global to be ::Color.new(:red)") ASPEC.compile_time_assert(\{{ config["value"] == {"id" => "10", "name" => "fred"} }}, "Expected value to be the expected hash") ASPEC.compile_time_assert(\{{ config["regex"] == /foo/ }}, "Expected regex to be /foo/") end end CR end it "does not error if nothing is configured, but all properties have defaults or are nilable" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema property id : Int32 = 123 end ADI.register_extension "blah", Schema macro finished macro finished \{% config = ADI::CONFIG["blah"] %} ASPEC.compile_time_assert(\{{ config["id"] == 123 }}, "Expected id to be 123") end end CR end it "inherits type of arrays from property if not explicitly set" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema property foo : Array(Int32) end ADI.register_extension "test", Schema ADI.configure({ test: { foo: [1, 2] } }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["foo"] == [1, 2] }}, "Expected foo to be [1, 2]") end end CR end it "allows using NoReturn to type empty arrays in schema" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema property foo : Array(Int32 | String) = [] of NoReturn end ADI.register_extension "test", Schema macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["foo"].stringify == "Array(Int32 | String).new" }}, "Expected foo to stringify as empty array") end end CR end it "allows customizing values when using NoReturn to type empty arrays defaults in schema" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema property foo : Array(Int32 | String) = [] of NoReturn end ADI.register_extension "test", Schema ADI.configure({ test: { foo: [1, 2] } }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["foo"] == [1, 2] }}, "Expected foo to be [1, 2]") end end CR end it "expands schema to include expected structure/defaults if not configuration is provided" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema module One include ADI::Extension::Schema property enabled : Bool = false end module Two include ADI::Extension::Schema property enabled : Bool = false end end ADI.register_extension "test", Schema macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["one"]["enabled"] == false }}, "Expected one.enabled to be false") ASPEC.compile_time_assert(\{{ config["two"]["enabled"] == false }}, "Expected two.enabled to be false") end end CR end it "expands schema to include expected structure/defaults if not explicitly provided" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema module One include ADI::Extension::Schema property enabled : Bool = false property id : Int32 end module Two include ADI::Extension::Schema property enabled : Bool = false module Three include ADI::Extension::Schema property enabled : Bool = false end end end ADI.register_extension "test", Schema ADI.configure({ test: { one: { id: 10, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["one"]["enabled"] == false }}, "Expected one.enabled to be false") ASPEC.compile_time_assert(\{{ config["one"]["id"] == 10 }}, "Expected one.id to be 10") ASPEC.compile_time_assert(\{{ config["two"]["enabled"] == false }}, "Expected two.enabled to be false") ASPEC.compile_time_assert(\{{ config["two"]["three"]["enabled"] == false }}, "Expected two.three.enabled to be false") end end CR end it "merges missing array_of defaults" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema array_of rules, id : Int32, stop : Bool = false end ADI.register_extension "test", Schema ADI.configure({ test: { rules: [ {id: 10}, ], }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["rules"][0]["id"] == 10 }}, "Expected rules[0].id to be 10") ASPEC.compile_time_assert(\{{ config["rules"][0]["stop"] == false }}, "Expected rules[0].stop to be false") end end CR end it "merges missing array_of defaults in time for other compiler passes" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema array_of rules, id : Int32, stop : Bool = false end module MyExtension macro included macro finished {% verbatim do %} {% ADI::CONFIG["parameters"]["stop"] = ADI::CONFIG["test"]["rules"][0]["stop"] %} {% end %} end end end ADI.register_extension "test", Schema ADI.add_compiler_pass MyExtension, :before_optimization, 500 # Ensure the Config passes run first ADI.configure({ test: { rules: [ {id: 10}, ], }, }) macro finished macro finished \{% parameters = ADI::CONFIG["parameters"] %} ASPEC.compile_time_assert(\{{ parameters["stop"] == false }}, "Expected parameters stop to be false") end end CR end it "array_of with nested object_schema fills in nested defaults" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : String = "hmac.sha256" array_of items, name : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { items: [ {name: "item1", jwt: {secret: "secret1"}}, {name: "item2", jwt: {secret: "secret2"}}, ], }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["items"][0]["name"] == "item1" }}, "Expected items[0].name to be item1") ASPEC.compile_time_assert(\{{ config["items"][0]["jwt"]["secret"] == "secret1" }}, "Expected items[0].jwt.secret to be secret1") ASPEC.compile_time_assert(\{{ config["items"][0]["jwt"]["algorithm"] == "hmac.sha256" }}, "Expected items[0].jwt.algorithm to be hmac.sha256") ASPEC.compile_time_assert(\{{ config["items"][1]["name"] == "item2" }}, "Expected items[1].name to be item2") ASPEC.compile_time_assert(\{{ config["items"][1]["jwt"]["secret"] == "secret2" }}, "Expected items[1].jwt.secret to be secret2") ASPEC.compile_time_assert(\{{ config["items"][1]["jwt"]["algorithm"] == "hmac.sha256" }}, "Expected items[1].jwt.algorithm to be hmac.sha256") end end CR end it "object_of with nested object_schema fills in nested defaults" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : String = "hmac.sha256" object_of connection, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { url: "localhost", jwt: {secret: "my-secret"}, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["connection"]["url"] == "localhost" }}, "Expected connection.url to be localhost") ASPEC.compile_time_assert(\{{ config["connection"]["jwt"]["secret"] == "my-secret" }}, "Expected connection.jwt.secret to be my-secret") ASPEC.compile_time_assert(\{{ config["connection"]["jwt"]["algorithm"] == "hmac.sha256" }}, "Expected connection.jwt.algorithm to be hmac.sha256") end end CR end it "fills in missing nilable keys with `nil`" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema object_of config, id : Int32, name : String? end ADI.register_extension "blah", Schema ADI.configure({ blah: { config: {id: 10}, }, }) macro finished macro finished \{% config = ADI::CONFIG["blah"] %} ASPEC.compile_time_assert(\{{ config["config"].keys.stringify == %([__nil, id, name]) }}, "Expected config keys to be [__nil, id, name]") ASPEC.compile_time_assert(\{{ config["config"]["id"] == 10 }}, "Expected config.id to be 10") ASPEC.compile_time_assert(\{{ config["config"]["name"].nil? }}, "Expected config.name to be nil") end end CR end it "fills in missing nilable keys with `nil` when missing from default value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema object_of config = {id: 123}, id : Int32, name : String? end ADI.register_extension "blah", Schema macro finished macro finished \{% config = ADI::CONFIG["blah"] %} ASPEC.compile_time_assert(\{{ config["config"].keys.stringify == %([id, name]) }}, "Expected config keys to be [id, name]") ASPEC.compile_time_assert(\{{ config["config"]["id"] == 123 }}, "Expected config.id to be 123") ASPEC.compile_time_assert(\{{ config["config"]["name"].nil? }}, "Expected config.name to be nil") end end CR end describe "map_of" do it "merges missing map_of defaults for each value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema map_of hubs, url : String, port : Int32 = 5432 end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: {url: "localhost"}, secondary: {url: "remote", port: 5433}, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["url"] == "localhost" }}, "Expected hubs.primary.url to be localhost") ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["port"] == 5432 }}, "Expected hubs.primary.port to be 5432") ASPEC.compile_time_assert(\{{ config["hubs"]["secondary"]["url"] == "remote" }}, "Expected hubs.secondary.url to be remote") ASPEC.compile_time_assert(\{{ config["hubs"]["secondary"]["port"] == 5433 }}, "Expected hubs.secondary.port to be 5433") end end CR end it "defaults to empty hash when not provided" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema map_of hubs, url : String end ADI.register_extension "test", Schema ADI.configure({ test: {} of Nil => Nil, }) macro finished macro finished \{% config = ADI::CONFIG["test"] # Check that hubs key exists and is empty (when converted to string, looks like "{}" or "{hubs => {}}") found_hubs = false hubs_empty = false config.each do |k, v| if k.stringify == "hubs" found_hubs = true hubs_empty = v.keys.reject { |vk| vk.stringify == "__nil" }.empty? end end %} ASPEC.compile_time_assert(\{{ found_hubs }}, "Expected hubs key to exist in config") ASPEC.compile_time_assert(\{{ hubs_empty }}, "Expected empty hash for hubs") end end CR end it "map_of? defaults to nil when not provided" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema map_of? hubs, url : String end ADI.register_extension "test", Schema ADI.configure({ test: {} of Nil => Nil, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["hubs"].nil? }}, "Expected hubs to be nil") end end CR end it "map_of with nested object_schema fills in nested defaults" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : String = "hmac.sha256" map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {secret: "my-secret"}, }, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["url"] == "localhost" }}, "Expected hubs.primary.url to be localhost") ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["jwt"]["secret"] == "my-secret" }}, "Expected hubs.primary.jwt.secret to be my-secret") ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["jwt"]["algorithm"] == "hmac.sha256" }}, "Expected hubs.primary.jwt.algorithm to be hmac.sha256") end end CR end it "errors if a required hash value property is missing" do assert_compile_time_error "Configuration value 'test.hubs.primary' is missing required value for 'url' of type 'String'.", <<-'CR' module Schema include ADI::Extension::Schema map_of hubs, url : String, port : Int32 = 5432 end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: {port: 5432}, }, }, }) CR end it "errors if a hash value has an unexpected key" do assert_compile_time_error "Expected configuration value 'test.hubs.primary' to be a '{url: url : String, port: port : Int32 = 5432}', but encountered unexpected key 'invalid'.", <<-'CR' module Schema include ADI::Extension::Schema map_of hubs, url : String, port : Int32 = 5432 end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: {url: "localhost", invalid: "foo"}, }, }, }) CR end it "errors if a nested map value has an unexpected type" do assert_compile_time_error "Expected configuration value 'test.hubs.primary.jwt.secret' to be a 'String', but got 'Int32'.", <<-'CR' module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {secret: 123}, }, }, }, }) CR end it "errors if a hash value property has wrong type" do assert_compile_time_error "Expected configuration value 'test.hubs.primary.port' to be a 'Int32', but got 'String'.", <<-'CR' module Schema include ADI::Extension::Schema map_of hubs, url : String, port : Int32 end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: {url: "localhost", port: "not-a-number"}, }, }, }) CR end it "errors if map_of direct member has wrong type when object_schema also present" do assert_compile_time_error "Expected configuration value 'test.hubs.primary.url' to be a 'String', but got 'Int32'.", <<-'CR' module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: 123, jwt: {secret: "valid"}, }, }, }, }) CR end it "fills in nested object_schema defaults for multiple map entries independently" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : String = "hmac.sha256" map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {secret: "secret1"}, }, secondary: { url: "remote", jwt: {secret: "secret2"}, }, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} # Verify both entries get their own independent defaults ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["jwt"]["algorithm"] == "hmac.sha256" }}, "Expected hubs.primary.jwt.algorithm to be hmac.sha256") ASPEC.compile_time_assert(\{{ config["hubs"]["secondary"]["jwt"]["algorithm"] == "hmac.sha256" }}, "Expected hubs.secondary.jwt.algorithm to be hmac.sha256") # And their unique values are preserved ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["jwt"]["secret"] == "secret1" }}, "Expected hubs.primary.jwt.secret to be secret1") ASPEC.compile_time_assert(\{{ config["hubs"]["secondary"]["jwt"]["secret"] == "secret2" }}, "Expected hubs.secondary.jwt.secret to be secret2") end end CR end it "errors if nested object_schema field is missing required value" do assert_compile_time_error "Configuration value 'test.hubs.primary.jwt' is missing required value for 'secret' of type 'String'.", <<-'CR' module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : String = "hmac.sha256" map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {algorithm: "rsa"}, }, }, }, }) CR end it "errors if nested object_schema has unexpected key" do assert_compile_time_error "Expected configuration value 'test.hubs.primary.jwt' to be a '{secret: secret : String, algorithm: algorithm : String = \"hmac.sha256\"}', but encountered unexpected key 'invalid'.", <<-'CR' module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : String = "hmac.sha256" map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {secret: "my-secret", invalid: "foo"}, }, }, }, }) CR end it "uses custom default for map_of with assignment syntax" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema map_of hubs = {default: {url: "localhost", port: 8080}}, url : String, port : Int32 = 5432 end ADI.register_extension "test", Schema # Don't configure hubs at all - it should use the custom default macro finished macro finished \{% config = ADI::CONFIG["test"] %} # Custom default entry should be present with its values ASPEC.compile_time_assert(\{{ config["hubs"]["default"]["url"] == "localhost" }}, "Expected hubs.default.url to be localhost") ASPEC.compile_time_assert(\{{ config["hubs"]["default"]["port"] == 8080 }}, "Expected hubs.default.port to be 8080") end end CR end it "object_schema enum member with symbol default value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {secret: "my-secret"}, }, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["url"] == "localhost" }}, "Expected hubs.primary.url to be localhost") ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["jwt"]["secret"] == "my-secret" }}, "Expected hubs.primary.jwt.secret to be my-secret") ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["jwt"]["algorithm"].stringify == "Algorithm.new(:hs256)" }}, "Expected hubs.primary.jwt.algorithm to be Algorithm.new(:hs256)") end end CR end it "object_schema enum member with user-provided symbol value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {secret: "my-secret", algorithm: :hs512}, }, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["url"] == "localhost" }}, "Expected hubs.primary.url to be localhost") ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["jwt"]["secret"] == "my-secret" }}, "Expected hubs.primary.jwt.secret to be my-secret") ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["jwt"]["algorithm"].stringify == "Algorithm.new(:hs512)" }}, "Expected hubs.primary.jwt.algorithm to be Algorithm.new(:hs512)") end end CR end it "object_schema enum member with global type" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : ::Algorithm = :hs256 map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {secret: "my-secret"}, }, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["jwt"]["algorithm"].stringify == "::Algorithm.new(:hs256)" }}, "Expected hubs.primary.jwt.algorithm to be ::Algorithm.new(:hs256)") end end CR end it "errors for invalid enum symbol in object_schema" do assert_compile_time_error "Unknown 'Algorithm' enum member for default value of 'test.hubs.primary.jwt.algorithm'.", <<-'CR' enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :invalid_algo map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {secret: "my-secret"}, }, }, }, }) CR end it "errors for invalid user-provided enum symbol in object_schema" do assert_compile_time_error "Unknown 'Algorithm' enum member for 'test.hubs.primary.jwt.algorithm'.", <<-'CR' enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {secret: "my-secret", algorithm: :invalid_algo}, }, }, }, }) CR end it "array_of object_schema enum member with symbol default value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 array_of items, name : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { items: [ {name: "item1", jwt: {secret: "my-secret"}}, ], }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["items"][0]["name"] == "item1" }}, "Expected items[0].name to be item1") ASPEC.compile_time_assert(\{{ config["items"][0]["jwt"]["secret"] == "my-secret" }}, "Expected items[0].jwt.secret to be my-secret") ASPEC.compile_time_assert(\{{ config["items"][0]["jwt"]["algorithm"].stringify == "Algorithm.new(:hs256)" }}, "Expected items[0].jwt.algorithm to be Algorithm.new(:hs256)") end end CR end it "array_of object_schema enum member with user-provided symbol value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 array_of items, name : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { items: [ {name: "item1", jwt: {secret: "my-secret", algorithm: :hs512}}, ], }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["items"][0]["jwt"]["algorithm"].stringify == "Algorithm.new(:hs512)" }}, "Expected items[0].jwt.algorithm to be Algorithm.new(:hs512)") end end CR end it "errors for invalid enum symbol in array_of object_schema default" do assert_compile_time_error "Unknown 'Algorithm' enum member for default value of 'test.items.jwt.algorithm'.", <<-'CR' enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :invalid_algo array_of items, name : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { items: [ {name: "item1", jwt: {secret: "my-secret"}}, ], }, }) CR end it "errors for invalid user-provided enum symbol in array_of object_schema" do assert_compile_time_error "Unknown 'Algorithm' enum member for 'test.items.jwt.algorithm'.", <<-'CR' enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 array_of items, name : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { items: [ {name: "item1", jwt: {secret: "my-secret", algorithm: :invalid_algo}}, ], }, }) CR end it "object_of object_schema enum member with symbol default value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 object_of connection, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { url: "localhost", jwt: {secret: "my-secret"}, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["connection"]["url"] == "localhost" }}, "Expected connection.url to be localhost") ASPEC.compile_time_assert(\{{ config["connection"]["jwt"]["secret"] == "my-secret" }}, "Expected connection.jwt.secret to be my-secret") ASPEC.compile_time_assert(\{{ config["connection"]["jwt"]["algorithm"].stringify == "Algorithm.new(:hs256)" }}, "Expected connection.jwt.algorithm to be Algorithm.new(:hs256)") end end CR end it "object_of object_schema enum member with user-provided symbol value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 object_of connection, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { url: "localhost", jwt: {secret: "my-secret", algorithm: :hs512}, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["connection"]["jwt"]["algorithm"].stringify == "Algorithm.new(:hs512)" }}, "Expected connection.jwt.algorithm to be Algorithm.new(:hs512)") end end CR end it "errors for invalid enum symbol in object_of object_schema default" do assert_compile_time_error "Unknown 'Algorithm' enum member for default value of 'test.connection.jwt.algorithm'.", <<-'CR' enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :invalid_algo object_of connection, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { url: "localhost", jwt: {secret: "my-secret"}, }, }, }) CR end it "errors for invalid user-provided enum symbol in object_of object_schema" do assert_compile_time_error "Unknown 'Algorithm' enum member for 'test.connection.jwt.algorithm'.", <<-'CR' enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 object_of connection, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { url: "localhost", jwt: {secret: "my-secret", algorithm: :invalid_algo}, }, }, }) CR end it "map_of object_schema enum member with number default value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = 0 map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {secret: "my-secret"}, }, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["jwt"]["algorithm"].stringify == "Algorithm.new(0)" }}, "Expected hubs.primary.jwt.algorithm to be Algorithm.new(0)") end end CR end it "map_of object_schema enum member with user-provided number value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { hubs: { primary: { url: "localhost", jwt: {secret: "my-secret", algorithm: 2}, }, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["hubs"]["primary"]["jwt"]["algorithm"].stringify == "Algorithm.new(2)" }}, "Expected hubs.primary.jwt.algorithm to be Algorithm.new(2)") end end CR end it "array_of object_schema enum member with number default value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = 0 array_of items, name : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { items: [ {name: "item1", jwt: {secret: "my-secret"}}, ], }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["items"][0]["jwt"]["algorithm"].stringify == "Algorithm.new(0)" }}, "Expected items[0].jwt.algorithm to be Algorithm.new(0)") end end CR end it "array_of object_schema enum member with user-provided number value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 array_of items, name : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { items: [ {name: "item1", jwt: {secret: "my-secret", algorithm: 2}}, ], }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["items"][0]["jwt"]["algorithm"].stringify == "Algorithm.new(2)" }}, "Expected items[0].jwt.algorithm to be Algorithm.new(2)") end end CR end it "object_of object_schema enum member with number default value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = 0 object_of connection, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { url: "localhost", jwt: {secret: "my-secret"}, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["connection"]["jwt"]["algorithm"].stringify == "Algorithm.new(0)" }}, "Expected connection.jwt.algorithm to be Algorithm.new(0)") end end CR end it "object_of object_schema enum member with user-provided number value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" enum Algorithm Hs256 Hs384 Hs512 end module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : Algorithm = :hs256 object_of connection, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { url: "localhost", jwt: {secret: "my-secret", algorithm: 2}, }, }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["connection"]["jwt"]["algorithm"].stringify == "Algorithm.new(2)" }}, "Expected connection.jwt.algorithm to be Algorithm.new(2)") end end CR end end it "handles multiple extensions where one has nested schemas" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" # First extension with nested schema modules module FirstSchema include ADI::Extension::Schema property root_prop : String = "root" module Nested include ADI::Extension::Schema property nested_prop : Int32 = 42 end end # Second extension without nested schemas module SecondSchema include ADI::Extension::Schema property other_prop : Bool = true end ADI.register_extension "first", FirstSchema ADI.register_extension "second", SecondSchema macro finished macro finished \{% first_config = ADI::CONFIG["first"] second_config = ADI::CONFIG["second"] %} ASPEC.compile_time_assert(\{{ first_config["root_prop"] == "root" }}, "Expected first.root_prop to be root") ASPEC.compile_time_assert(\{{ first_config["nested"]["nested_prop"] == 42 }}, "Expected first.nested.nested_prop to be 42") ASPEC.compile_time_assert(\{{ second_config["other_prop"] == true }}, "Expected second.other_prop to be true") ASPEC.compile_time_assert(\{{ second_config["nested"] == nil }}, "Expected second to not have nested key") end end CR end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/namespaced_spec.cr ================================================ require "../spec_helper" @[ADI::Register] class MyApp::Models::Foo end @[ADI::Register(public: true)] class NamespaceClient getter service def initialize(@service : MyApp::Models::Foo); end end describe ADI::ServiceContainer do it "correctly resolves the service" do ADI.container.namespace_client.service.should be_a MyApp::Models::Foo end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/normalize_definitions_spec.cr ================================================ require "../spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: <<-'CRYSTAL', postamble: <<-'CR' require "../spec_helper.cr" module MySchema include ADI::Extension::Schema property id : Int32 = 10 end class SomeService; end module MyExtension macro included macro finished {% verbatim do %} {% CRYSTAL %} {% end %} end end end ADI.register_extension "test", MySchema ADI.add_compiler_pass MyExtension, :before_optimization, 1028 ADI::ServiceContainer.new CR end describe ADI::ServiceContainer::NormalizeDefinitions, tags: "compiled" do describe "compiler errors" do it "`class` is not provided" do assert_compile_time_error "Service 'some_service' is missing required 'class' property.", <<-'CR' SERVICE_HASH["some_service"] = { public: false, } CR end end it "applies defaults to missing properties" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper.cr" module MySchema include ADI::Extension::Schema property id : Int32 = 10 end class SomeService; end module MyExtension macro included macro finished {% verbatim do %} {% SERVICE_HASH["some_service"] = { class: SomeService, public: true, } %} {% end %} end end end ADI.register_extension "test", MySchema ADI.add_compiler_pass MyExtension, :before_optimization, 1028 ADI::ServiceContainer.new macro finished macro finished \{% some_service = ADI::ServiceContainer::SERVICE_HASH["some_service"] %} ASPEC.compile_time_assert(\{{ some_service["class"] == SomeService }}, "Expected class to be SomeService") ASPEC.compile_time_assert(\{{ some_service["public"] == true }}, "Expected public to be true") ASPEC.compile_time_assert(\{{ some_service["calls"].size == 0 }}, "Expected calls to be empty") ASPEC.compile_time_assert(\{{ some_service["tags"].size == 0 }}, "Expected tags to be empty") ASPEC.compile_time_assert(\{{ some_service["generics"].size == 0 }}, "Expected generics to be empty") ASPEC.compile_time_assert(\{{ some_service["parameters"].size == 0 }}, "Expected parameters to be empty") ASPEC.compile_time_assert(\{{ some_service["shared"] == true }}, "Expected shared to be true") ASPEC.compile_time_assert(\{{ some_service["referenced_services"].size == 0 }}, "Expected referenced_services to be empty") end end CR end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/optional_services_spec.cr ================================================ require "../spec_helper" struct OptionalMissingService end @[ADI::Register] struct OptionalExistingService end @[ADI::Register(public: true)] class OptionalClient getter service_missing, service_existing, service_default def initialize( @service_missing : OptionalMissingService?, @service_existing : OptionalExistingService?, @service_default : OptionalMissingService | Int32 | Nil = 12, ); end end describe ADI::ServiceContainer do describe "where a dependency is optional" do describe "and does not exist" do describe "without a default value" do it "should inject `nil`" do ADI.container.optional_client.service_missing.should be_nil end end describe "with a default value" do it "should inject the default" do ADI.container.optional_client.service_default.should eq 12 end end end describe "and does exist" do it "should inject that service" do ADI.container.optional_client.service_existing.should be_a OptionalExistingService end end end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/parameters_spec.cr ================================================ require "../spec_helper" @[ADI::Register( public: true, _reference: "%app.domain%", _with_percent: "%app.with_percent%", _with_percent_and_placeholder: "%app.with_percent_placeholder%", _with_single_placeholder: "%app.placeholder%", _with_multiple_placeholders: "%app.placeholders%", _hash: "%app.mapping%", _array: "%app.array%", _nested_array: "%app.nested_array%", _nested_hash: "%app.nested_mapping%", _non_string: "%app.enable_v2_protocol%", _nested_deferred_string: "%app.full_url%" )] class ParametersClient def initialize( reference : String, with_percent : String, with_percent_and_placeholder : String, with_single_placeholder : String, with_multiple_placeholders : String, hash : Hash(Int32, String), array : Array(String), nested_array : Array(Array(String) | String), nested_hash : Hash(String, Bool | String | Array(String) | Array(Array(String) | String)), non_string : Bool, nested_deferred_string : String, ) reference.should eq "google.com" with_percent.should eq "foo%bar" with_percent_and_placeholder.should eq "https://google.com/path/t%o/thing" with_single_placeholder.should eq "https://google.com/path/to/thing" with_multiple_placeholders.should eq "https://google.com/path/to/false" hash.should eq({10 => "google.com", 20 => "https://google.com/path/to/thing"}) array.should eq ["google.com", "https://google.com/path/to/thing", "foo%bar", "https://google.com/path/t%o/thing"] nested_array.should eq [["google.com", "https://google.com/path/to/thing", "foo%bar", "https://google.com/path/t%o/thing"], "google.com", "foo%bar"] nested_hash.should eq({"string" => "google.com", "array" => ["google.com", "https://google.com/path/to/thing", "foo%bar", "https://google.com/path/t%o/thing"], "nested_array" => [["google.com", "https://google.com/path/to/thing", "foo%bar", "https://google.com/path/t%o/thing"], "google.com", "foo%bar"], "bool" => false, "escaped" => "foo%bar"}) non_string.should be_false nested_deferred_string.should eq "Visit: https://google.com/path/to/thing!" end end describe ADI::ServiceContainer do it "resolves parameters" do ADI.container.parameters_client end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/process_aliases_spec.cr ================================================ require "../spec_helper" private def assert_compiles(code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compiles code, line: line, preamble: %(require "../spec_helper.cr"), postamble: "ADI::ServiceContainer.new" end private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "../spec_helper.cr"), postamble: "ADI::ServiceContainer.new" end describe ADI::ServiceContainer::ProcessAliases, tags: "compiled" do it "errors if unable to determine the alias name" do assert_compile_time_error "Alias cannot be automatically determined for 'foo' (Foo). If the type includes multiple interfaces, provide the interface to alias as the first positional argument to `@[ADI::AsAlias]`.", <<-'CR' module SomeInterface; end module OtherInterface; end @[ADI::Register] @[ADI::AsAlias] class Foo include SomeInterface include OtherInterface end CR end it "allows explicit string alias name" do assert_compiles <<-'CR' @[ADI::Register] @[ADI::AsAlias("bar")] class Foo; end macro finished macro finished ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES.keys == ["bar"] }}, "Expected alias keys to be [bar]") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES["bar"].size == 1 }}, "Expected bar alias size to be 1") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES["bar"][0]["id"] == "foo" }}, "Expected bar alias id to be foo") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES["bar"][0]["public"] == false }}, "Expected bar alias public to be false") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES["bar"][0]["name"].nil? }}, "Expected bar alias name to be nil") end end CR end it "allows explicit const alias name" do assert_compiles <<-'CR' BAR = "bar" @[ADI::Register] @[ADI::AsAlias(BAR)] class Foo; end macro finished macro finished ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES.keys == ["bar"] }}, "Expected alias keys to be [bar]") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES["bar"].size == 1 }}, "Expected bar alias size to be 1") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES["bar"][0]["id"] == "foo" }}, "Expected bar alias id to be foo") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES["bar"][0]["public"] == false }}, "Expected bar alias public to be false") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES["bar"][0]["name"].nil? }}, "Expected bar alias name to be nil") end end CR end it "allows explicit TypeNode alias name" do assert_compiles <<-'CR' module SomeInterface; end @[ADI::Register] @[ADI::AsAlias(SomeInterface, public: true)] class Foo include SomeInterface end macro finished macro finished ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES.keys == [SomeInterface] }}, "Expected alias keys to be [SomeInterface]") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface].size == 1 }}, "Expected SomeInterface alias size to be 1") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface][0]["id"] == "foo" }}, "Expected SomeInterface alias id to be foo") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface][0]["public"] == true }}, "Expected SomeInterface alias public to be true") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface][0]["name"].nil? }}, "Expected SomeInterface alias name to be nil") end end CR end it "uses included interface type as alias name if there is only 1" do assert_compiles <<-'CR' module SomeInterface; end @[ADI::Register] @[ADI::AsAlias] class Foo include SomeInterface end macro finished macro finished ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES.keys == [SomeInterface] }}, "Expected alias keys to be [SomeInterface]") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface].size == 1 }}, "Expected SomeInterface alias size to be 1") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface][0]["id"] == "foo" }}, "Expected SomeInterface alias id to be foo") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface][0]["public"] == false }}, "Expected SomeInterface alias public to be false") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface][0]["name"].nil? }}, "Expected SomeInterface alias name to be nil") end end CR end it "allows aliasing more than one interface" do assert_compiles <<-'CR' module SomeInterface; end module OtherInterface; end @[ADI::Register] @[ADI::AsAlias(SomeInterface)] @[ADI::AsAlias(OtherInterface)] class Foo include SomeInterface include OtherInterface end macro finished macro finished ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES.keys == [SomeInterface, OtherInterface] }}, "Expected alias keys to be [SomeInterface, OtherInterface]") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface].size == 1 }}, "Expected SomeInterface alias size to be 1") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[OtherInterface].size == 1 }}, "Expected OtherInterface alias size to be 1") end end CR end it "allows named alias with type" do assert_compiles <<-'CR' module SomeInterface; end @[ADI::Register] @[ADI::AsAlias(SomeInterface, name: "my_param")] class Foo include SomeInterface end macro finished macro finished ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES.keys == [SomeInterface] }}, "Expected alias keys to be [SomeInterface]") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface].size == 1 }}, "Expected SomeInterface alias size to be 1") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface][0]["id"] == "foo" }}, "Expected SomeInterface alias id to be foo") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface][0]["name"].id == "my_param" }}, "Expected SomeInterface alias name to be my_param") end end CR end it "allows multiple named aliases for same type" do assert_compiles <<-'CR' module SomeInterface; end @[ADI::Register] @[ADI::AsAlias(SomeInterface, name: "first")] class First include SomeInterface end @[ADI::Register] @[ADI::AsAlias(SomeInterface, name: "second")] class Second include SomeInterface end macro finished macro finished ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface].size == 2 }}, "Expected SomeInterface alias size to be 2") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface][0]["name"].id == "first" }}, "Expected SomeInterface alias[0] name to be first") ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface][1]["name"].id == "second" }}, "Expected SomeInterface alias[1] name to be second") end end CR end it "allows both named and type-only aliases for same type" do assert_compiles <<-'CR' module SomeInterface; end @[ADI::Register] @[ADI::AsAlias(SomeInterface, name: "specific")] class Specific include SomeInterface end @[ADI::Register] @[ADI::AsAlias(SomeInterface)] class Default include SomeInterface end macro finished macro finished ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::ALIASES[SomeInterface].size == 2 }}, "Expected SomeInterface alias size to be 2") \{% named = ADI::ServiceContainer::ALIASES[SomeInterface].find { |a| !a["name"].nil? } type_only = ADI::ServiceContainer::ALIASES[SomeInterface].find { |a| a["name"].nil? } %} ASPEC.compile_time_assert(\{{ named["id"] == "specific" }}, "Expected named alias id to be specific") ASPEC.compile_time_assert(\{{ type_only["id"] == "default" }}, "Expected type-only alias id to be default") end end CR end it "errors on duplicate type+name combination" do assert_compile_time_error "Duplicate alias for type 'SomeInterface' with name 'my_param'", <<-'CR' module SomeInterface; end @[ADI::Register] @[ADI::AsAlias(SomeInterface, name: "my_param")] class Foo include SomeInterface end @[ADI::Register] @[ADI::AsAlias(SomeInterface, name: "my_param")] class Bar include SomeInterface end CR end it "errors on duplicate type-only alias" do assert_compile_time_error "Duplicate alias for type 'SomeInterface'. A type-only alias", <<-'CR' module SomeInterface; end @[ADI::Register] @[ADI::AsAlias(SomeInterface)] class Foo include SomeInterface end @[ADI::Register] @[ADI::AsAlias(SomeInterface)] class Bar include SomeInterface end CR end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/process_auto_configurations_spec.cr ================================================ require "../spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "../spec_helper.cr"), postamble: "ADI::ServiceContainer.new" end private PARTNER_TAG = "partner" enum Status Active Inactive end @[ADI::Register(_id: 1, name: "google", tags: [{name: PARTNER_TAG, priority: 5}])] @[ADI::Register(_id: 2, name: "facebook", tags: [PARTNER_TAG])] @[ADI::Register(_id: 3, name: "yahoo", tags: [{name: "partner", priority: 10}])] @[ADI::Register(_id: 4, name: "microsoft", tags: [PARTNER_TAG])] struct FeedPartner getter id def initialize(@id : Int32); end end @[ADI::Register(_services: "!partner", public: true)] class PartnerClient getter services def initialize(@services : Array(FeedPartner)) end end @[ADI::Register(_services: "!partner", public: true)] class PartnerNamedDefaultClient getter services getter status def initialize( @services : Array(FeedPartner), @status : Status = Status::Active, ) end end @[ADI::Register(_services: "!partner", public: true)] record ProxyTagClient, services : Array(ADI::Proxy(FeedPartner)) OTHER_TAG = "foo.bar" @[ADI::Autoconfigure(tags: [OTHER_TAG])] module Test; end @[ADI::Autoconfigure(public: true)] module PublicService; end @[ADI::Register] @[ADI::Autoconfigure(tags: ["config"])] class MultipleTags include Test end @[ADI::Register] class SingleTag include Test end @[ADI::Register(_services: "!foo.bar", public: true)] record OtherTagClient, services : Array(Test) @[ADI::Register(_services: "!config", public: true)] record ConfigTagClient, services : Array(MultipleTags) @[ADI::Register] record AutoConfiguredPublicService do include PublicService end STRING_UNUSED_TAG2 = "string_unused_tag2" STRING_UNUSED_TAG3 = "string_unused_tag3" STRING_UNUSED_TAG4 = "string_unused_tag4" @[ADI::Autoconfigure(tags: ["unused_tag"])] module UnusedInterface; end @[ADI::Autoconfigure(tags: [STRING_UNUSED_TAG2])] module UnusedInterface2; end @[ADI::Autoconfigure(tags: STRING_UNUSED_TAG3)] module UnusedInterface3; end @[ADI::Autoconfigure(tags: [{name: STRING_UNUSED_TAG4}])] module UnusedInterface4; end @[ADI::Autoconfigure(tags: [{name: "string_unused_tag5"}])] module UnusedInterface5; end @[ADI::Register(_services: "!unused_tag", public: true)] record UnusedTagClient, services : Array(UnusedInterface) @[ADI::Autoconfigure(calls: [{"foo", {6}}, {"foo", {3}}, {"foo"}])] module CallInterface; end @[ADI::Register(public: true)] class CallAutoconfigureClient include CallInterface getter values = [] of Int32 def foo(value : Int32 = 1) @values << value end end @[ADI::AutoconfigureTag] module Namespace::FQNTagInterface; end @[ADI::AutoconfigureTag("foo")] module Namespace::ExplicitTagInterface; end BAR_TAG = "bar" @[ADI::AutoconfigureTag(BAR_TAG)] module Namespace::ExplicitTagConstInterface; end @[ADI::AutoconfigureTag("prioritized_tag", priority: 10)] module Namespace::PrioritizedTagInterface; end @[ADI::Register] record FQNService1 do include Namespace::FQNTagInterface include Namespace::ExplicitTagConstInterface end @[ADI::Register] record FQNService2 do include Namespace::FQNTagInterface include Namespace::ExplicitTagInterface end @[ADI::Register] record PrioritizedService1 do include Namespace::PrioritizedTagInterface end @[ADI::Register(tags: [{name: "prioritized_tag", priority: 20}])] record PrioritizedService2 do include Namespace::PrioritizedTagInterface end @[ADI::Register(public: true, _services: "!prioritized_tag")] class PrioritizedTagClient getter services : Array(Namespace::PrioritizedTagInterface) def initialize(@services : Array(Namespace::PrioritizedTagInterface)); end end @[ADI::Register(public: true, _services: "!Namespace::FQNTagInterface")] class FQNTagClient getter services : Array(Namespace::FQNTagInterface) def initialize(@services : Array(Namespace::FQNTagInterface)); end end @[ADI::Register(public: true, _services: "!foo")] class FQNTagNamedClient getter services : Array(Namespace::ExplicitTagInterface) def initialize(@services : Array(Namespace::ExplicitTagInterface)); end end @[ADI::Register(public: true, _services: "!bar")] class FQNTagConstClient getter services : Array(Namespace::ExplicitTagConstInterface) def initialize(@services : Array(Namespace::ExplicitTagConstInterface)); end end @[ADI::Register(public: true)] class FQNTaggedIteratorClient getter services def initialize(@[ADI::TaggedIterator] @services : Enumerable(Namespace::FQNTagInterface)); end end @[ADI::Register(public: true)] class FQNTaggedIteratorNamedClient getter services def initialize(@[ADI::TaggedIterator("Namespace::FQNTagInterface")] @services : Enumerable(Namespace::FQNTagInterface)); end end NAMESPACE_TAG = "Namespace::FQNTagInterface" @[ADI::Register(public: true)] class FQNTaggedIteratorNamedConstClient getter services def initialize(@[ADI::TaggedIterator(NAMESPACE_TAG)] @services : Enumerable(Namespace::FQNTagInterface)); end end @[ADI::Autoconfigure(tags: ["non-service-abstract"])] abstract struct NonServiceAbstract; end @[ADI::Register] record NonServiceChild1 < NonServiceAbstract @[ADI::Register] record NonServiceChild2 < NonServiceAbstract @[ADI::Register(public: true, _services: "!non-service-abstract")] class NonServiceAbstractClient getter services : Array(NonServiceAbstract) def initialize(@services : Array(NonServiceAbstract)); end end @[ADI::Autoconfigure(constructor: "create", public: true)] abstract class AutoConfigureConstructor; end @[ADI::Register(public: true)] class ConstructorOne < AutoConfigureConstructor getter num def self.create : self new 123 end private def initialize(@num : Int32); end end @[ADI::Register(_id: 999, public: true)] class ConstructorTwo < AutoConfigureConstructor getter num def self.create(id : Int32) : self new id end private def initialize(@num : Int32); end end module ManualTagInterface; end module ManualPublicInterface; end module ManualCallsInterface; end abstract class ManualConstructorBase; end module ManualAutoConfigSetup macro included macro finished {% verbatim do %} {% AUTO_CONFIGURATIONS[ManualTagInterface] = {tags: ["manual_tag"]} AUTO_CONFIGURATIONS[ManualPublicInterface] = {public: true} AUTO_CONFIGURATIONS[ManualCallsInterface] = {calls: [{"foo", {6}}, {"foo"}]} AUTO_CONFIGURATIONS[ManualConstructorBase] = {constructor: "create", public: true} %} {% end %} end end end ADI.add_compiler_pass ManualAutoConfigSetup, priority: 200 @[ADI::Register] record ManualTagService1 do include ManualTagInterface end @[ADI::Register] record ManualTagService2 do include ManualTagInterface end @[ADI::Register(public: true, _services: "!manual_tag")] class ManualTagClient getter services : Array(ManualTagInterface) def initialize(@services : Array(ManualTagInterface)); end end @[ADI::Register] record ManualPublicService do include ManualPublicInterface end @[ADI::Register(public: true)] class ManualCallsClient include ManualCallsInterface getter values = [] of Int32 def foo(value : Int32 = 1) @values << value end end @[ADI::Register(public: true)] class ManualConstructorChild < ManualConstructorBase getter num def self.create : self new 42 end private def initialize(@num : Int32); end end describe ADI::ServiceContainer::ProcessAutoconfigureAnnotations do describe "compiler errors", tags: "compiled" do describe "tags" do it "errors if the `tags` field is not of a valid type" do assert_compile_time_error "'tags' field of service 'foo' must be an 'ArrayLiteral', got 'NumberLiteral'.", <<-CR @[ADI::Register(tags: 123)] record Foo CR end it "errors if the `tags` field on the auto configuration is not of a valid type" do assert_compile_time_error "'tags' field of auto configuration 'Test' must be an 'ArrayLiteral', got 'NumberLiteral'.", <<-CR @[ADI::Autoconfigure(tags: 123)] module Test; end @[ADI::Register] record Foo do include Test end CR end it "errors if not all tags are of the proper type" do assert_compile_time_error "Tag must be a 'StringLiteral' or 'NamedTupleLiteral', got 'NumberLiteral'.", <<-CR @[ADI::Autoconfigure(tags: [123])] module Test; end @[ADI::Register] record Foo do include Test end CR end it "errors if not all tags have a name" do assert_compile_time_error "Failed to register service 'foo' (Foo). Tag must have a name.", <<-CR @[ADI::Autoconfigure(tags: [{ name: "A" }, { priority: 123 }])] module Test; end @[ADI::Register] record Foo do include Test end CR end describe ADI::TaggedIterator do it "errors if used with unsupported collection type" do assert_compile_time_error "Failed to register service 'fqn_tagged_iterator_named_client' (FQNTaggedIteratorNamedClient). Collection type must be one of 'Indexable', 'Iterator', or 'Enumerable'. Got 'Set'.", <<-CR @[ADI::Register] class FQNTaggedIteratorNamedClient getter services def initialize(@[ADI::TaggedIterator] @services : Set(String)); end end CR end end it "errors when both an annotation and a manual entry exist for the same type" do assert_compile_time_error "Auto configuration for 'ManualConflict' is already defined in 'AUTO_CONFIGURATIONS'. Remove the annotation or the manual entry.", <<-CR @[ADI::Autoconfigure(tags: ["conflict_tag"])] module ManualConflict; end module ConflictSetup macro included macro finished {% verbatim do %} {% AUTO_CONFIGURATIONS[ManualConflict] = {tags: ["conflict_tag"]} %} {% end %} end end end ADI.add_compiler_pass ConflictSetup, priority: 200 @[ADI::Register] record Foo do include ManualConflict end CR end end describe "calls" do it "errors if the method of a call is empty" do assert_compile_time_error "Auto configuration 'Test': 'calls' method name cannot be empty.", <<-CR @[ADI::Autoconfigure(calls: [{""}])] module Test; end @[ADI::Register] record Foo do include Test end CR end it "errors if the method does not exist on the type" do assert_compile_time_error "Auto configuration 'Test': 'calls' method does not exist on service 'foo' (Foo).", <<-CR @[ADI::Autoconfigure(calls: [{"foo"}])] module Test; end @[ADI::Register] record Foo do include Test end CR end end end describe "tags" do it "injects all services with that tag, ordering based on priority" do services = ADI.container.partner_client.services services[0].id.should eq 3 services[1].id.should eq 1 services[2].id.should eq 2 services[3].id.should eq 4 end it "also allows the service to still have defaults after the tagged services argument" do service = ADI.container.partner_named_default_client service.services.size.should eq 4 service.status.should eq Status::Active end it "converts each tagged service into a proxy" do services = ADI.container.proxy_tag_client.services services[0].id.should eq 3 services[1].id.should eq 1 services[2].id.should eq 2 services[3].id.should eq 4 end it "applies tags from auto_configure" do ADI.container.other_tag_client.services.size.should eq 2 ADI.container.config_tag_client.services.size.should eq 1 end describe ADI::AutoconfigureTag do it "without tag" do ADI.container.fqn_tag_client.services.should eq [FQNService1.new, FQNService2.new] end it "with tag name" do ADI.container.fqn_tag_named_client.services.should eq [FQNService2.new] end it "with tag name const" do ADI.container.fqn_tag_const_client.services.should eq [FQNService1.new] end it "with named args" do services = ADI.container.prioritized_tag_client.services services[0].should eq PrioritizedService2.new services[1].should eq PrioritizedService1.new end end describe ADI::TaggedIterator do it "without tag name" do collection = ADI.container.fqn_tagged_iterator_client.services collection.should be_a Iterator(Namespace::FQNTagInterface) collection.map(&.itself).should eq [FQNService1.new, FQNService2.new] end it "with tag name" do collection = ADI.container.fqn_tagged_iterator_named_client.services collection.should be_a Iterator(Namespace::FQNTagInterface) collection.map(&.itself).should eq [FQNService1.new, FQNService2.new] end it "with tag name as const" do collection = ADI.container.fqn_tagged_iterator_named_const_client.services collection.should be_a Iterator(Namespace::FQNTagInterface) collection.map(&.itself).should eq [FQNService1.new, FQNService2.new] end end it "handles a non service abstract parent type with service child types" do ADI.container.non_service_abstract_client.services.should eq [NonServiceChild1.new, NonServiceChild2.new] end it "provides an empty array if there were no services configured with the desired tag" do ADI.container.unused_tag_client.services.should be_empty end it "applies tags to manually configured matching services" do ADI.container.manual_tag_client.services.should eq [ManualTagService1.new, ManualTagService2.new] end end describe "public" do it "when wired up via annotation" do ADI.container.auto_configured_public_service.should be_a AutoConfiguredPublicService end it "when manually wired up" do ADI.container.manual_public_service.should be_a ManualPublicService end end describe "calls" do it "when wired up via annotation" do ADI.container.call_autoconfigure_client.values.should eq [6, 3, 1] end it "when manually wired up" do ADI.container.manual_calls_client.values.should eq [6, 1] end end describe "constructor" do it "when wired up via annotation" do ADI.container.constructor_one.num.should eq 123 ADI.container.constructor_two.num.should eq 999 end it "when manually wired up" do ADI.container.manual_constructor_child.num.should eq 42 end end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/process_bindings_spec.cr ================================================ require "../spec_helper" ADI.bind id, 20 ADI.bind name, "Fred" # Overrides previous value ADI.bind value, 66 ADI.bind value, 88 # Mixed type ADI.bind value : Float32, 3.14 ADI.bind value : Float64, 99.99 @[ADI::Register(public: true, _id: 30)] @[ADI::Autoconfigure(bind: {id: 10, name: "Jim", alive: true})] class BindingsPriorityClient def initialize( id : Int64, # Ann has highest priority name : String, # Global bind 2nd highest priority alive : Bool, # Autoconfigure lowest priority value : Float32, ) id.should eq 30 name.should eq "Fred" alive.should be_true value.should eq 3.14_f32 end end @[ADI::Register] class SomeClassService getter value : Int32 = 123 def_equals end @[ADI::Register] record SomeStructService, name : String = "Fred" @[ADI::Register( public: true, _some_service: "@some_class_service", _some_parameter: "%app.domain%", _service_hash: { "class" => "@some_class_service", "struct" => "@some_struct_service", }, _service_array: [ "@some_class_service", "@some_struct_service", ] )] class BindingsClient def initialize( some_service : SomeClassService, some_parameter : String, service_hash : Hash(String, SomeClassService | SomeStructService), service_array : Array(SomeClassService | SomeStructService), value : Float64, proxy_service : ADI::Proxy(SomeStructService), ) some_service.value.should eq 123 some_parameter.should eq "google.com" service_hash.should eq({"class" => SomeClassService.new, "struct" => SomeStructService.new}) service_array.should eq [SomeClassService.new, SomeStructService.new] value.should eq 99.99 proxy_service.should eq SomeStructService.new end end @[ADI::Register(public: true)] class Bindings2Client def initialize( value, proxy_service : SomeStructService, ) value.should eq 88 proxy_service.should eq SomeStructService.new end end alias MyCustomInt = Int32 ADI.bind aliased_number : Int32, 123 ADI.bind aliased_number, 456 @[ADI::Register(public: true)] record AliasedBindingClient, aliased_number : MyCustomInt describe ADI::ServiceContainer do it "resolves bindings in proper order Annotation > Global > AutoConfigure" do ADI.container.bindings_priority_client end it "resolves parameter and service references" do ADI.container.bindings_client ADI.container.bindings2_client end it "resolves typed bindings when types differ" do ADI.container.aliased_binding_client.aliased_number.should eq 123 end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/process_parameters_spec.cr ================================================ require "../spec_helper" describe ADI::ServiceContainer::ProcessParameters, tags: "compiled" do it "populates parameter information of registered services" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper.cr" @[ADI::Register(_id: 123, public: true)] class SomeService def initialize(@id : Int32); end end ADI::ServiceContainer.new macro finished macro finished \{% parameters = ADI::ServiceContainer::SERVICE_HASH["some_service"]["parameters"] id = parameters["id"] %} ASPEC.compile_time_assert(\{{ parameters.size == 1 }}, "Expected parameters size to be 1") ASPEC.compile_time_assert(\{{ id["declaration"].stringify == "id : Int32" }}, "Expected declaration to be id : Int32") ASPEC.compile_time_assert(\{{ id["name"] == "id" }}, "Expected name to be id") ASPEC.compile_time_assert(\{{ id["internal_name"] == "id" }}, "Expected internal_name to be id") ASPEC.compile_time_assert(\{{ id["idx"] == 0 }}, "Expected idx to be 0") ASPEC.compile_time_assert(\{{ id["restriction"].stringify == "Int32" }}, "Expected restriction to be Int32") ASPEC.compile_time_assert(\{{ id["resolved_restriction"].stringify == "Int32" }}, "Expected resolved_restriction to be Int32") ASPEC.compile_time_assert(\{{ id["default_value"].nil? }}, "Expected default_value to be nil") ASPEC.compile_time_assert(\{{ id["value"] == 123 }}, "Expected value to be 123") end end CR end it "does not override value of manually wired up parameters" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper.cr" class SomeService def initialize(@id : Int32); end end module MyExtension macro included macro finished {% verbatim do %} {% SERVICE_HASH["some_service"] = { class: SomeService, public: true, parameters: { id: {value: 999} } } %} {% end %} end end end ADI.add_compiler_pass MyExtension, :before_optimization, 1028 ADI::ServiceContainer.new macro finished macro finished ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::SERVICE_HASH["some_service"]["parameters"]["id"]["value"] == 999 }}, "Expected value to be 999") end end CR end it "does not override value of manually wired up parameters with default value" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper.cr" class SomeService def initialize(@id : Int32 = 123); end end module MyExtension macro included macro finished {% verbatim do %} {% SERVICE_HASH["some_service"] = { class: SomeService, public: true, parameters: { id: {value: 999} } } %} {% end %} end end end ADI.add_compiler_pass MyExtension, :before_optimization, 1028 ADI::ServiceContainer.new macro finished macro finished ASPEC.compile_time_assert(\{{ ADI::ServiceContainer::SERVICE_HASH["some_service"]["parameters"]["id"]["value"] == 999 }}, "Expected value to be 999") end end CR end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/proxy_spec.cr ================================================ require "../spec_helper" @[ADI::Register] class ServiceThree class_getter? instantiated : Bool = false getter value = 123 def initialize @@instantiated = true end end @[ADI::Register] class ServiceTwo getter value = 123 end @[ADI::Register] record Some::Namespace::Service @[ADI::Register(public: true)] class ServiceOne getter service_two : ADI::Proxy(ServiceTwo) getter service_three : ADI::Proxy(ServiceThree) getter namespaced_service : ADI::Proxy(Some::Namespace::Service) getter service_two_extra : ADI::Proxy(ServiceTwo) def initialize( @service_two : ADI::Proxy(ServiceTwo), @service_three : ADI::Proxy(ServiceThree), @namespaced_service : ADI::Proxy(Some::Namespace::Service), @service_two_extra : ADI::Proxy(ServiceTwo), ) end def test 1 + 1 end def run @service_three.value end end describe ADI::ServiceContainer do describe "with service proxies" do it "delays instantiation until the proxy is used" do service = ADI.container.service_one ServiceThree.instantiated?.should be_false service.test ServiceThree.instantiated?.should be_false service.run.should eq 123 ServiceThree.instantiated?.should be_true end it "exposes the service ID and type of the proxied service" do service = ADI.container.service_one service.service_two_extra.service_id.should eq "service_two" service.service_two_extra.service_type.should eq ServiceTwo service.service_two_extra.instantiated?.should be_false service.service_two_extra.value.should eq 123 service.service_two_extra.instantiated?.should be_true service.namespaced_service.service_id.should eq "some_namespace_service" service.namespaced_service.service_type.should eq Some::Namespace::Service end end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/register_services_spec.cr ================================================ require "../spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "../spec_helper.cr"), postamble: "ADI::ServiceContainer.new" end # Happy Path @[ADI::Register] class SingleService getter value : Int32 = 1 end @[ADI::Register(public: true)] class SingleClient getter service : SingleService def initialize(@service : SingleService); end end # Factories class TestFactory def self.create_factory_tuple(value : Int32) : FactoryTuple FactoryTuple.new value * 3 end def self.create_factory_service(value_provider : ValueProvider) : FactoryService FactoryService.new value_provider.valuee end end @[ADI::Register(_value: 10, public: true, factory: {TestFactory, "create_factory_tuple"})] class FactoryTuple getter value : Int32 def initialize(@value : Int32); end end @[ADI::Register(_value: 10, public: true, factory: "double")] class FactoryString getter value : Int32 def self.double(value : Int32) : self new value * 2 end def initialize(@value : Int32); end end @[ADI::Register(_value: 50, public: true)] class PseudoFactory getter value : Int32 @[ADI::Inject] def self.new_instance(value : Int32) : self new value * 2 end def initialize(@value : Int32); end end @[ADI::Register] record ValueProvider, valuee : Int32 = 10 @[ADI::Register(public: true, factory: {TestFactory, "create_factory_service"})] class FactoryService getter value : Int32 def initialize(@value : Int32); end end @[ADI::Register(_value: 99, public: true)] class InstanceInjectService getter value : Int32 def initialize(value : String) @value = value.to_i end @[ADI::Inject] def initialize(@value : Int32); end end # Calls @[ADI::Register(public: true, calls: [ {"foo"}, {"foo", {3}}, {"foo", {6}}, ])] class CallClient getter values = [] of Int32 def foo(value : Int32 = 1) @values << value end end describe ADI::ServiceContainer::RegisterServices do describe "compiler errors", tags: "compiled" do it "errors if a service has multiple ADI::Register annotations but not all of them have a name" do assert_compile_time_error "Failed to auto register services for 'Foo'. Each service must explicitly provide a name when auto registering more than one service based on the same type.", <<-CR @[ADI::Register(name: "one")] @[ADI::Register] record Foo CR end it "errors if the generic service does not have a name." do assert_compile_time_error "Failed to auto register service for 'Foo(T)'. Generic services must explicitly provide a name.", <<-CR @[ADI::Register] record Foo(T) CR end it "errors if the service is already registered" do assert_compile_time_error "Failed to auto register service for 'my_service' (MyService). It is already registered.", <<-CR @[ADI::Register] record MyService module MyExtension macro included macro finished {% verbatim do %} {% SERVICE_HASH["my_service"] = { class: MyService, } %} {% end %} end end end ADI.add_compiler_pass MyExtension, :before_optimization, 1028 CR end describe "factory" do it "errors if method is an instance method" do assert_compile_time_error "Failed to auto register service 'foo'. Factory method 'foo' within 'Foo' is an instance method.", <<-CR @[ADI::Register(factory: "foo")] record Foo do def foo; end end CR end it "errors if the method is missing" do assert_compile_time_error "Failed to auto register service 'foo'. Factory method 'foo' within 'Foo' does not exist.", <<-CR @[ADI::Register(factory: "foo")] record Foo CR end end describe "tags" do it "errors if not all tags have a `name` field" do assert_compile_time_error "Failed to register service 'foo' (Foo). Tag must have a name.", <<-CR @[ADI::Register(tags: [{priority: 100}])] record Foo CR end it "errors if not all tags are of the proper type" do assert_compile_time_error "Tag must be a 'StringLiteral' or 'NamedTupleLiteral', got 'NumberLiteral'.", <<-CR @[ADI::Register(tags: [100])] record Foo CR end end describe "calls" do it "errors if the method of a call is empty" do assert_compile_time_error "'calls' field of service 'foo': method name cannot be empty.", <<-CR @[ADI::Register(calls: [{""}])] record Foo CR end it "errors if the method does not exist on the type" do assert_compile_time_error "'calls' field of service 'foo' (Foo): method does not exist.", <<-CR @[ADI::Register(calls: [{"foo"}])] record Foo CR end end end describe "with factory based services" do it "supports passing a tuple" do ADI::ServiceContainer.new.factory_tuple.value.should eq 30 end it "supports passing the string method name" do ADI::ServiceContainer.new.factory_string.value.should eq 20 end it "supports auto resolving factory method service dependencies" do ADI::ServiceContainer.new.factory_service.value.should eq 10 end describe "with an ADI:Inject annotation" do it "on a class method" do ADI::ServiceContainer.new.pseudo_factory.value.should eq 100 end it "allows specifying which initialize method to use" do ADI::ServiceContainer.new.instance_inject_service.value.should eq 99 end end end it "correctly resolves the service" do service = ADI.container.single_client.service service.should be_a SingleService service.value.should eq 1 end it "registers calls" do ADI.container.call_client.values.should eq [1, 3, 6] end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/remove_unused_services_spec.cr ================================================ require "../spec_helper" # 1. Unused private service - will be removed @[ADI::Register] class RemoveUnusedService end # 2. Used private service - should NOT be removed @[ADI::Register] class RemoveUsedService def value 10 end end @[ADI::Register(public: true)] class RemoveUsedClient def initialize(@dep : RemoveUsedService) end def value @dep.value end end # 3. Public alias target - should NOT be removed module RemoveTestInterface; end @[ADI::Register] @[ADI::AsAlias("remove_test_alias", public: true)] class RemoveAliasedService include RemoveTestInterface def value 20 end end describe ADI::ServiceContainer::RemoveUnusedServices do it "keeps referenced private services" do ADI.container.remove_used_client.value.should eq 10 end it "keeps services with public aliases" do ADI.container.remove_test_alias.value.should eq 20 end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/resolve_parameter_placeholders_spec.cr ================================================ require "../spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "../spec_helper.cr"), postamble: "ADI::ServiceContainer.new" end describe ADI::ServiceContainer::ResolveParameterPlaceholders do describe "compiler errors", tags: "compiled" do it "errors if a parameter references another undefined placeholder." do assert_compile_time_error "Parameter 'parameters[app.name]' referenced unknown parameter 'app.version'.", <<-CR ADI.configure({ parameters: { "app.name": "Testing v%app.version%" } }) CR end it "errors if a parameter references another undefined placeholder within a hash." do assert_compile_time_error "Parameter 'parameters[app.settings][\"thing\"]' referenced unknown parameter 'app.name'.", <<-CR ADI.configure({ parameters: { "app.settings": { "thing" => "%app.name%", } } }) CR end it "errors if a parameter references another undefined placeholder within an array." do assert_compile_time_error "Parameter 'parameters[app.settings][0]' referenced unknown parameter 'app.name'.", <<-CR ADI.configure({ parameters: { "app.settings": ["%app.name%"] } }) CR end end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/resolve_values_spec.cr ================================================ require "../spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "../spec_helper.cr"), postamble: "ADI::ServiceContainer.new" end module ResolveValuePriorityInterface; end @[ADI::Register] @[ADI::AsAlias("my_string_alias")] record ServicePriorityOne do include ResolveValuePriorityInterface end @[ADI::Register] record ServicePriorityTwo do include ResolveValuePriorityInterface end @[ADI::Register] @[ADI::AsAlias(ResolveValuePriorityInterface)] record ServicePriorityFour do include ResolveValuePriorityInterface end @[ADI::Register] record ServicePriorityThree @[ADI::Register(_ann_bind: 1000, public: true)] @[ADI::Autoconfigure(bind: {ann_bind: 800, global_bind: 800, auto_configure_bind: 800})] class ValuePriorityService getter ann_bind, global_bind, auto_configure_bind, default_value, nilable_type def initialize( ann_bind : Int32, global_bind : Int32, auto_configure_bind : Int32, nilable_type : Int32?, default_value : Int32 = 700, ) ann_bind.should eq 1000 global_bind.should eq 900 auto_configure_bind.should eq 800 nilable_type.should be_nil default_value.should eq 700 end end ADI.bind ann_bind : Int32, 900 ADI.bind global_bind : Int32, 900 @[ADI::Register(_alias_overridden_by_ann_bind: "@service_priority_one", _alias_service_via_string_alias: "@my_string_alias", public: true)] @[ADI::Autoconfigure(bind: {alias_overridden_by_auto_configure_bind: "@service_priority_two"})] class ServiceValuePriorityService getter explicit_auto_wire, interface_service_matches_name, default_alias, alias_overridden_by_ann_bind def initialize( explicit_auto_wire : ServicePriorityThree, service_priority_two : ResolveValuePriorityInterface, default_alias : ResolveValuePriorityInterface, alias_overridden_by_ann_bind : ResolveValuePriorityInterface, alias_overridden_by_global_bind : ResolveValuePriorityInterface, alias_overridden_by_auto_configure_bind : ResolveValuePriorityInterface, # Validates container rewrites the alias service ID to the real underlying service ID alias_service_via_string_alias : ResolveValuePriorityInterface, ) explicit_auto_wire.should be_a ServicePriorityThree service_priority_two.should be_a ServicePriorityTwo default_alias.should be_a ServicePriorityFour alias_overridden_by_ann_bind.should be_a ServicePriorityOne alias_overridden_by_global_bind.should be_a ServicePriorityOne alias_overridden_by_auto_configure_bind.should be_a ServicePriorityTwo alias_service_via_string_alias.should be_a ServicePriorityOne end end ADI.bind alias_overridden_by_global_bind : ResolveValuePriorityInterface, "@service_priority_one" @[ADI::Register(_value: false, public: true)] record BoolExplicitArgumentService, value : Bool describe ADI::ServiceContainer::ResolveValues do describe "compiler errors", tags: "compiled" do it "errors if a service string reference doesn't map to a known service" do assert_compile_time_error "Service 'foo' (Foo) references undefined service 'bar'.", <<-CR @[ADI::Register(_id: "@bar")] record Foo, id : Int32 CR end end it "resolves the values with the expected priority" do ADI.container.value_priority_service end it "resolves service references with the expected priority" do ADI.container.service_value_priority_service end it "allows passing `false` as an argument" do ADI.container.bool_explicit_argument_service.value.should be_false end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/untyped_with_default_spec.cr ================================================ require "../spec_helper" record NotAService, id : Int32 = 1234 @[ADI::Register(public: true)] class SomeUntypedService getter service : NotAService def initialize(@service = NotAService.new); end end describe ADI::ServiceContainer do it "when the constructor arg is not typed, but has a default" do ADI::ServiceContainer.new.some_untyped_service.service.id.should eq 1234 end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/validate_arguments_spec.cr ================================================ require "../spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "../spec_helper.cr"), postamble: "ADI::ServiceContainer.new" end describe ADI::ServiceContainer::ValidateArguments, tags: "compiled" do describe "compiler errors" do it "errors if a expects a string value parameter but it is not of that type" do assert_compile_time_error "Service 'foo' (Foo): parameter expects a 'String' but got 'Int32'.", <<-'CR' @[ADI::Register(_value: 123)] record Foo, value : String CR assert_compile_time_error "Service 'foo' (Foo): parameter expects a 'String' but got 'UInt8'.", <<-'CR' @[ADI::Register(_value: 123_u8)] record Foo, value : String CR assert_compile_time_error "Service 'foo' (Foo): parameter expects a 'String' but got 'Bool'.", <<-'CR' @[ADI::Register(_value: true)] record Foo, value : String CR assert_compile_time_error "Service 'foo' (Foo): parameter expects a 'String' but got 'Float64'.", <<-'CR' @[ADI::Register(_value: 3.14)] record Foo, value : String CR assert_compile_time_error "Service 'foo' (Foo): parameter expects a 'String' but got 'Symbol'.", <<-'CR' @[ADI::Register(_value: :foo)] record Foo, value : String CR end it "still errors with explicit calls even if they are not of the proper type" do assert_compile_time_error "expected argument 'value' to 'Foo.new' to be String, not Int32", <<-'CR' @[ADI::Register(_value: "123".to_i, public: true)] record Foo, value : String ADI.container.foo CR end it "errors if a parameter resolves to a service of the incorrect type" do assert_compile_time_error "Service 'foo' (Foo): parameter expects 'Int32' but the resolved service 'bar' is of type 'Bar'.", <<-'CR' @[ADI::Register] record Bar @[ADI::Register(_value: "@bar", public: true)] record Foo, value : Int32 CR end describe NamedTuple do it "errors if configuration is missing a non-nilable property" do assert_compile_time_error "Configuration value 'test.connection' is missing required value for 'port' of type 'Int32'.", <<-'CR' module Schema include ADI::Extension::Schema property connection : NamedTuple(hostname: String, username: String, password: String, port: Int32) end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { hostname: "my-db", username: "user", password: "pass", }, }, }) CR end it "errors if there is a type mismatch" do assert_compile_time_error "Expected configuration value 'test.connection.hostname' to be a 'String', but got 'Int32'.", <<-'CR' module Schema include ADI::Extension::Schema property connection : NamedTuple(hostname: String) end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { hostname: 10, }, }, }) CR end it "errors if there is a type mismatch within an array type" do assert_compile_time_error "Expected configuration value 'test.connection.ports[1]' to be a 'Int32', but got 'String'.", <<-'CR' module Schema include ADI::Extension::Schema property connection : NamedTuple(ports: Array(Int32)) end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { ports: [ 10, "blah" ] }, }, }) CR end it "errors if there is a type mismatch within a nilable array type" do assert_compile_time_error "Expected configuration value 'test.connection.ports[1]' to be a 'Int32', but got 'String'.", <<-'CR' module Schema include ADI::Extension::Schema property connection : NamedTuple(ports: Array(Int32)?) end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { ports: [ 10, "blah" ] }, }, }) CR end end describe "array_of" do it "errors on type mismatch in array within array_of object" do assert_compile_time_error "Expected configuration value 'test.rules[0].priorities[2]' to be a 'String', but got 'Int32'.", <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema array_of rules, priorities : Array(String)? = nil end ADI.register_extension "test", Schema ADI.configure({ test: { rules: [ {priorities: ["json", "xml", 2]}, ], }, }) CR end end describe "object_of" do it "errors on type mismatch in array within object_of object" do assert_compile_time_error "Expected configuration value 'test.rule.priorities[2]' to be a 'String', but got 'Int32'.", <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema object_of rule, priorities : Array(String)? = nil end ADI.register_extension "test", Schema ADI.configure({ test: { rule: {priorities: ["json", "xml", 2]}, }, }) CR end end end it "sets missing NT keys to `nil` if the type is nilable" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema property connection : NamedTuple(hostname: String, username: String, password: String, port: Int32?) end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { hostname: "my-db", username: "user", password: "pass", }, }, }) macro finished macro finished ASPEC.compile_time_assert(\{{ ADI::CONFIG["test"]["connection"]["port"].nil? }}, "Expected port to be nil") end end CR end it "properly checks type within array of array_of object" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema array_of rules, priorities : Array(String)? = nil end ADI.register_extension "test", Schema ADI.configure({ test: { rules: [ {priorities: ["json", "xml"]}, ], }, }) CR end it "properly checks type within array of object_of object" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" module Schema include ADI::Extension::Schema object_of rule, priorities : Array(String)? = nil end ADI.register_extension "test", Schema ADI.configure({ test: { rule: {priorities: ["json", "xml"]}, }, }) CR end it "allows calls to `String` parameters" do ASPEC::Methods.assert_compiles <<-'CR' require "../spec_helper" @[ADI::Register(_value: 123.to_s, public: true)] record Foo, value : String ADI.container.foo CR end end ================================================ FILE: src/components/dependency_injection/spec/compiler_passes/validate_generics_spec.cr ================================================ require "../spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "../spec_helper.cr"), postamble: "ADI::ServiceContainer.new" end @[ADI::Register(Int32, Bool, public: true, name: "int_service")] @[ADI::Register(Float64, Bool, public: true, name: "float_service")] struct GenericServiceBase(T, B) def type {T, B} end end describe ADI::ServiceContainer::ValidateGenerics do describe "compiler errors", tags: "compiled" do it "errors if the generic service does not provide the generic arguments." do assert_compile_time_error "Failed to register service 'foo_service'. Generic services must provide the types to use via the 'generics' field.", <<-CR @[ADI::Register(name: "foo_service")] record Foo(T) CR end it "errors if there is a generic argument count mismatch." do assert_compile_time_error "Failed to register service 'foo_service'. Expected 1 generics types got 2.", <<-CR @[ADI::Register(String, Bool, name: "foo_service")] record Foo(T) CR end end it "correctly initializes the service with the given generic arguments" do ADI.container.int_service.type.should eq({Int32, Bool}) ADI.container.float_service.type.should eq({Float64, Bool}) end end ================================================ FILE: src/components/dependency_injection/spec/extension_spec.cr ================================================ require "./spec_helper" private def assert_compiles(code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compiles code, line: line, preamble: %(require "./spec_helper.cr") end private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "./spec_helper.cr"), postamble: "ADI::ServiceContainer.new" end describe ADI::Extension, tags: "compiled" do it "happy path" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema property id : Int32 property name : String = "Fred" end ADI.register_extension "test", Schema ADI.configure({ test: { id: 10, }, }) macro finished macro finished \{% options = Schema::OPTIONS %} ASPEC.compile_time_assert(\{{ options.size == 2 }}, "Expected options size to be 2") ASPEC.compile_time_assert(\{{ options[0]["name"] == "id" }}, "Expected first option name to be id") ASPEC.compile_time_assert(\{{ options[0]["type"] == Int32 }}, "Expected first option type to be Int32") ASPEC.compile_time_assert(\{{ options[0]["default"].nil? }}, "Expected first option default to be nil") ASPEC.compile_time_assert(\{{ options[1]["name"] == "name" }}, "Expected second option name to be name") ASPEC.compile_time_assert(\{{ options[1]["type"] == String }}, "Expected second option type to be String") ASPEC.compile_time_assert(\{{ options[1]["default"] == "Fred" }}, "Expected second option default to be Fred") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"id","type":"`Int32`","default":"``"}, {"name":"name","type":"`String`","default":"`Fred`"}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end it "allows using NoReturn array default to inherit type of the array" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema property values : Array(Int32 | String) = [] of NoReturn end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ options[0]["name"] == "values" }}, "Expected option name to be values") ASPEC.compile_time_assert(\{{ options[0]["type"] == Array(Int32 | String) }}, "Expected option type to be Array(Int32 | String)") ASPEC.compile_time_assert(\{{ options[0]["default"].stringify == "Array(Int32 | String).new" }}, "Expected option default to be Array(Int32 | String).new") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"values","type":"`Array(Int32 | String)`","default":"`Array(Int32 | String).new`"}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end describe "object_of / object_of?" do it "is able to resolve parameters from the object value" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema object_of connection, username : String, password : String, port : Int32 = 1234 end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { username: "%app.username%", password: "abc123", }, }, parameters: { "app.username": "addminn", }, }) macro finished macro finished \{% config = ADI::CONFIG["test"] %} ASPEC.compile_time_assert(\{{ config["connection"]["username"] == "addminn" }}, "Expected connection username to be addminn") ASPEC.compile_time_assert(\{{ config["connection"]["password"] == "abc123" }}, "Expected connection password to be abc123") ASPEC.compile_time_assert(\{{ config["connection"]["port"] == 1234 }}, "Expected connection port to be 1234") end end CR end it "errors if a required configuration value has not been provided" do assert_compile_time_error "Configuration value 'test.connection' is missing required value for 'port' of type 'Int32'.", <<-'CR' module Schema include ADI::Extension::Schema object_of connection, username : String, password : String, port : Int32 end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { username: "admin", password: "abc123", }, }, }) CR end it "errors if a configuration value has been provided a value of the wrong type" do assert_compile_time_error "Expected configuration value 'test.connection.port' to be a 'Int32', but got 'Bool'.", <<-'CR' module Schema include ADI::Extension::Schema object_of connection, username : String, password : String, port : Int32 end ADI.register_extension "test", Schema ADI.configure({ test: { connection: { username: "admin", password: "abc123", port: false, }, }, }) CR end it "object_of" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema object_of rule, id : Int32, stop : Bool = false end ADI.register_extension "test", Schema ADI.configure({ test: { rule: { id: 10 }, }, }) macro finished macro finished \{% options = Schema::OPTIONS members = options[0]["members"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ options[0]["name"] == "rule" }}, "Expected option name to be rule") ASPEC.compile_time_assert(\{{ options[0]["type"].stringify == "NamedTuple(T)" }}, "Expected option type to be NamedTuple(T)") ASPEC.compile_time_assert(\{{ options[0]["default"].nil? }}, "Expected option default to be nil") ASPEC.compile_time_assert(\{{ members.size == 3 }}, "Expected members size to be 3") # Account for __nil ASPEC.compile_time_assert(\{{ members["id"].type.stringify == "Int32" }}, "Expected id type to be Int32") ASPEC.compile_time_assert(\{{ members["id"].value.nil? }}, "Expected id value to be nil") ASPEC.compile_time_assert(\{{ members["stop"].type.stringify == "Bool" }}, "Expected stop type to be Bool") ASPEC.compile_time_assert(\{{ members["stop"].value == false }}, "Expected stop value to be false") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"rule","type":"`NamedTuple(T)`","default":"``","members":[{"name":"id","type":"`Int32`","default":"``","doc":""},{"name":"stop","type":"`Bool`","default":"`false`","doc":""}]}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end it "object_of with assign" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema object_of rule = {id: 999}, id : Int32, stop : Bool = false end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS members = options[0]["members"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ options[0]["name"] == "rule" }}, "Expected option name to be rule") ASPEC.compile_time_assert(\{{ options[0]["type"].stringify == "NamedTuple(T)" }}, "Expected option type to be NamedTuple(T)") ASPEC.compile_time_assert(\{{ options[0]["default"] == {id: 999, stop: false} }}, "Expected option default to match") ASPEC.compile_time_assert(\{{ members.size == 3 }}, "Expected members size to be 3") # Account for __nil ASPEC.compile_time_assert(\{{ members["id"].type.stringify == "Int32" }}, "Expected id type to be Int32") ASPEC.compile_time_assert(\{{ members["id"].value.nil? }}, "Expected id value to be nil") ASPEC.compile_time_assert(\{{ members["stop"].type.stringify == "Bool" }}, "Expected stop type to be Bool") ASPEC.compile_time_assert(\{{ members["stop"].value == false }}, "Expected stop value to be false") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"rule","type":"`NamedTuple(T)`","default":"`{id: 999}`","members":[{"name":"id","type":"`Int32`","default":"``","doc":""},{"name":"stop","type":"`Bool`","default":"`false`","doc":""}]}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end it "object_of?" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema object_of? rule, id : Int32, stop : Bool = false end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS members = options[0]["members"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ options[0]["name"] == "rule" }}, "Expected option name to be rule") ASPEC.compile_time_assert(\{{ options[0]["type"].stringify == "(NamedTuple(T) | Nil)" }}, "Expected option type to be (NamedTuple(T) | Nil)") ASPEC.compile_time_assert(\{{ options[0]["default"].nil? }}, "Expected option default to be nil") ASPEC.compile_time_assert(\{{ members.size == 3 }}, "Expected members size to be 3") # Account for __nil ASPEC.compile_time_assert(\{{ members["id"].type.stringify == "Int32" }}, "Expected id type to be Int32") ASPEC.compile_time_assert(\{{ members["id"].value.nil? }}, "Expected id value to be nil") ASPEC.compile_time_assert(\{{ members["stop"].type.stringify == "Bool" }}, "Expected stop type to be Bool") ASPEC.compile_time_assert(\{{ members["stop"].value == false }}, "Expected stop value to be false") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"rule","type":"`(NamedTuple(T) | Nil)`","default":"`nil`","members":[{"name":"id","type":"`Int32`","default":"``","doc":""},{"name":"stop","type":"`Bool`","default":"`false`","doc":""}]}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end it "object_of? with assign" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema object_of? rule = {id: 999}, id : Int32, stop : Bool = false end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS members = options[0]["members"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ options[0]["name"] == "rule" }}, "Expected option name to be rule") ASPEC.compile_time_assert(\{{ options[0]["type"].stringify == "(NamedTuple(T) | Nil)" }}, "Expected option type to be (NamedTuple(T) | Nil)") ASPEC.compile_time_assert(\{{ options[0]["default"].nil? }}, "Expected option default to be nil") ASPEC.compile_time_assert(\{{ members.size == 3 }}, "Expected members size to be 3") # Account for __nil ASPEC.compile_time_assert(\{{ members["id"].type.stringify == "Int32" }}, "Expected id type to be Int32") ASPEC.compile_time_assert(\{{ members["id"].value.nil? }}, "Expected id value to be nil") ASPEC.compile_time_assert(\{{ members["stop"].type.stringify == "Bool" }}, "Expected stop type to be Bool") ASPEC.compile_time_assert(\{{ members["stop"].value == false }}, "Expected stop value to be false") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"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) }}, "Expected CONFIG_DOCS to match") end end CR end end describe "array_of / array_of?" do it "array_of" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema array_of rules, id : Int32, stop : Bool = false end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS members = options[0]["members"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ options[0]["name"] == "rules" }}, "Expected option name to be rules") ASPEC.compile_time_assert(\{{ options[0]["type"].stringify == "Array(T)" }}, "Expected option type to be Array(T)") ASPEC.compile_time_assert(\{{ options[0]["default"].stringify == "[]" }}, "Expected option default to be empty array") ASPEC.compile_time_assert(\{{ members.size == 3 }}, "Expected members size to be 3") # Account for __nil ASPEC.compile_time_assert(\{{ members["id"].type.stringify == "Int32" }}, "Expected id type to be Int32") ASPEC.compile_time_assert(\{{ members["id"].value.nil? }}, "Expected id value to be nil") ASPEC.compile_time_assert(\{{ members["stop"].type.stringify == "Bool" }}, "Expected stop type to be Bool") ASPEC.compile_time_assert(\{{ members["stop"].value == false }}, "Expected stop value to be false") end end CR end it "array_of with assign" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema array_of rules = [{id: 10}], id : Int32, stop : Bool = false end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS members = options[0]["members"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ options[0]["name"] == "rules" }}, "Expected option name to be rules") ASPEC.compile_time_assert(\{{ options[0]["type"].stringify == "Array(T)" }}, "Expected option type to be Array(T)") ASPEC.compile_time_assert(\{{ options[0]["default"] == [{id: 10, stop: false}] }}, "Expected option default to match") ASPEC.compile_time_assert(\{{ members.size == 3 }}, "Expected members size to be 3") # Account for __nil ASPEC.compile_time_assert(\{{ members["id"].type.stringify == "Int32" }}, "Expected id type to be Int32") ASPEC.compile_time_assert(\{{ members["id"].value.nil? }}, "Expected id value to be nil") ASPEC.compile_time_assert(\{{ members["stop"].type.stringify == "Bool" }}, "Expected stop type to be Bool") ASPEC.compile_time_assert(\{{ members["stop"].value == false }}, "Expected stop value to be false") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"rules","type":"`Array(T)`","default":"`[{id: 10}]`","members":[{"name":"id","type":"`Int32`","default":"``","doc":""},{"name":"stop","type":"`Bool`","default":"`false`","doc":""}]}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end it "array_of?" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema array_of? rules, id : Int32, stop : Bool = false end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS members = options[0]["members"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ options[0]["name"] == "rules" }}, "Expected option name to be rules") ASPEC.compile_time_assert(\{{ options[0]["type"].stringify == "(Array(T) | Nil)" }}, "Expected option type to be (Array(T) | Nil)") ASPEC.compile_time_assert(\{{ options[0]["default"].nil? }}, "Expected option default to be nil") ASPEC.compile_time_assert(\{{ members.size == 3 }}, "Expected members size to be 3") # Account for __nil ASPEC.compile_time_assert(\{{ members["id"].type.stringify == "Int32" }}, "Expected id type to be Int32") ASPEC.compile_time_assert(\{{ members["id"].value.nil? }}, "Expected id value to be nil") ASPEC.compile_time_assert(\{{ members["stop"].type.stringify == "Bool" }}, "Expected stop type to be Bool") ASPEC.compile_time_assert(\{{ members["stop"].value == false }}, "Expected stop value to be false") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"rules","type":"`(Array(T) | Nil)`","default":"`nil`","members":[{"name":"id","type":"`Int32`","default":"``","doc":""},{"name":"stop","type":"`Bool`","default":"`false`","doc":""}]}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end end describe "object_schema" do it "stores schema in OBJECT_SCHEMAS" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : String = "hmac.sha256" end ADI.register_extension "test", Schema macro finished macro finished \{% schemas = Schema::OBJECT_SCHEMAS jwt_schema = schemas["JwtConfig"] %} ASPEC.compile_time_assert(\{{ schemas.size == 1 }}, "Expected schemas size to be 1") ASPEC.compile_time_assert(\{{ schemas["JwtConfig"] != nil }}, "Expected JwtConfig schema to exist") ASPEC.compile_time_assert(\{{ jwt_schema["members"].size == 3 }}, "Expected jwt_schema members size to be 3") # Account for __nil ASPEC.compile_time_assert(\{{ jwt_schema["members"]["secret"].type.stringify == "String" }}, "Expected secret type to be String") ASPEC.compile_time_assert(\{{ jwt_schema["members"]["secret"].value.nil? }}, "Expected secret value to be nil") ASPEC.compile_time_assert(\{{ jwt_schema["members"]["algorithm"].type.stringify == "String" }}, "Expected algorithm type to be String") ASPEC.compile_time_assert(\{{ jwt_schema["members"]["algorithm"].value == "hmac.sha256" }}, "Expected algorithm value to be hmac.sha256") end end CR end it "supports nested object_schema references" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema object_schema InnerConfig, value : String object_schema OuterConfig, name : String, inner : InnerConfig end ADI.register_extension "test", Schema macro finished macro finished \{% schemas = Schema::OBJECT_SCHEMAS outer_schema = schemas["OuterConfig"] inner_member = outer_schema["members"]["inner"] %} # The inner member should have nested members from InnerConfig ASPEC.compile_time_assert(\{{ inner_member["members"] != nil }}, "Expected inner member to have members") ASPEC.compile_time_assert(\{{ inner_member["members"]["value"].type.stringify == "String" }}, "Expected inner value type to be String") # OuterConfig's members_string should include InnerConfig's nested members ASPEC.compile_time_assert(\{{ outer_schema["members_string"] == %([{"name":"name","type":"`String`","default":"``","doc":""},{"name":"inner","type":"`InnerConfig`","default":"``","doc":"","members":[{"name":"value","type":"`String`","default":"``","doc":""}]}]) }}, "Expected members_string to match") end end CR end end describe "map_of / map_of?" do it "map_of" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema map_of hubs, url : String, port : Int32 = 5432 end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS members = options[0]["members"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ options[0]["name"] == "hubs" }}, "Expected option name to be hubs") ASPEC.compile_time_assert(\{{ options[0]["type"].stringify == "Hash(K, V)" }}, "Expected option type to be Hash(K, V)") ASPEC.compile_time_assert(\{{ options[0]["default"].stringify == "{__nil: nil}" }}, "Expected option default to be {__nil: nil}") ASPEC.compile_time_assert(\{{ members.size == 3 }}, "Expected members size to be 3") # Account for __nil ASPEC.compile_time_assert(\{{ members["url"].type.stringify == "String" }}, "Expected url type to be String") ASPEC.compile_time_assert(\{{ members["url"].value.nil? }}, "Expected url value to be nil") ASPEC.compile_time_assert(\{{ members["port"].type.stringify == "Int32" }}, "Expected port type to be Int32") ASPEC.compile_time_assert(\{{ members["port"].value == 5432 }}, "Expected port value to be 5432") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"hubs","type":"`Hash(K, V)`","default":"`{__nil: nil}`","members":[{"name":"url","type":"`String`","default":"``","doc":""},{"name":"port","type":"`Int32`","default":"`5432`","doc":""}]}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end it "map_of?" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema map_of? hubs, url : String, port : Int32 = 5432 end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS members = options[0]["members"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ options[0]["name"] == "hubs" }}, "Expected option name to be hubs") ASPEC.compile_time_assert(\{{ options[0]["type"].stringify == "(Hash(K, V) | Nil)" }}, "Expected option type to be (Hash(K, V) | Nil)") ASPEC.compile_time_assert(\{{ options[0]["default"].nil? }}, "Expected option default to be nil") ASPEC.compile_time_assert(\{{ members.size == 3 }}, "Expected members size to be 3") # Account for __nil ASPEC.compile_time_assert(\{{ members["url"].type.stringify == "String" }}, "Expected url type to be String") ASPEC.compile_time_assert(\{{ members["port"].type.stringify == "Int32" }}, "Expected port type to be Int32") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"hubs","type":"`(Hash(K, V) | Nil)`","default":"`nil`","members":[{"name":"url","type":"`String`","default":"``","doc":""},{"name":"port","type":"`Int32`","default":"`5432`","doc":""}]}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end it "map_of with object_schema reference" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : String = "hmac.sha256" map_of hubs, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS jwt_member = options[0]["members"]["jwt"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ jwt_member["members"] != nil }}, "Expected jwt member to have members") ASPEC.compile_time_assert(\{{ jwt_member["members"]["secret"].type.stringify == "String" }}, "Expected secret type to be String") ASPEC.compile_time_assert(\{{ jwt_member["members"]["algorithm"].value == "hmac.sha256" }}, "Expected algorithm value to be hmac.sha256") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"hubs","type":"`Hash(K, V)`","default":"`{__nil: nil}`","members":[{"name":"url","type":"`String`","default":"``","doc":""},{"name":"jwt","type":"`JwtConfig`","default":"``","doc":"","members":[{"name":"secret","type":"`String`","default":"``","doc":""},{"name":"algorithm","type":"`String`","default":"`hmac.sha256`","doc":""}]}]}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end it "map_of with custom default using assignment syntax" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema map_of hubs = {default: {url: "localhost", port: 8080}}, url : String, port : Int32 = 5432 end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS default = options[0]["default"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ options[0]["name"] == "hubs" }}, "Expected option name to be hubs") ASPEC.compile_time_assert(\{{ options[0]["type"].stringify == "Hash(K, V)" }}, "Expected option type to be Hash(K, V)") # Custom default should be preserved ASPEC.compile_time_assert(\{{ default["default"]["url"] == "localhost" }}, "Expected default url to be localhost") ASPEC.compile_time_assert(\{{ default["default"]["port"] == 8080 }}, "Expected default port to be 8080") end end CR end end describe "object_schema in array_of" do it "array_of with object_schema reference" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : String = "hmac.sha256" array_of items, name : String, jwt : JwtConfig end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS jwt_member = options[0]["members"]["jwt"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ jwt_member["members"] != nil }}, "Expected jwt member to have members") ASPEC.compile_time_assert(\{{ jwt_member["members"]["secret"].type.stringify == "String" }}, "Expected secret type to be String") ASPEC.compile_time_assert(\{{ jwt_member["members"]["algorithm"].value == "hmac.sha256" }}, "Expected algorithm value to be hmac.sha256") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"items","type":"`Array(T)`","default":"`[]`","members":[{"name":"name","type":"`String`","default":"``","doc":""},{"name":"jwt","type":"`JwtConfig`","default":"``","doc":"","members":[{"name":"secret","type":"`String`","default":"``","doc":""},{"name":"algorithm","type":"`String`","default":"`hmac.sha256`","doc":""}]}]}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end end describe "object_schema in object_of" do it "object_of with object_schema reference" do assert_compiles <<-'CR' module Schema include ADI::Extension::Schema object_schema JwtConfig, secret : String, algorithm : String = "hmac.sha256" # Use object_of? since we're not providing config - just testing OPTIONS structure object_of? connection, url : String, jwt : JwtConfig end ADI.register_extension "test", Schema macro finished macro finished \{% options = Schema::OPTIONS jwt_member = options[0]["members"]["jwt"] %} ASPEC.compile_time_assert(\{{ options.size == 1 }}, "Expected options size to be 1") ASPEC.compile_time_assert(\{{ jwt_member["members"] != nil }}, "Expected jwt member to have members") ASPEC.compile_time_assert(\{{ jwt_member["members"]["secret"].type.stringify == "String" }}, "Expected secret type to be String") ASPEC.compile_time_assert(\{{ jwt_member["members"]["algorithm"].value == "hmac.sha256" }}, "Expected algorithm value to be hmac.sha256") ASPEC.compile_time_assert(\{{ Schema::CONFIG_DOCS.stringify == %([{"name":"connection","type":"`(NamedTuple(T) | Nil)`","default":"`nil`","members":[{"name":"url","type":"`String`","default":"``","doc":""},{"name":"jwt","type":"`JwtConfig`","default":"``","doc":"","members":[{"name":"secret","type":"`String`","default":"``","doc":""},{"name":"algorithm","type":"`String`","default":"`hmac.sha256`","doc":""}]}]}] of Nil) }}, "Expected CONFIG_DOCS to match") end end CR end end end ================================================ FILE: src/components/dependency_injection/spec/spec_helper.cr ================================================ require "spec" require "../src/athena-dependency_injection" require "athena-spec" require "../src/spec" ADI.configure({ parameters: { "app.mapping": { 10 => "%app.domain%", 20 => "%app.placeholder%", # Resolves recursively out of order }, "app.nested_mapping": { "string" => "%app.domain%", "array" => "%app.array%", "nested_array" => "%app.nested_array%", "bool" => "%app.enable_v2_protocol%", "escaped" => "foo%%bar", # Escape `%` in hash }, "app.nested_array": [ "%app.array%", "%app.domain%", "foo%%bar", # Escape `%` in array ], "app.array": [ "%app.domain%", "%app.placeholder%", "%app.with_percent%", "%app.with_percent_placeholder%", ], "app.domain": "google.com", "app.with_percent": "foo%%bar", # Escape `%` "app.with_percent_placeholder": "https://%app.domain%/path/t%%o/thing", "app.enable_v2_protocol": false, "app.full_url": "Visit: %app.placeholder%!", # String that contains a placeholder to a yet to be defined parameter that'll need re-processed "app.placeholder": "https://%app.domain%/path/to/thing", "app.placeholders": "https://%app.domain%/path/to/%app.enable_v2_protocol%", "app.empty": "", "app.empty.reference": "%app.empty%", "app.empty.reference.nested": "%app.empty.reference%", }, }) ================================================ FILE: src/components/dependency_injection/spec/spec_spec.cr ================================================ require "./spec_helper" module TransformerInterface abstract def transform end class FakeTransformer include TransformerInterface def transform end end class ADI::Spec::MockableServiceContainer property reverse_transformer : TransformerInterface? end describe ADI::Spec::MockableServiceContainer do it "allows mocking services" do mock_container = ADI::Spec::MockableServiceContainer.new mock_container.reverse_transformer = FakeTransformer.new mock_container.reverse_transformer.should be_a FakeTransformer end end ================================================ FILE: src/components/dependency_injection/src/abstract_bundle.cr ================================================ module Athena::DependencyInjection # :nodoc: abstract struct AbstractBundle PASSES = [] of Nil end # Registers the provided *bundle*. # # See the [Getting Started](/getting_started/configuration) docs for more information. macro register_bundle(bundle) {% resolved_bundle = bundle.resolve unless resolved_bundle <= Athena::DependencyInjection::AbstractBundle bundle.raise "The provided bundle '#{bundle}' be inherit from 'ADI::AbstractBundle'." end ann = resolved_bundle.annotation Athena::DependencyInjection::Bundle unless name = ann[0] || ann["name"] bundle.raise "Unable to determine extension name. It was not provided as the first positional argument nor via the 'name' field." end %} ADI.register_extension {{name}}, {{"#{bundle.resolve.id}::Schema".id}} ADI.add_compiler_pass {{"#{bundle.resolve.id}::Extension".id}}, :before_optimization, 1028 {% for pass in resolved_bundle.constant("PASSES") %} ADI.add_compiler_pass {{pass.splat}} {% end %} end end ================================================ FILE: src/components/dependency_injection/src/annotation_configurations.cr ================================================ module Athena::DependencyInjection # :nodoc: CUSTOM_ANNOTATIONS = [] of Nil # Registers a configuration annotation with the provided *name*. # Defines a configuration record with the provided *args*, if any, that represents the possible arguments that the annotation accepts. # May also be used with a block to add custom methods to the configuration record. # # ### Example # # ``` # # Defines an annotation without any arguments. # ADI.configuration_annotation Secure # # # Defines annotation with a required and optional argument. # # The default value will be used if that key isn't supplied in the annotation. # ADI.configuration_annotation SomeAnn, id : Int32, debug : Bool = true # # # A block can be used to define custom methods on the configuration object. # ADI.configuration_annotation CustomAnn, first_name : String, last_name : String do # def name : String # "#{@first_name} #{@last_name}" # end # end # ``` # # NOTE: The logic to actually do the resolution of the annotations must be handled in the owning shard. # `Athena::DependencyInjection` only defines the common logic that each implementation can use. # See `ADI::AnnotationConfigurations` for more information. macro configuration_annotation(name, *args, &) annotation {{name.id}}; end # :nodoc: record {{name.id}}Configuration < ADI::AnnotationConfigurations::ConfigurationBase{% unless args.empty? %}, {{args.splat}}{% end %} do {{yield}} end {% CUSTOM_ANNOTATIONS << name %} end # Wraps a hash of configuration annotations applied to a given type, method, or instance variable. # Provides the logic to access each annotation's configuration in a type safe manner. # # Implementations using this type must define the logic to provide the annotation hash manually; # this would most likely just be something like: # # ``` # # Define a hash to store the configurations. # {% custom_configurations = {} of Nil => Nil %} # # # Iterate over the stored annotation classes. # {% for ann_class in ADI::CUSTOM_ANNOTATIONS %} # {% ann_class = ann_class.resolve %} # # # Define an array to store the annotation configurations of this type. # {% annotations = [] of Nil %} # # # Iterate over each annotation of this type on the given type, method, or instance variable. # {% for ann in type_method_instance_variable.annotations ann_class %} # # Add a new instance of the annotations configuration to the array. # # Add the annotation's positional arguments first, if any, then named arguments. # {% annotations << "#{ann_class}Configuration.new(#{ann.args.empty? ? "".id : "#{ann.args.splat},".id}#{ann.named_args.double_splat})".id %} # {% end %} # # # Update the configuration hash with the annotation class and configuration objects, but only if there was at least one. # {% custom_configurations[ann_class] = "(#{annotations} of ADI::AnnotationConfigurations::ConfigurationBase)".id unless annotations.empty? %} # {% end %} # # # ... # # # Use the built hash to instantiate a new `ADI::AnnotationConfigurations` instance. # ADI::AnnotationConfigurations.new({{custom_configurations}} of ADI::AnnotationConfigurations::Classes => Array(ADI::AnnotationConfigurations::ConfigurationBase)), # ``` # # TODO: Centralize the hash resolution logic once [this issue](https://github.com/crystal-lang/crystal/issues/8835) is resolved. struct AnnotationConfigurations # :inherit: def inspect(io : IO) : Nil io << "#" end # Base type of annotation configuration objects registered via `Athena::DependencyInjection.configuration_annotation`. abstract struct ConfigurationBase; end # :nodoc: # # Used to type the `#annotation_hash` when there are no user defined annotations. annotation Placeholder; end macro finished # A union representing the possible annotation classes that could be applied to a type, method, or instance variable. alias Classes = {{%(Union(#{ADI::CUSTOM_ANNOTATIONS.empty? ? "Placeholder.class".id : ADI::CUSTOM_ANNOTATIONS.map { |t| "#{t}.class".id }.splat})).id}} # The Hash type that will store the annotation configurations. alias AnnotationHash = Hash(ADI::AnnotationConfigurations::Classes, Array(ADI::AnnotationConfigurations::ConfigurationBase)) def initialize(@annotation_hash : AnnotationHash = AnnotationHash.new); end {% for ann_class in ADI::CUSTOM_ANNOTATIONS %} # Returns the `{{ann_class}}` configuration instance for the provided *ann_class* at the provided *index*. # # Returns the last configuration instance by default. def [](ann_class : {{ann_class}}.class, index : Int32 = -1) : {{ann_class}}Configuration self.[]?(ann_class, index) || raise KeyError.new "No annotations of type '#{ann_class}' were found." end # Returns the `{{ann_class}}` configuration instance for the provided *ann_class* at the provided *index*, # or `nil` if no annotations of that type were found. # # Returns the last configuration instance by default. def []?(ann_class : {{ann_class}}.class, index : Int32 = -1) : {{ann_class}}Configuration? @annotation_hash[ann_class]?.try(&.[index]).as {{ann_class}}Configuration? end # Returns an array of `{{ann_class}}` configuration instances for the provided *ann_class*. def fetch_all(ann_class : {{ann_class}}.class) : Array(ADI::AnnotationConfigurations::ConfigurationBase) @annotation_hash[ann_class]? || Array(ADI::AnnotationConfigurations::ConfigurationBase).new end {% end %} # Returns `true` if there are annotations of the provided *ann_class*, otherwise `false`. def has?(ann_class : ADI::AnnotationConfigurations::Classes) : Bool @annotation_hash.has_key? ann_class end end end end ================================================ FILE: src/components/dependency_injection/src/annotations.cr ================================================ module Athena::DependencyInjection # Allows defining an alternative name to identify a service. # This helps solve two primary use cases: # # 1. Defining a default service to use when a parameter is typed as an interface # 1. Decoupling a service from its ID to more easily allow customizing it. # # ### Default Service # # This annotation may be applied to a service that includes one or more interface(s). # The annotation can then be provided the interface to alias as the first positional argument. # If the service only includes one interface (module ending with `Interface`), the annotation argument can be omitted. # Multiple annotations may be applied if it includes more than one. # # ``` # module SomeInterface; end # # module OtherInterface; end # # module BlahInterface; end # # # `Foo` is implicitly aliased to `SomeInterface` since it only includes the one. # @[ADI::Register] # @[ADI::AsAlias] # SomeInterface is assumed # class Foo # include SomeInterface # end # # # Alias `Bar` to both included interfaces. # @[ADI::Register] # @[ADI::AsAlias(BlahInterface)] # @[ADI::AsAlias(OtherInterface)] # class Bar # include BlahInterface # include OtherInterface # end # ``` # # In this example, anytime a parameter restriction for `SomeInterface` is encountered, `Foo` will be injected. # Similarly, anytime a parameter restriction of `BlahInterface` or `OtherInterface` is encountered, `Bar` will be injected. # This can be especially useful for when you want to define a default service to use when there are multiple implementations of an interface. # # ### String Keys # # The use case for string keys is you can do something like this: # # ``` # @[ADI::Register(name: "default_service")] # @[ADI::AsAlias("my_service")] # class SomeService # end # ``` # The idea being, have a service with an internal `default_service` id, but alias it to a more general `my_service` id. # Dependencies could then be wired up to depend upon the `"@my_service"` implementation. # This enabled the user/other logic to override the `my_service` alias to their own implementation (assuming it implements same API/interface(s)). # This should allow everything to propagate and use the custom type without having to touch the original `default_service`. # # ### Named Aliases # # When multiple implementations of an interface need to be injected into the same service, # the `name` parameter specifies which constructor parameter should receive which implementation. # The name matches the constructor parameter name. # # ``` # module LoggerInterface; end # # @[ADI::Register] # @[ADI::AsAlias(LoggerInterface, name: "file_logger")] # class FileLogger # include LoggerInterface # end # # @[ADI::Register] # @[ADI::AsAlias(LoggerInterface, name: "console_logger")] # class ConsoleLogger # include LoggerInterface # end # # @[ADI::Register(public: true)] # class MyService # # file_logger -> FileLogger, console_logger -> ConsoleLogger # def initialize(@file_logger : LoggerInterface, @console_logger : LoggerInterface) # end # end # ``` # # Named aliases take precedence over type-only aliases. A type-only alias can still be defined # as a fallback for parameters whose names don't match any named alias. # # NOTE: Named aliases cannot be accessed directly via `container.get(Interface)`. # Only type-only aliases support the `public` parameter for direct container access. annotation AsAlias; end # Applies the provided configuration to any registered service of the type the annotation is applied to. # E.g. a module interface, or a parent type. # # The following values may be auto-configured: # # * `tags : Array(String | NamedTuple(name: String, priority: Int32?))` - The [tags](/DependencyInjection/Register/#Athena::DependencyInjection::Register--tagging-services) to apply. # * `calls : Array(Tuple(String, Tuple(T)))` - [Service calls](/DependencyInjection/Register/#Athena::DependencyInjection::Register--service-calls) that should be made on the service after its instantiated. # * `bind : NamedTuple(*)` - A named tuple of values that should be available to the constructors # * `public : Bool` - If the services should be accessible directly from the container # * `constructor : String` - Name of a class method to use as the [service factory](/DependencyInjection/Register/#Athena::DependencyInjection::Register--factories) # # TIP: Checkout `ADI::AutoconfigureTag` and `ADI::TaggedIterator` for a simpler way of handling tags. # # ### Example # # ``` # @[ADI::Autoconfigure(bind: {id: 123}, public: true)] # module SomeInterface; end # # @[ADI::Register] # record One do # include SomeInterface # end # # @[ADI::Register] # record Two, id : Int32 do # include SomeInterface # end # # # The services are only accessible like this since they were auto-configured to be public. # ADI.container.one # => One() # # # `123` is used as it was bound to all services that include `SomeInterface`. # ADI.container.two # => Two(@id=123) # ``` annotation Autoconfigure; end # Similar to `ADI::Autoconfigure` but specialized for easily configuring [tags](/DependencyInjection/Register/#Athena::DependencyInjection::Register--tagging-services). # Accepts an optional tag name as the first positional parameter, otherwise defaults to the FQN of the type. # Named arguments may also be provided that'll be added to the tag as attributes. # # TIP: This type is best used in conjunction with `ADI::TaggedIterator`. # # ### Example # # ``` # # All services including `SomeInterface` will be tagged with `"some-tag"`. # @[ADI::AutoconfigureTag("some-tag")] # module SomeInterface; end # # # All services including `OtherInterface` will be tagged with `"OtherInterface"`. # @[ADI::AutoconfigureTag] # module OtherInterface; end # ``` annotation AutoconfigureTag; end # Can be applied to a collection parameter to provide all the services with a specific tag. # Supported collection types include: `Indexable`, `Enumerable`, and `Iterator`. # Accepts an optional tag name as the first positional parameter, otherwise defaults to the FQN of the type within the collection type's generic. # # TIP: This type is best used in conjunction with `ADI::AutoconfigureTag`. # # The provided type lazily initializes the provided services as they are accessed. # # ### Example # # ``` # @[ADI::Register] # class Foo # # Inject all services tagged with `"some-tag"`. # def initialize(@[ADI::TaggedIterator("some-tag")] @services : Enumerable(SomeInterface)); end # end # # @[ADI::Register] # class Bar # # Inject all services tagged with `"SomeInterface"`. # def initialize(@[ADI::TaggedIterator] @services : Enumerable(SomeInterface)); end # end # ``` annotation TaggedIterator; end # Automatically registers a service based on the type the annotation is applied to. # # The type of the service affects how it behaves within the container. When a `struct` service is retrieved or injected into a type, it will be a copy of the one in the SC (passed by value). # This means that changes made to it in one type, will _NOT_ be reflected in other types. A `class` service on the other hand will be a reference to the one in the SC. This allows it # to share state between services. # # ## Optional Arguments # # In most cases, the annotation can be applied without additional arguments. However, the annotation accepts a handful of optional arguments to fine tune how the service is registered. # # * `name : String`- The name of the service. Should be unique. Defaults to the type's FQN snake cased. # * `factory : String | Tuple(T, String)` - Use a factory type/method to create the service. See the [Factories](#factories) section. # * `public : Bool` - If the service should be directly accessible from the container. Defaults to `false`. # * `alias : Array(T)` - Injects `self` when any of these types are used as a type restriction. See the Aliasing Services example for more information. # * `tags : Array(String | NamedTuple(name: String, priority: Int32?))` - Tags that should be assigned to the service. Defaults to an empty array. See the [Tagging Services][Athena::DependencyInjection::Register--tagging-services] example for more information. # * `calls : Array(Tuple(String, Tuple(T)))` - Calls that should be made on the service after its instantiated. # # ## Examples # # ### Basic Usage # # The simplest usage involves only applying the `ADI::Register` annotation to a type. If the type does not have any arguments, then it is simply registered as a service as is. If the type _does_ have arguments, then an attempt is made to register the service by automatically resolving dependencies based on type restrictions. # # ``` # @[ADI::Register] # # Register a service without any dependencies. # struct ShoutTransformer # def transform(value : String) : String # value.upcase # end # end # # @[ADI::Register(public: true)] # # The ShoutTransformer is injected based on the type restriction of the `transformer` argument. # struct SomeAPIClient # def initialize(@transformer : ShoutTransformer); end # # def send(message : String) # message = @transformer.transform message # # # ... # end # end # # ADI.container.some_api_client.send "foo" # => FOO # ``` # # ### Aliasing Services # # An important part of DI is building against interfaces as opposed to concrete types. # This allows a type to depend upon abstractions rather than a specific implementation of the interface. # Or in other words, prevents a singular implementation from being tightly coupled with another type. # # The `ADI::AsAlias` annotation can be used to define a default implementation for an interface. # Checkout the annotation's docs for more information. # # ### Scalar Arguments # # The auto registration logic as shown in previous examples only works on service dependencies. Scalar arguments, such as Arrays, Strings, NamedTuples, etc, must be defined manually. # This is achieved by using the argument's name prefixed with a `_` symbol as named arguments within the annotation. # # ``` # @[ADI::Register(_shell: ENV["SHELL"], _config: {id: 12_i64, active: true}, public: true)] # struct ScalarClient # def initialize(@shell : String, @config : NamedTuple(id: Int64, active: Bool)); end # end # # ADI.container.scalar_client # => ScalarClient(@config={id: 12, active: true}, @shell="/bin/bash") # ``` # Arrays can also include references to services by prefixing the name of the service with an `@` symbol. # # ``` # module Interface; end # # @[ADI::Register] # struct One # include Interface # end # # @[ADI::Register] # struct Two # include Interface # end # # @[ADI::Register] # struct Three # include Interface # end # # @[ADI::Register(_services: ["@one", "@three"], public: true)] # struct ArrayClient # def initialize(@services : Array(Interface)); end # end # # ADI.container.array_client # => ArrayClient(@services=[One(), Three()]) # ``` # # While scalar arguments cannot be auto registered by default, the `Athena::DependencyInjection.bind` macro can be used to support it. For example: `ADI.bind shell, "bash"`. # This would now inject the string `"bash"` whenever an argument named `shell` is encountered. # # ### Tagging Services # # Services can also be tagged. # Service tags allows another service to have all services with a specific tag injected as a dependency. # A tag consists of a name, and additional metadata related to the tag. # # TIP: Checkout `ADI::AutoconfigureTag` for an easy way to tag services. # # ``` # PARTNER_TAG = "partner" # # @[ADI::Register(_id: 1, name: "google", tags: [{name: PARTNER_TAG, priority: 5}])] # @[ADI::Register(_id: 2, name: "facebook", tags: [PARTNER_TAG])] # @[ADI::Register(_id: 3, name: "yahoo", tags: [{name: "partner", priority: 10}])] # @[ADI::Register(_id: 4, name: "microsoft", tags: [PARTNER_TAG])] # # Register multiple services based on the same type. Each service must give define a unique name. # record FeedPartner, id : Int32 # # @[ADI::Register(public: true)] # class PartnerClient # getter services : Enumerable(FeedPartner) # # def initialize(@[ADI::TaggedIterator(PARTNER_TAG)] @services : Enumerable(FeedPartner)); end # end # # ADI.container.partner_client.services.to_a # => # # [FeedPartner(@id=3), # # FeedPartner(@id=1), # # FeedPartner(@id=2), # # FeedPartner(@id=4)] # ``` # # The `ADI::TaggedIterator` annotation provides an easy way to inject services with a specific tag to a specific parameter. # # ### Service Calls # # Service calls can be defined that will call a specific method on the service, with a set of arguments. # Use cases for this are generally not all that common, but can sometimes be useful. # # ``` # @[ADI::Register(public: true, calls: [ # {"foo"}, # {"foo", {3}}, # {"foo", {6}}, # ])] # class CallClient # getter values = [] of Int32 # # def foo(value : Int32 = 1) # @values << value # end # end # # ADI.container.call_client.values # => [1, 3, 6] # ``` # # ### Service Proxies # # In some cases, it may be a bit "heavy" to instantiate a service that may only be used occasionally. # To solve this, a proxy of the service could be injected instead. # The instantiation of proxied services are deferred until a method is called on it. # # A service is proxied by changing the type signature of the service to be of the `ADI::Proxy(T)` type, where `T` is the service to be proxied. # # ``` # @[ADI::Register] # class ServiceTwo # getter value = 123 # # def initialize # pp "new s2" # end # end # # @[ADI::Register(public: true)] # class ServiceOne # getter service_two : ADI::Proxy(ServiceTwo) # # # Tells `ADI` that a proxy of `ServiceTwo` should be injected. # def initialize(@service_two : ADI::Proxy(ServiceTwo)) # pp "new s1" # end # # def run # # At this point service_two hasn't been initialized yet. # pp "before value" # # # First method interaction with the proxy instantiates the service and forwards the method to it. # pp @service_two.value # end # end # # ADI.container.service_one.run # # "new s1" # # "before value" # # "new s2" # # 123 # ``` # # #### Tagged Services Proxies # # Tagged services may also be injected as an array of proxy objects. # This can be useful as an easy way to manage a collection of services where only one (or a small amount) will be used at a time. # # ``` # @[ADI::Register(_services: "!some_tag")] # class SomeService # def initialize(@services : Array(ADI::Proxy(ServiceType))) # end # end # ``` # # #### Proxy Metadata # # The `ADI::Proxy` object also exposes some metadata related to the proxied object; such as its name, type, and if it has been instantiated yet. # # For example, using `ServiceTwo`: # # ``` # # Assume this returns a `ADI::Proxy(ServiceTwo)`. # proxy = ADI.container.service_two # # proxy.service_id # => "service_two" # proxy.service_type # => ServiceTwo # proxy.instantiated? # => false # proxy.value # => 123 # proxy.instantiated? # => true # ``` # # ### Parameters # # 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. # # ``` # ADI.configure({ # parameters: { # "app.name": "My App", # "app.database.username": "administrator", # }, # }) # # ADI.bind db_username, "%app.database.username%" # # @[ADI::Register(_app_name: "%app.name%", public: true)] # record SomeService, app_name : String, db_username : String # # service = ADI.container.some_service # service.app_name # => "My App" # service.db_username # => "USERNAME" # ``` # # ### 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. # Similarly, if the argument has a default value, that value would be used instead. # # ``` # struct OptionalMissingService # end # # @[ADI::Register] # struct OptionalExistingService # end # # @[ADI::Register(public: true)] # class OptionalClient # getter service_missing, service_existing, service_default # # def initialize( # @service_missing : OptionalMissingService?, # @service_existing : OptionalExistingService?, # @service_default : OptionalMissingService | Int32 | Nil = 12, # ); end # end # # ADI.container.optional_client # # # # ``` # # ### Generic Services # # Generic arguments can be provided as positional arguments within the `ADI::Register` annotation. # # !!!note # Services based on generic types _MUST_ explicitly provide a name via the `name` field within the `ADI::Register` annotation # since there wouldn't be a way to tell them apart from the class name alone. # # ``` # @[ADI::Register(Int32, Bool, name: "int_service", public: true)] # @[ADI::Register(Float64, Bool, name: "float_service", public: true)] # struct GenericService(T, B) # def type # {T, B} # end # end # # ADI.container.int_service.type # => {Int32, Bool} # ADI.container.float_service.type # => {Float64, Bool} # ``` # # ### Factories # # In some cases it may be necessary to use the [factory design pattern](https://en.wikipedia.org/wiki/Factory_%28object-oriented_programming%29) # to handle creating an object as opposed to creating the object directly. In this case the `factory` argument can be used. # # Factory methods are class methods defined on some type; either the service itself or a different type. # Arguments to the factory method are provided as they would if the service was being created directly. # This includes auto resolved service dependencies, and scalar underscore based arguments included within the `ADI::Register` annotation. # # #### Same Type # # A `String` `factory` value denotes the method name that should be called on the service itself to create the service. # # ``` # # Calls `StringFactoryService.double` to create the service. # @[ADI::Register(_value: 10, public: true, factory: "double")] # class StringFactoryService # getter value : Int32 # # def self.double(value : Int32) : self # new value * 2 # end # # def initialize(@value : Int32); end # end # # ADI.container.string_factory_service.value # => 20 # ``` # # Using the `ADI::Inject` annotation on a class method is equivalent to providing that method's name as the `factory` value. # For example, this is the same as the previous example: # # ``` # @[ADI::Register(_value: 10, public: true)] # class StringFactoryService # getter value : Int32 # # @[ADI::Inject] # def self.double(value : Int32) : self # new value * 2 # end # # def initialize(@value : Int32); end # end # # ADI.container.string_factory_service.value # => 20 # ``` # # #### Different Type # # A `Tuple` can also be provided as the `factory` value to allow using an external type's factory method to create the service. # The first item represents the factory type to use, and the second item represents the method that should be called. # # ``` # class TestFactory # def self.create_tuple_service(value : Int32) : TupleFactoryService # TupleFactoryService.new value * 3 # end # end # # # Calls `TestFactory.create_tuple_service` to create the service. # @[ADI::Register(_value: 10, public: true, factory: {TestFactory, "create_tuple_service"})] # class TupleFactoryService # getter value : Int32 # # def initialize(@value : Int32); end # end # # ADI.container.tuple_factory_service.value # => 30 # ``` annotation Register; end # Specifies which constructor should be used for injection. # # ``` # @[ADI::Register(_value: 2, public: true)] # class SomeService # @active : Bool = false # # def initialize(value : String, @active : Bool) # @value = value.to_i # end # # @[ADI::Inject] # def initialize(@value : Int32); end # end # # ADI.container.some_service # => # # SomeService.new "1", true # => # # ``` # # Without the `ADI::Inject` annotation, the first initializer would be used, which would fail since we are not providing a value for the `active` argument. # `ADI::Inject` allows telling the service container that it should use the second constructor when registering this service. This allows a constructor overload # specific to DI to be used while still allowing the type to be used outside of DI via other constructors. # # Using the `ADI::Inject` annotation on a class method also acts a shortcut for defining a service [factory][Athena::DependencyInjection::Register--factories]. annotation Inject; end # :nodoc: annotation Bundle; end end ================================================ FILE: src/components/dependency_injection/src/athena-dependency_injection.cr ================================================ require "./abstract_bundle" require "./annotations" require "./annotation_configurations" require "./extension" require "./proxy" require "./service_container" # :nodoc: class Fiber property container : ADI::ServiceContainer { ADI::ServiceContainer.new } end # Convenience alias to make referencing `Athena::DependencyInjection` types easier. alias ADI = Athena::DependencyInjection # Robust dependency injection service container framework. module Athena::DependencyInjection VERSION = "0.4.5" private BINDINGS = {} of Nil => Nil private AUTO_CONFIGURATIONS = {} of Nil => Nil private EXTENSIONS = {} of Nil => Nil # :nodoc: CONFIG = {parameters: {__nil: nil}} # Ensure this type is a NamedTupleLiteral private CONFIGS = [] of Nil # Allows binding a *value* to a *key* in order to enable auto registration of that value. # # Bindings allow scalar values, or those that could not otherwise be handled via [service aliases][Athena::DependencyInjection::Register--aliasing-services], to be auto registered. # This allows those arguments to be defined once and reused, as opposed to using named arguments to manually specify them for each service. # # Bindings can also be declared with a type restriction to allow taking the type restriction of the argument into account. # Typed bindings are always checked first as the most specific type is always preferred. # If no typed bindings match the argument's type, then the last defined untyped bindings is used. # # ### Example # # ``` # module ValueInterface; end # # @[ADI::Register(_value: 1, name: "value_one")] # @[ADI::Register(_value: 2, name: "value_two")] # @[ADI::Register(_value: 3, name: "value_three")] # record ValueService, value : Int32 do # include ValueInterface # end # # # Untyped bindings # ADI.bind api_key, ENV["API_KEY"] # ADI.bind config, {id: 12_i64, active: true} # ADI.bind static_value, 123 # ADI.bind odd_values, ["@value_one", "@value_three"] # ADI.bind value_arr, [true, true, false] # # # Typed bindings # ADI.bind value_arr : Array(Int32), [1, 2, 3] # ADI.bind value_arr : Array(Float64), [1.0, 2.0, 3.0] # # @[ADI::Register(public: true)] # record BindingClient, # api_key : String, # config : NamedTuple(id: Int64, active: Bool), # static_value : Int32, # odd_values : Array(ValueInterface) # # @[ADI::Register(public: true)] # record IntArr, value_arr : Array(Int32) # # @[ADI::Register(public: true)] # record FloatArr, value_arr : Array(Float64) # # @[ADI::Register(public: true)] # record BoolArr, value_arr : Array(Bool) # # ADI.container.binding_client # => # # BindingClient( # # @api_key="123ABC", # # @config={id: 12, active: true}, # # @static_value=123, # # @odd_values=[ValueService(@value=1), ValueService(@value=3)]) # # ADI.container.int_arr # => IntArr(@value_arr=[1, 2, 3]) # ADI.container.float_arr # => FloatArr(@value_arr=[1.0, 2.0, 3.0]) # ADI.container.bool_arr # => BoolArr(@value_arr=[true, true, false]) # ``` macro bind(key, value) {% BINDINGS[key] = value %} end # Returns the `ADI::ServiceContainer` for the current fiber. def self.container : ADI::ServiceContainer 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 # 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 unless pass_type.module? pass.raise "Pass type must be a module." end type = type || :before_optimization priority = priority || 0 if hash = ADI::ServiceContainer::PASS_CONFIG[type] hash[priority] = [] of Nil if hash[priority] == nil hash[priority] << pass_type.id else type.raise "Invalid compiler pass type: '#{type}'." end %} end # Registers an extension `ADI::Extension::Schema` with the provided *name*. macro register_extension(name, schema) {% ADI::ServiceContainer::EXTENSIONS[name.id.stringify] = schema %} end # :nodoc: macro service_iterator(for name, services) {% begin %} private struct {{name.camelcase.id}}(T, S) include Iterator(T) include Indexable(T) @offset = 0 def initialize(@container : ADI::ServiceContainer); end def next : T | Iterator::Stop return stop if @offset == S self .unsafe_fetch(@offset) .tap { @offset += 1 } end def size : Int32 S end def rewind : Nil @offset = 0 end # :nodoc: def unsafe_fetch(index : Int) : T case index {% for service_id, idx in services %} when {{idx}} then @container.{{service_id.id}}\ {% end %} else raise "" end end end {% end %} end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/analyze_service_references.cr ================================================ # :nodoc: # # Builds a reference graph tracking which services are used and how many times. # Populates SERVICE_REFERENCES with reference counts for use by optimization passes. module Athena::DependencyInjection::ServiceContainer::AnalyzeServiceReferences macro included macro finished {% verbatim do %} {% __nil = nil # Initialize reference counts for all services SERVICE_HASH.each do |service_id, definition| if definition != nil SERVICE_REFERENCES[service_id] = { count: 0, public: definition["public"] == true, referenced_by: [] of Nil, } end end # Analyze references SERVICE_HASH.each do |service_id, definition| if definition != nil # 1. Check parameter values for service references if parameters = definition["parameters"] parameters.each do |_, param| value = param["value"] # Direct service reference (bare identifier after ResolveValues) if value && SERVICE_HASH[value.stringify] != nil ref_id = value.stringify SERVICE_REFERENCES[ref_id]["count"] += 1 SERVICE_REFERENCES[ref_id]["referenced_by"] << service_id end # Array of service references if value.is_a?(ArrayLiteral) value.each do |v| if SERVICE_HASH[v.stringify] != nil ref_id = v.stringify SERVICE_REFERENCES[ref_id]["count"] += 1 SERVICE_REFERENCES[ref_id]["referenced_by"] << service_id end end end end end # 2. Check calls array for service references if calls = definition["calls"] calls.each do |call| method, args = call if args args.each do |arg| if SERVICE_HASH[arg.stringify] != nil ref_id = arg.stringify SERVICE_REFERENCES[ref_id]["count"] += 1 SERVICE_REFERENCES[ref_id]["referenced_by"] << service_id end end end end end # 3. Check explicit referenced_services metadata if referenced_services = definition["referenced_services"] referenced_services.each do |ref_id| ref_id_str = ref_id.id.stringify if SERVICE_HASH[ref_id_str] != nil SERVICE_REFERENCES[ref_id_str]["count"] += 1 SERVICE_REFERENCES[ref_id_str]["referenced_by"] << service_id end end end end end # 4. Count public aliases as references to their target services # Only type-only aliases (name is nil) can be public ALIASES.each do |alias_name, alias_entries| type_only_alias = alias_entries.find(&.["name"].nil?) if type_only_alias && type_only_alias["public"] == true target_id = type_only_alias["id"].id.stringify SERVICE_REFERENCES.keys.each do |key| if key.id.stringify == target_id old_info = SERVICE_REFERENCES[key] SERVICE_REFERENCES[key] = { count: old_info["count"] + 1, public: old_info["public"], referenced_by: old_info["referenced_by"] << "alias:#{alias_name.id}", } end end end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/auto_wire.cr ================================================ # :nodoc: module Athena::DependencyInjection::ServiceContainer::AutoWire macro included macro finished {% verbatim do %} {% printed = false SERVICE_HASH.each do |_, definition| definition["parameters"].each do |name, param| param_resolved_restriction = param["resolved_restriction"] resolved_services = [] of Nil # Gather a list of services that are compatible with the parameter's type restriction. SERVICE_HASH.each do |id, s_metadata| if (type = param_resolved_restriction) && ( s_metadata["class"] <= type || (type < ADI::Proxy && s_metadata["class"] <= type.type_vars.first.resolve) ) resolved_services << id end end # If only one service was resolved use it, but only if the parameter is typed as a non-module. # This prevents parameters typed as an interface from being resolved if there is only a single implementation. # # These services should be wired up as aliases to prevent errors if/when another implementation is added. resolved_service = if resolved_services.size == 1 && !param_resolved_restriction.module? resolved_services[0] # If there are more than one, try and match the parameter's name to a service ID. elsif rs = resolved_services.find(&.==(name.id)) rs # Otherwise see if any aliases explicitly match the parameter's type restriction. elsif a = ALIASES.keys.find { |k| k == param_resolved_restriction } aliases_for_type = ALIASES[a] # Try named alias first (more specific match by parameter name) named_alias = aliases_for_type.find { |entry| entry["name"] && entry["name"].id == name.id } if named_alias named_alias["id"] else # Fall back to type-only alias type_only_alias = aliases_for_type.find(&.["name"].nil?) type_only_alias ? type_only_alias["id"] : nil end end if resolved_service if param["resolved_restriction"] < ADI::Proxy param["value"] = "ADI::Proxy.new(#{resolved_service}, ->#{resolved_service.id})".id # Track proxy references to ensure getters are generated definition["referenced_services"] << resolved_service else param["value"] = resolved_service.id end end end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/define_getters.cr ================================================ # :nodoc: module Athena::DependencyInjection::ServiceContainer::DefineGetters macro included macro finished {% verbatim do %} {% for service_id, metadata in SERVICE_HASH %} {% if metadata != nil && !metadata["inlined"] %} # String literal primarily represents internal services created during container construction. {% service_name = metadata[:class].is_a?(StringLiteral) ? metadata[:class].id : metadata[:class].name(generic_args: false) %} {% generics_type = "#{service_name}(#{metadata[:generics].splat})".id %} {% service = metadata[:generics].empty? ? metadata[:class].id : generics_type.id %} {% ivar_type = metadata[:generics].empty? ? metadata[:class].id : generics_type.id %} {% fq_prefix = metadata[:class].is_a?(StringLiteral) ? "".id : "::".id %} {% constructor_service = service %} {% constructor_method = "new" %} {% if factory = metadata[:factory] %} {% constructor_service, constructor_method = factory %} {% end %} {% __nil = nil # Collect inline setup code from inlined dependencies inline_setups = [] of Nil metadata["parameters"].each do |_, param| value = param["value"] # Check direct service reference value_str = value.id.stringify dep = SERVICE_HASH[value_str] if dep && dep["inlined"] && dep["inline_setup"] inline_setups << dep["inline_setup"] end # Check array elements for service references if value.is_a?(ArrayLiteral) value.each do |v| v_str = v.id.stringify dep = SERVICE_HASH[v_str] if dep && dep["inlined"] && dep["inline_setup"] inline_setups << dep["inline_setup"] end end end end # Collect inline setups from call arguments if calls = metadata["calls"] calls.each do |call| method, args = call if args args.each do |arg| arg_dep = SERVICE_HASH[arg.stringify] if arg_dep && arg_dep["inlined"] && arg_dep["inline_setup"] inline_setups << arg_dep["inline_setup"] end end end end end %} {% if !metadata[:public] %}protected {% end %}getter {{service_id.id}} : {{fq_prefix}}{{ivar_type}} do {% for setup in inline_setups %} {{setup.id}} {% end %} instance = {{fq_prefix}}{{constructor_service}}.{{constructor_method.id}}({{ metadata["parameters"].map do |name, param| value = param["value"] value_str = value.id.stringify # Check if this parameter references an inlined service dep = SERVICE_HASH[value_str] if dep && dep["inlined"] && dep["inline_var"] "#{name.id}: #{dep["inline_var"].id}".id elsif value.is_a?(ArrayLiteral) # Handle array with potential inlined services elements = value.map do |v| v_str = v.id.stringify v_dep = SERVICE_HASH[v_str] if v_dep && v_dep["inlined"] && v_dep["inline_var"] v_dep["inline_var"].id else v end end str = "#{name.id}: [#{elements.splat}]" if (resolved_restriction = param["resolved_restriction"]) && resolved_restriction <= Array && (value.of.is_a?(Nop) || elements.empty?) str += " of Union(#{resolved_restriction.type_vars.splat})" end str.id else "#{name.id}: #{value}".id end end.splat }}) {% for call in metadata[:calls] %} {% method, args = call %} {% transformed_args = args.map do |arg| arg_dep = SERVICE_HASH[arg.stringify] if arg_dep && arg_dep["inlined"] && arg_dep["inline_var"] arg_dep["inline_var"].id else arg end end %} instance.{{method.id}}({{transformed_args.splat}}) {% end %} instance end {% if metadata[:public] %} def get(service : {{fq_prefix}}{{service}}.class) : {{fq_prefix}}{{service.id}} {{service_id.id}} end {% end %} {% end %} {% end %} {% for alias_name, alias_entries in ALIASES %} # Find type-only alias (name is nil) for public access {% type_only_alias = alias_entries.find(&.["name"].nil?) %} {% if type_only_alias && type_only_alias["public"] %} # String alias maps to a service => service alias so we just need a method with the alias' name. {% if alias_name.is_a?(StringLiteral) %} {% aliased_class = SERVICE_HASH[type_only_alias["id"]]["class"] %} {% alias_fq_prefix = aliased_class.is_a?(StringLiteral) ? "".id : "::".id %} def {{alias_name.id}} : {{alias_fq_prefix}}{{aliased_class.id}} {{type_only_alias["id"].id}} end # TypeNode alias maps to an interface => service alias, so we need an override of `#get` pinned to the interface type. {% else %} def get(service : {{alias_name.id}}.class) : {{alias_name.id}} {{type_only_alias["id"].id}} end {% end %} {% end %} {% end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/inline_service_definitions.cr ================================================ # :nodoc: # # Marks private single-use services for inlining into their consumers. # Precomputes inline setup code and variable names which DefineGetters uses. # Supports nested inlining where service A depends on service B, both single-use. module Athena::DependencyInjection::ServiceContainer::InlineServiceDefinitions macro included macro finished {% verbatim do %} {% __nil = nil # Build set of services that are targets of public aliases # Only type-only aliases (name is nil) can be public alias_targets = {} of Nil => Nil ALIASES.each do |alias_name, alias_entries| type_only_alias = alias_entries.find(&.["name"].nil?) if type_only_alias && type_only_alias["public"] == true alias_targets[type_only_alias["id"].id.stringify] = true end end # Build set of services that are referenced via Proxy (need getters for proc references) proxy_targets = {} of Nil => Nil SERVICE_HASH.each do |_, definition| if definition != nil && definition["referenced_services"] definition["referenced_services"].each do |ref_id| proxy_targets[ref_id.id.stringify] = true end end end # First pass: mark ALL single-use private services for inlining SERVICE_REFERENCES.each do |service_id, ref_info| definition = SERVICE_HASH[service_id] # Only inline if: not nil, not public, exactly one reference, not a public alias target, AND not a proxy target sid_str = service_id.id.stringify if definition != nil && ref_info["public"] == false && ref_info["count"] == 1 && !alias_targets[sid_str] && !proxy_targets[sid_str] definition["inlined"] = true # Pre-compute the variable name (service_id with dashes replaced) definition["inline_var"] = service_id.gsub(/-/, "_") end end # Second pass: compute inline setup code in dependency order # Uses a queue pattern - services whose deps aren't ready get pushed back for later processing to_process = [] of Nil SERVICE_HASH.each do |service_id, definition| if definition != nil && definition["inlined"] to_process << service_id end end to_process.each do |service_id| definition = SERVICE_HASH[service_id] if !definition["inline_setup"] # Check if all inlined dependencies have their setup computed can_compute = true if params = definition["parameters"] params.each do |_, param| value = param["value"] # Check direct service reference value_str = value.id.stringify dep = SERVICE_HASH[value_str] if dep && dep["inlined"] && !dep["inline_setup"] can_compute = false end # Check array elements if value.is_a?(ArrayLiteral) value.each do |v| v_str = v.id.stringify v_dep = SERVICE_HASH[v_str] if v_dep && v_dep["inlined"] && !v_dep["inline_setup"] can_compute = false end end end end end # Check call arguments for inlined dependencies if calls = definition["calls"] calls.each do |call| method, args = call if args args.each do |arg| arg_dep = SERVICE_HASH[arg.stringify] if arg_dep && arg_dep["inlined"] && !arg_dep["inline_setup"] can_compute = false end end end end end if can_compute service_name = definition["class"].is_a?(StringLiteral) ? definition["class"].id : definition["class"].name(generic_args: false) generics_type = "#{service_name}(#{definition["generics"].splat})".id service = definition["generics"].empty? ? definition["class"].id : generics_type.id constructor_service = service constructor_method = "new" if factory = definition["factory"] constructor_service, constructor_method = factory end fq_prefix = definition["class"].is_a?(StringLiteral) ? "".id : "::".id var_name = definition["inline_var"] setup_lines = [] of Nil # First, include setup code from all inlined dependencies in parameters if params = definition["parameters"] params.each do |_, param| value = param["value"] # Check direct service reference value_str = value.id.stringify dep = SERVICE_HASH[value_str] if dep && dep["inlined"] && dep["inline_setup"] setup_lines << dep["inline_setup"] end # Check array elements if value.is_a?(ArrayLiteral) value.each do |v| v_str = v.id.stringify v_dep = SERVICE_HASH[v_str] if v_dep && v_dep["inlined"] && v_dep["inline_setup"] setup_lines << v_dep["inline_setup"] end end end end end # Include setup code from inlined dependencies in call arguments if calls = definition["calls"] calls.each do |call| method, args = call if args args.each do |arg| arg_dep = SERVICE_HASH[arg.stringify] if arg_dep && arg_dep["inlined"] && arg_dep["inline_setup"] setup_lines << arg_dep["inline_setup"] end end end end end # Build parameter list, using inline_var for inlined deps param_strs = [] of Nil if params = definition["parameters"] params.each do |name, param| value = param["value"] value_str = value.id.stringify dep = SERVICE_HASH[value_str] if dep && dep["inlined"] && dep["inline_var"] param_strs << "#{name.id}: #{dep["inline_var"].id}" elsif value.is_a?(ArrayLiteral) # Handle array with potential inlined services elements = value.map do |v| v_str = v.id.stringify v_dep = SERVICE_HASH[v_str] if v_dep && v_dep["inlined"] && v_dep["inline_var"] v_dep["inline_var"].id else v end end str = "#{name.id}: [#{elements.splat}]" # Always add type annotation for arrays (needed for empty arrays) if (resolved_restriction = param["resolved_restriction"]) && resolved_restriction <= Array str += " of Union(#{resolved_restriction.type_vars.splat})" end param_strs << str else param_strs << "#{name.id}: #{value}" end end end # Add this service's instantiation setup_lines << "#{var_name.id} = #{fq_prefix}#{constructor_service}.#{constructor_method.id}(#{param_strs.join(", ").id})" # Add any calls on this service, transforming inlined service args if calls = definition["calls"] calls.each do |call| method, args = call transformed_args = args.map do |arg| arg_dep = SERVICE_HASH[arg.stringify] if arg_dep && arg_dep["inlined"] && arg_dep["inline_var"] arg_dep["inline_var"].id else arg end end setup_lines << "#{var_name.id}.#{method.id}(#{transformed_args.splat})" end end definition["inline_setup"] = setup_lines.join("\n") else # Dependencies not ready yet, push back for later processing to_process << service_id end end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/merge_configs.cr ================================================ # :nodoc: # # Merges successive calls to `ADI.configure`, with the last ones winning. module Athena::DependencyInjection::ServiceContainer::MergeConfigs macro included macro finished {% verbatim do %} {% to_process = [] of Nil CONFIGS.each do |c| c.to_a.each do |tup| to_process << {tup[0], tup[1], c, [tup[0]], CONFIG} end end to_process.each do |(k, v, h, stack, root)| if v.is_a?(NamedTupleLiteral) v.to_a.each do |(sk, sv)| to_process << {sk, sv, v, stack + [sk], root} end else stack[..-2].each_with_index do |sk, idx| if root[sk] == nil root[sk] = {__nil: nil} # Ensure this is a NamedTupleLiteral end root = root[sk] end root[k] = v end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/merge_extension_config.cr ================================================ # :nodoc: # # Compiler pass that merges user-provided configuration with extension schema defaults. # # This pass handles two main scenarios for each schema property: # 1. User provided a value: Transform it as needed (e.g., resolve enums, fill in default values for missing object members) # 2. User didn't provide a value: Use the schema's default value # # Key concepts: # - CONFIG: User-provided configuration (from ADI.configure) # - OPTIONS: Schema property definitions (from extension.cr macros) # - member_map: For array_of/object_of/map_of, describes the structure of each element. # Members can be TypeDeclaration (simple) or NamedTupleLiteral (object_schema ref). # - map_of properties use `prop["type"] <= Hash` as their identifying marker module Athena::DependencyInjection::ServiceContainer::MergeExtensionConfig private EXTENSION_SCHEMA_PROPERTIES_MAP = {} of Nil => Nil macro included macro finished {% verbatim do %} # In order to keep extensions local to the DI component, they must be registered via a dedicated macro call. # This includes the name of the extension and its schema. # If the extension has any compiler passes (including the extension itself), those must be registered via a dedicated macro call as well. # This setup keeps things pretty de-coupled; allowing use of extensions/compiler passes if used outside of the Framework. {% _nil = nil # Array of tuples representing all of the extension types that are to be processed. # 0 : String - name of the extension # 1 : Array(String) - represents the path from root of the extension to this child extension type # 2 : TypeNode - extension type extensions_to_process = [] of Nil extension_schema_map = {} of Nil => Nil # For each extension type, register its base type ADI::ServiceContainer::EXTENSIONS.each do |name, ext| extensions_to_process << {name.id, [] of Nil, ext.resolve} end # For each base type, determine all child extension types extensions_to_process.each do |(ext_name, ext_path, ext)| ext.constants.reject(&.==("OPTIONS")).each do |sub_ext| t = parse_type("::#{ext}::#{sub_ext}").resolve # We only want to process sub extension modules, not just any primitive constant defined within these types if t.is_a? TypeNode extensions_to_process << {ext_name, ext_path + [sub_ext.stringify.underscore.downcase.id], t} end end end # For each extension to register, build out a schema hash consisting of the schema types related to each extension extensions_to_process.each do |(ext_name, ext_path, ext)| if extension_schema_map[ext_name] == nil ext_options = extension_schema_map[ext_name] = {__nil: nil} # Ensure this is a NamedTupleLiteral EXTENSION_SCHEMA_PROPERTIES_MAP[ext_name] = [] of Nil end ext.constant("OPTIONS").each do |o| obj = extension_schema_map[ext_name] if ext_path.empty? obj[o["name"]] = o EXTENSION_SCHEMA_PROPERTIES_MAP[ext_name] << {o, ext_path} else ext_path.each_with_index do |k, idx| obj[k] = {} of Nil => Nil if obj[k] == nil obj = obj[k] if idx == ext_path.size - 1 obj[o["name"]] = o EXTENSION_SCHEMA_PROPERTIES_MAP[ext_name] << {o, ext_path} end end end end end # Validate there is no configuration for un-registered extensions extra_keys = CONFIG.keys.reject { |k| k == "parameters".id || k == "__nil".id } - extension_schema_map.keys unless extra_keys.empty? CONFIG[extra_keys.first].raise "Extension '#{extra_keys.first.id}' is configured, but no extension with that name has been registered." end EXTENSION_SCHEMA_PROPERTIES_MAP.each do |ext_name, schema_properties| # Ensure the root CONFIG obj has a key for each extension unless extension_config = CONFIG[ext_name] extension_config = CONFIG[ext_name] = {__nil: nil} # Ensure this is a NamedTupleLiteral end # Iterate over each schema property to process them schema_properties.each do |(prop, ext_path)| extension_schema_for_current_property = extension_schema_map[ext_name] extension_config_for_current_property = extension_config ext_path.each do |p| extension_schema_for_current_property = extension_schema_for_current_property[p] if extension_schema_for_current_property # Ensure this is a NamedTupleLiteral, expand user provided value to include defaults from schema if not provided extension_config_for_current_property[p] = {__nil: nil} if extension_config_for_current_property[p] == nil extension_config_for_current_property = extension_config_for_current_property[p] end # If the user provided configuration, check for unexpected keys extra_keys = extension_config_for_current_property.keys.reject { |k| k == "__nil".id } - extension_schema_for_current_property.keys unless extra_keys.empty? extra_key_value = extension_config_for_current_property[extra_keys.first] extra_key_value.raise "Unexpected property '#{([ext_name] + ext_path).join('.').id}.#{extra_keys.first.id}'." end # Then handle any light transformations needed to get the configuration value into the expected format/type if (config_value = extension_config_for_current_property[prop["name"]]) != nil config_value = config_value.is_a?(Path) ? config_value.resolve : config_value resolved_value = if config_value.is_a?(SymbolLiteral) && (type = prop["type"]) <= ::Enum config_value.raise "Unknown '#{type}' enum member for property '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}'." unless type.constants.any?(&.downcase.id.==(config_value.id)) # Resolve symbol literals to enum members config_value = "#{prop["global"] ? "::".id : "".id}#{type}.new(#{config_value})".id elsif config_value.is_a?(NumberLiteral) && (type = prop["type"]) <= ::Enum # Resolve enum value to enum members config_value = "#{prop["global"] ? "::".id : "".id}#{type}.new(#{config_value})".id elsif config_value.is_a?(ArrayLiteral) # If there is an array literal and the prop has `members`, # assume it is an `array_of` schema property array and fill in unprovided fields. if member_map = prop["members"] config_value.each do |cfv| provided_keys = cfv.keys # Convert user-provided enum values for object_schema members # Skip nested object_schema references (NamedTupleLiteral with members key) provided_keys.reject { |k| k.stringify == "__nil" }.each do |k| if (decl = member_map[k]) && (user_value = cfv[k]) && !(decl.is_a?(NamedTupleLiteral) && decl["members"]) decl_type = decl.is_a?(TypeDeclaration) ? decl.type : decl["type"] decl_global = decl_type.is_a?(Path) && decl_type.global? resolved_decl_type = decl_type.resolve if user_value.is_a?(SymbolLiteral) && resolved_decl_type <= ::Enum user_value.raise "Unknown '#{resolved_decl_type}' enum member for '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}'." unless resolved_decl_type.constants.any?(&.downcase.id.==(user_value.id)) cfv[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{user_value})".id elsif user_value.is_a?(NumberLiteral) && resolved_decl_type <= ::Enum cfv[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{user_value})".id end end end # We only want to add in missing default values, so reject any properties that were provided, even if they may be incorrect. member_map.keys.reject { |k| k.stringify == "__nil" || provided_keys.includes? k }.each do |k| decl = member_map[k] # Handle both TypeDeclaration and NamedTupleLiteral (for nested object_schema) decl_value = decl.is_a?(TypeDeclaration) ? decl.value : decl["value"] # Skip setting required values so that it results in a missing error vs type mismatch error. unless decl_value.is_a?(Nop) # Skip enum conversion for nested object_schema references if decl.is_a?(NamedTupleLiteral) && decl["members"] cfv[k] = decl_value else # Convert symbol/number to enum if applicable decl_type = decl.is_a?(TypeDeclaration) ? decl.type : decl["type"] decl_global = decl_type.is_a?(Path) && decl_type.global? resolved_decl_type = decl_type.resolve if decl_value.is_a?(SymbolLiteral) && resolved_decl_type <= ::Enum decl_value.raise "Unknown '#{resolved_decl_type}' enum member for default value of '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}'." unless resolved_decl_type.constants.any?(&.downcase.id.==(decl_value.id)) cfv[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id elsif decl_value.is_a?(NumberLiteral) && resolved_decl_type <= ::Enum cfv[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id else cfv[k] = decl_value end end end end # Recursively fill in defaults for nested object_schema members member_map.keys.reject { |k| k.stringify == "__nil" }.each do |k| decl = member_map[k] if decl.is_a?(NamedTupleLiteral) && (nested_members = decl["members"]) && (nested_cfv = cfv[k]) nested_provided_keys = nested_cfv.keys # Convert user-provided enum values for nested object_schema members # Skip nested object_schema references nested_provided_keys.reject { |nk| nk.stringify == "__nil" }.each do |nk| if (nested_decl = nested_members[nk]) && (nested_user_value = nested_cfv[nk]) && !(nested_decl.is_a?(NamedTupleLiteral) && nested_decl["members"]) nested_decl_type = nested_decl.is_a?(TypeDeclaration) ? nested_decl.type : nested_decl["type"] nested_decl_global = nested_decl_type.is_a?(Path) && nested_decl_type.global? nested_resolved_decl_type = nested_decl_type.resolve if nested_user_value.is_a?(SymbolLiteral) && nested_resolved_decl_type <= ::Enum nested_user_value.raise "Unknown '#{nested_resolved_decl_type}' enum member for '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}.#{nk.id}'." unless nested_resolved_decl_type.constants.any?(&.downcase.id.==(nested_user_value.id)) nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_user_value})".id elsif nested_user_value.is_a?(NumberLiteral) && nested_resolved_decl_type <= ::Enum nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_user_value})".id end end end nested_members.keys.reject { |nk| nk.stringify == "__nil" || nested_provided_keys.includes? nk }.each do |nk| nested_decl = nested_members[nk] nested_decl_value = nested_decl.is_a?(TypeDeclaration) ? nested_decl.value : nested_decl["value"] unless nested_decl_value.is_a?(Nop) # Skip enum conversion for nested object_schema references if nested_decl.is_a?(NamedTupleLiteral) && nested_decl["members"] nested_cfv[nk] = nested_decl_value else # Convert symbol/number to enum if applicable nested_decl_type = nested_decl.is_a?(TypeDeclaration) ? nested_decl.type : nested_decl["type"] nested_decl_global = nested_decl_type.is_a?(Path) && nested_decl_type.global? nested_resolved_decl_type = nested_decl_type.resolve if nested_decl_value.is_a?(SymbolLiteral) && nested_resolved_decl_type <= ::Enum nested_decl_value.raise "Unknown '#{nested_resolved_decl_type}' enum member for default value of '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}.#{nk.id}'." unless nested_resolved_decl_type.constants.any?(&.downcase.id.==(nested_decl_value.id)) nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_decl_value})".id elsif nested_decl_value.is_a?(NumberLiteral) && nested_resolved_decl_type <= ::Enum nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_decl_value})".id else nested_cfv[nk] = nested_decl_value end end end end end end end end config_value elsif config_value.is_a?(NamedTupleLiteral) # NamedTupleLiteral handles three cases: # 1. map_of values: {key1: {members...}, key2: {members...}} # 2. object_of values: {member1: val, member2: val} # 3. Inline NamedTuple type properties # # Check if this is a map_of property (type is Hash and has members). if prop["type"] <= Hash && (member_map = prop["members"]) config_value.each do |hash_key, cfv| if hash_key != "__nil" provided_keys = cfv.keys # Convert user-provided enum values for object_schema members # Skip nested object_schema references (NamedTupleLiteral with members key) provided_keys.reject { |k| k.stringify == "__nil" }.each do |k| if (decl = member_map[k]) && (user_value = cfv[k]) && !(decl.is_a?(NamedTupleLiteral) && decl["members"]) decl_type = decl.is_a?(TypeDeclaration) ? decl.type : decl["type"] decl_global = decl_type.is_a?(Path) && decl_type.global? resolved_decl_type = decl_type.resolve if user_value.is_a?(SymbolLiteral) && resolved_decl_type <= ::Enum user_value.raise "Unknown '#{resolved_decl_type}' enum member for '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{hash_key.id}.#{k.id}'." unless resolved_decl_type.constants.any?(&.downcase.id.==(user_value.id)) cfv[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{user_value})".id elsif user_value.is_a?(NumberLiteral) && resolved_decl_type <= ::Enum cfv[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{user_value})".id end end end member_map.keys.reject { |k| k.stringify == "__nil" || provided_keys.includes? k }.each do |k| decl = member_map[k] # Handle both TypeDeclaration and NamedTupleLiteral (for nested object_schema) decl_value = decl.is_a?(TypeDeclaration) ? decl.value : decl["value"] # Skip setting required values so that it results in a missing error vs type mismatch error. unless decl_value.is_a?(Nop) # Skip enum conversion for nested object_schema references if decl.is_a?(NamedTupleLiteral) && decl["members"] cfv[k] = decl_value else # Convert symbol/number to enum if applicable decl_type = decl.is_a?(TypeDeclaration) ? decl.type : decl["type"] decl_global = decl_type.is_a?(Path) && decl_type.global? resolved_decl_type = decl_type.resolve if decl_value.is_a?(SymbolLiteral) && resolved_decl_type <= ::Enum decl_value.raise "Unknown '#{resolved_decl_type}' enum member for default value of '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{hash_key.id}.#{k.id}'." unless resolved_decl_type.constants.any?(&.downcase.id.==(decl_value.id)) cfv[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id elsif decl_value.is_a?(NumberLiteral) && resolved_decl_type <= ::Enum cfv[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id else cfv[k] = decl_value end end end end # Recursively fill in defaults for nested object_schema members member_map.keys.reject { |k| k.stringify == "__nil" }.each do |k| decl = member_map[k] if decl.is_a?(NamedTupleLiteral) && (nested_members = decl["members"]) && (nested_cfv = cfv[k]) nested_provided_keys = nested_cfv.keys # Convert user-provided enum values for nested object_schema members # Skip nested object_schema references nested_provided_keys.reject { |nk| nk.stringify == "__nil" }.each do |nk| if (nested_decl = nested_members[nk]) && (nested_user_value = nested_cfv[nk]) && !(nested_decl.is_a?(NamedTupleLiteral) && nested_decl["members"]) nested_decl_type = nested_decl.is_a?(TypeDeclaration) ? nested_decl.type : nested_decl["type"] nested_decl_global = nested_decl_type.is_a?(Path) && nested_decl_type.global? nested_resolved_decl_type = nested_decl_type.resolve if nested_user_value.is_a?(SymbolLiteral) && nested_resolved_decl_type <= ::Enum nested_user_value.raise "Unknown '#{nested_resolved_decl_type}' enum member for '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{hash_key.id}.#{k.id}.#{nk.id}'." unless nested_resolved_decl_type.constants.any?(&.downcase.id.==(nested_user_value.id)) nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_user_value})".id elsif nested_user_value.is_a?(NumberLiteral) && nested_resolved_decl_type <= ::Enum nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_user_value})".id end end end nested_members.keys.reject { |nk| nk.stringify == "__nil" || nested_provided_keys.includes? nk }.each do |nk| nested_decl = nested_members[nk] nested_decl_value = nested_decl.is_a?(TypeDeclaration) ? nested_decl.value : nested_decl["value"] unless nested_decl_value.is_a?(Nop) # Skip enum conversion for nested object_schema references if nested_decl.is_a?(NamedTupleLiteral) && nested_decl["members"] nested_cfv[nk] = nested_decl_value else # Convert symbol/number to enum if applicable nested_decl_type = nested_decl.is_a?(TypeDeclaration) ? nested_decl.type : nested_decl["type"] nested_decl_global = nested_decl_type.is_a?(Path) && nested_decl_type.global? nested_resolved_decl_type = nested_decl_type.resolve if nested_decl_value.is_a?(SymbolLiteral) && nested_resolved_decl_type <= ::Enum nested_decl_value.raise "Unknown '#{nested_resolved_decl_type}' enum member for default value of '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{hash_key.id}.#{k.id}.#{nk.id}'." unless nested_resolved_decl_type.constants.any?(&.downcase.id.==(nested_decl_value.id)) nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_decl_value})".id elsif nested_decl_value.is_a?(NumberLiteral) && nested_resolved_decl_type <= ::Enum nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_decl_value})".id else nested_cfv[nk] = nested_decl_value end end end end end end end end config_value # Fill in `nil` values to missing nilable NT keys elsif member_map = prop["members"] provided_keys = config_value.keys # Convert user-provided enum values for object_schema members # Skip nested object_schema references (NamedTupleLiteral with members key) provided_keys.reject { |k| k.stringify == "__nil" }.each do |k| if (decl = member_map[k]) && (user_value = config_value[k]) && !(decl.is_a?(NamedTupleLiteral) && decl["members"]) decl_type = decl.is_a?(TypeDeclaration) ? decl.type : decl["type"] decl_global = decl_type.is_a?(Path) && decl_type.global? resolved_decl_type = decl_type.resolve if user_value.is_a?(SymbolLiteral) && resolved_decl_type <= ::Enum user_value.raise "Unknown '#{resolved_decl_type}' enum member for '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}'." unless resolved_decl_type.constants.any?(&.downcase.id.==(user_value.id)) config_value[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{user_value})".id elsif user_value.is_a?(NumberLiteral) && resolved_decl_type <= ::Enum config_value[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{user_value})".id end end end # We only want to add in missing default values, so reject any properties that were provided, even if they may be incorrect. member_map.keys.reject { |k| k.stringify == "__nil" || provided_keys.includes? k }.each do |k| decl = member_map[k] # Handle both TypeDeclaration and NamedTupleLiteral (for nested object_schema) if decl.is_a?(TypeDeclaration) # If the value has a default, use it. # Otherwise skip setting required values so that it results in a missing error vs type mismatch error. if !decl.value.is_a?(Nop) decl_value = decl.value # Convert symbol/number to enum if applicable decl_global = decl.type.is_a?(Path) && decl.type.global? resolved_decl_type = decl.type.resolve if decl_value.is_a?(SymbolLiteral) && resolved_decl_type <= ::Enum decl_value.raise "Unknown '#{resolved_decl_type}' enum member for default value of '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}'." unless resolved_decl_type.constants.any?(&.downcase.id.==(decl_value.id)) config_value[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id elsif decl_value.is_a?(NumberLiteral) && resolved_decl_type <= ::Enum config_value[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id else config_value[k] = decl_value end elsif decl.type.resolve.nilable? config_value[k] = nil end elsif decl.is_a?(NamedTupleLiteral) # Nested object_schema reference decl_value = decl["value"] unless decl_value.is_a?(Nop) # Convert symbol/number to enum if applicable decl_type = decl["type"] decl_global = decl_type.is_a?(Path) && decl_type.global? resolved_decl_type = decl_type.resolve if decl_value.is_a?(SymbolLiteral) && resolved_decl_type <= ::Enum decl_value.raise "Unknown '#{resolved_decl_type}' enum member for default value of '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}'." unless resolved_decl_type.constants.any?(&.downcase.id.==(decl_value.id)) config_value[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id elsif decl_value.is_a?(NumberLiteral) && resolved_decl_type <= ::Enum config_value[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id else config_value[k] = decl_value end end end end # Recursively fill in defaults for nested object_schema members member_map.keys.reject { |k| k.stringify == "__nil" }.each do |k| decl = member_map[k] if decl.is_a?(NamedTupleLiteral) && (nested_members = decl["members"]) && (nested_cfv = config_value[k]) nested_provided_keys = nested_cfv.keys # Convert user-provided enum values for nested object_schema members # Skip nested object_schema references nested_provided_keys.reject { |nk| nk.stringify == "__nil" }.each do |nk| if (nested_decl = nested_members[nk]) && (nested_user_value = nested_cfv[nk]) && !(nested_decl.is_a?(NamedTupleLiteral) && nested_decl["members"]) nested_decl_type = nested_decl.is_a?(TypeDeclaration) ? nested_decl.type : nested_decl["type"] nested_decl_global = nested_decl_type.is_a?(Path) && nested_decl_type.global? nested_resolved_decl_type = nested_decl_type.resolve if nested_user_value.is_a?(SymbolLiteral) && nested_resolved_decl_type <= ::Enum nested_user_value.raise "Unknown '#{nested_resolved_decl_type}' enum member for '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}.#{nk.id}'." unless nested_resolved_decl_type.constants.any?(&.downcase.id.==(nested_user_value.id)) nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_user_value})".id elsif nested_user_value.is_a?(NumberLiteral) && nested_resolved_decl_type <= ::Enum nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_user_value})".id end end end nested_members.keys.reject { |nk| nk.stringify == "__nil" || nested_provided_keys.includes? nk }.each do |nk| nested_decl = nested_members[nk] nested_decl_value = nested_decl.is_a?(TypeDeclaration) ? nested_decl.value : nested_decl["value"] unless nested_decl_value.is_a?(Nop) # Skip enum conversion for nested object_schema references if nested_decl.is_a?(NamedTupleLiteral) && nested_decl["members"] nested_cfv[nk] = nested_decl_value else # Convert symbol/number to enum if applicable nested_decl_type = nested_decl.is_a?(TypeDeclaration) ? nested_decl.type : nested_decl["type"] nested_decl_global = nested_decl_type.is_a?(Path) && nested_decl_type.global? nested_resolved_decl_type = nested_decl_type.resolve if nested_decl_value.is_a?(SymbolLiteral) && nested_resolved_decl_type <= ::Enum nested_decl_value.raise "Unknown '#{nested_resolved_decl_type}' enum member for default value of '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}.#{nk.id}'." unless nested_resolved_decl_type.constants.any?(&.downcase.id.==(nested_decl_value.id)) nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_decl_value})".id elsif nested_decl_value.is_a?(NumberLiteral) && nested_resolved_decl_type <= ::Enum nested_cfv[nk] = "#{nested_decl_global ? "::".id : "".id}#{nested_resolved_decl_type}.new(#{nested_decl_value})".id else nested_cfv[nk] = nested_decl_value end end end end end end else p = prop["type"] p.keys.each do |k| t = p[k] if config_value[k] == nil && t.nilable? config_value[k] = nil end end end config_value else config_value end else # Otherwise fall back on the default value of the property resolved_value = if prop["default"].is_a?(Nop) nil elsif prop["default"].is_a?(Path) prop["default"].resolve else default_value = prop["default"] # Resolve symbol literals to enum members if default_value.is_a?(SymbolLiteral) && (type = prop["type"]) <= ::Enum prop["default"].raise "Unknown '#{type}' enum member for default value of property '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}'." unless type.constants.any?(&.downcase.id.==(default_value.id)) # Resolve symbol literals to enum members default_value = "#{prop["global"] ? "::".id : "".id}#{type}.new(#{default_value})".id elsif default_value.is_a?(ArrayLiteral) # If there is an array literal and the prop has `members`, # assume it is an `array_of` schema property array and fill in unprovided fields. if member_map = prop["members"] default_value.each do |cfv| provided_keys = cfv.keys # We only want to add in missing default values, so reject any properties that were provided, even if they may be incorrect. member_map.keys.reject { |k| k.stringify == "__nil" || provided_keys.includes? k }.each do |k| decl = member_map[k] # Skip setting required values so that it results in a missing error vs type mismatch error. unless decl.value.is_a?(Nop) decl_value = decl.value # Convert symbol/number to enum if applicable decl_global = decl.type.is_a?(Path) && decl.type.global? resolved_decl_type = decl.type.resolve if decl_value.is_a?(SymbolLiteral) && resolved_decl_type <= ::Enum decl_value.raise "Unknown '#{resolved_decl_type}' enum member for default value of '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}'." unless resolved_decl_type.constants.any?(&.downcase.id.==(decl_value.id)) cfv[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id elsif decl_value.is_a?(NumberLiteral) && resolved_decl_type <= ::Enum cfv[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id else cfv[k] = decl_value end end end end end default_value elsif default_value.is_a?(NamedTupleLiteral) # Fill in `nil` values to missing nilable NT keys # Skip for map_of properties - the empty map default shouldn't have member defaults filled in if !(prop["type"] <= Hash) && (member_map = prop["members"]) provided_keys = default_value.keys # We only want to add in missing default values, so reject any properties that were provided, even if they may be incorrect. member_map.keys.reject { |k| k.stringify == "__nil" || provided_keys.includes? k }.each do |k| decl = member_map[k] # Handle both TypeDeclaration and NamedTupleLiteral (for nested object_schema) if decl.is_a?(TypeDeclaration) # If the value has a default, use it. # Otherwise skip setting required values so that it results in a missing error vs type mismatch error. if !decl.value.is_a?(Nop) decl_value = decl.value # Convert symbol/number to enum if applicable decl_global = decl.type.is_a?(Path) && decl.type.global? resolved_decl_type = decl.type.resolve if decl_value.is_a?(SymbolLiteral) && resolved_decl_type <= ::Enum decl_value.raise "Unknown '#{resolved_decl_type}' enum member for default value of '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}'." unless resolved_decl_type.constants.any?(&.downcase.id.==(decl_value.id)) default_value[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id elsif decl_value.is_a?(NumberLiteral) && resolved_decl_type <= ::Enum default_value[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id else default_value[k] = decl_value end elsif decl.type.resolve.nilable? default_value[k] = nil end elsif decl.is_a?(NamedTupleLiteral) # Nested object_schema reference decl_value = decl["value"] unless decl_value.is_a?(Nop) # Convert symbol/number to enum if applicable decl_type = decl["type"] decl_global = decl_type.is_a?(Path) && decl_type.global? resolved_decl_type = decl_type.resolve if decl_value.is_a?(SymbolLiteral) && resolved_decl_type <= ::Enum decl_value.raise "Unknown '#{resolved_decl_type}' enum member for default value of '#{([ext_name] + ext_path).join('.').id}.#{prop["name"]}.#{k.id}'." unless resolved_decl_type.constants.any?(&.downcase.id.==(decl_value.id)) default_value[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id elsif decl_value.is_a?(NumberLiteral) && resolved_decl_type <= ::Enum default_value[k] = "#{decl_global ? "::".id : "".id}#{resolved_decl_type}.new(#{decl_value})".id else default_value[k] = decl_value end end end end end end default_value end end extension_config_for_current_property[prop["name"]] = resolved_value end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/normalize_definitions.cr ================================================ # :nodoc: # # Runs after extensions to normalize the manually wired up services. # Ensures required keys are present and with proper defaults if not specified. module Athena::DependencyInjection::ServiceContainer::NormalizeDefinitions macro included macro finished {% verbatim do %} {% SERVICE_HASH.each do |service_id, definition| definition_keys = definition.keys.map &.stringify unless definition_keys.includes? "class" definition.raise "Service '#{service_id.id}' is missing required 'class' property." end unless definition_keys.includes? "public" definition["public"] = false end unless definition_keys.includes? "shared" definition["shared"] = definition["class"].class? end unless definition_keys.includes? "calls" definition["calls"] = [] of Nil end unless definition_keys.includes? "tags" definition["tags"] = [] of Nil end unless definition_keys.includes? "bindings" definition["bindings"] = {} of Nil => Nil end unless definition_keys.includes? "parameters" definition["parameters"] = {} of Nil => Nil end unless definition_keys.includes? "generics" definition["generics"] = [] of Nil end unless definition_keys.includes? "referenced_services" definition["referenced_services"] = [] of Nil end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/process_aliases.cr ================================================ # :nodoc: module Athena::DependencyInjection::ServiceContainer::ProcessAliases macro included macro finished {% verbatim do %} {% SERVICE_HASH.each do |service_id, definition| interface_modules = definition["class"].ancestors.select &.name.ends_with? "Interface" default_alias = 1 == interface_modules.size ? interface_modules[0] : nil definition["class"].annotations(ADI::AsAlias).each do |ann| alias_id = if alias_type = ann[0] alias_type.is_a?(Path) ? alias_type.resolve : alias_type else default_alias end unless alias_id ann.raise <<-TXT Alias cannot be automatically determined for '#{service_id.id}' (#{definition["class"]}). \ If the type includes multiple interfaces, provide the interface to alias as the first positional argument to `@[ADI::AsAlias]`. TXT end param_name = ann["name"] # Initialize the array for this alias type if needed ALIASES[alias_id] = [] of Nil if ALIASES[alias_id].nil? # Check for duplicate type+name combination if ALIASES[alias_id].any? { |a| a["name"] == param_name } if param_name ann.raise "Duplicate alias for type '#{alias_id}' with name '#{param_name.id}'. " \ "An alias with this type and name combination is already registered." else ann.raise "Duplicate alias for type '#{alias_id}'. " \ "A type-only alias for this type is already registered." end end ALIASES[alias_id] << { id: service_id, public: ann["public"] == true, name: param_name, } end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/process_annotation_bindings.cr ================================================ # :nodoc: # # Applies bindings from the register annotation. module Athena::DependencyInjection::ServiceContainer::ProcessAnnotationBindings macro included macro finished {% verbatim do %} {% SERVICE_HASH.each do |_, definition| annotations = definition["class"].annotations ADI::Register # If there is only 1 ann, it's going to be this def, # otherwise we need to extract the name off the annotation to update the proper definition. # Depends on the `RegisterServices` logic that ensures there is a `name` property on all annotations when there is more than one. if 1 == annotations.size annotations.first.named_args.each do |k, v| if k.starts_with? '_' definition["bindings"][k[1..-1]] = v end end else annotations.each do |ann| ann.named_args.each do |k, v| if k.starts_with? '_' SERVICE_HASH[ann["name"]]["bindings"][k[1..-1]] = v end end end end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/process_autoconfigure_annotations.cr ================================================ # :nodoc: # # Processes `@[ADI::Autoconfigure]` annotations and `AUTO_CONFIGURATIONS` entries module Athena::DependencyInjection::ServiceContainer::ProcessAutoconfigureAnnotations macro included macro finished {% verbatim do %} {% __nil = nil # Unified hash of auto configurations keyed by resolved type. # Each value is a named tuple: {tags, calls, bind, public, constructor} auto_configs = AUTO_CONFIGURATIONS # Build out a list of interfaces, and types that can be used to autoconfigure other services # # Array(TypeNode) types_to_process = [] of Nil # Use `Object.all_subclasses` since some autoconfigured types may not be services themselves. Object.all_subclasses.each do |sc| # Used interface modules (this only captures modules that would be included in at least 1 used type) sc.ancestors.each do |a| # TODO: Use `#private?` once available. if a.module? && (m = parse_type(a.name(generic_args: false).stringify).resolve?) && (m.annotation(ADI::Autoconfigure) || m.annotation(ADI::AutoconfigureTag)) types_to_process << m end end # Non module types that may be the parent type of a service. types_to_process << sc if sc.annotation(ADI::Autoconfigure) || sc.annotation(ADI::AutoconfigureTag) end # Don't process types more than once. types_to_process = types_to_process.uniq types_to_process.each do |t| if auto_configs[t] t.raise "Auto configuration for '#{t.id}' is already defined in 'AUTO_CONFIGURATIONS'. Remove the annotation or the manual entry." end tags = [] of Nil if at = t.annotation(ADI::AutoconfigureTag) tag_name = if n = at[0] if n.is_a?(Path) n.resolve else n end else t.stringify end tag = {name: tag_name} at.named_args.each do |k, v| tag[k.id.stringify] = v end tags << tag end ann = t.annotation ADI::Autoconfigure if ann && (v = ann["tags"]) != nil unless v.is_a? ArrayLiteral v.raise "'tags' field of auto configuration '#{t.id}' must be an 'ArrayLiteral', got '#{v.class_name.id}'." end v.each do |tag| tags << tag end end auto_configs[t] = { tags: tags, calls: ann ? ann["calls"] : nil, bind: ann ? ann["bind"] : nil, public: ann ? ann["public"] : nil, constructor: ann ? ann["constructor"] : nil, } end SERVICE_HASH.each do |service_id, definition| klass = definition["class"] auto_configs.each do |t, config| if klass <= t if (v = config["constructor"]) != nil definition["factory"] = {klass, v} end if (v = config["bind"]) != nil v.each do |k, v| definition["bindings"][k] = v end end if (v = config["public"]) != nil definition["public"] = v end if (v = config["calls"]) != nil calls = [] of Nil v.each do |call| method = call[0] args = call[1] || nil if method.empty? method.raise "Auto configuration '#{t.id}': 'calls' method name cannot be empty." end unless klass.resolve.has_method?(method) method.raise "Auto configuration '#{t.id}': 'calls' method does not exist on service '#{service_id.id}' (#{klass})." end calls << {method, args || [] of Nil} end definition["calls"] = calls end # Append raw tags - will be normalized by ProcessTags pass if tags = config["tags"] tags.each do |tag| definition["tags"] << tag end end end end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/process_bindings.cr ================================================ # :nodoc: # # Service bindings overrides those defined globally, but both override autoconfigured bindings. module Athena::DependencyInjection::ServiceContainer::ProcessBindings macro included macro finished {% verbatim do %} {% SERVICE_HASH.each do |_, definition| definition["parameters"].each do |name, param| set_value = false # Typed binding BINDINGS.keys.select(&.is_a?(TypeDeclaration)).each do |key| 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 end # Untyped binding BINDINGS.keys.select(&.!.is_a?(TypeDeclaration)).each do |key| 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 end end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/process_parameters.cr ================================================ # :nodoc: # # Processes each service definition to determine their constructor parameters. # Also ensures manually wired up services have full and proper initializer information module Athena::DependencyInjection::ServiceContainer::ProcessParameters macro included macro finished {% verbatim do %} {% SERVICE_HASH.each do |service_id, definition| klass = definition["class"] initializer = if f = definition["factory"] f.first.class.methods.find(&.name.==(f[1])) elsif specific_initializer = klass.methods.find(&.annotation(ADI::Inject)) specific_initializer else klass.methods.find(&.name.==("initialize")) end # If no initializer was resolved, assume it's the default argless constructor. initializer_args = (i = initializer) ? i.args : [] of Nil parameters = definition["parameters"] initializer_args.each_with_index do |initializer_arg, idx| param_name = initializer_arg.name.id.stringify default_value = nil # Inherit value if it was already configured on the param value = if (p = parameters[param_name]) && p.keys.map(&.stringify).includes?("value") p["value"] else nil end # Set the default value is there is one. if !(dv = initializer_arg.default_value).is_a?(Nop) default_value = dv end parameters[initializer_arg.name.id.stringify] = { declaration: initializer_arg, name: initializer_arg.name.stringify, idx: idx, internal_name: initializer_arg.internal_name.stringify, restriction: initializer_arg.restriction, resolved_restriction: ((r = initializer_arg.restriction).is_a?(Nop) ? nil : r.resolve), default_value: default_value, value: value.nil? ? default_value : value, } end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/process_tags.cr ================================================ # :nodoc: # # Normalizes service tags and populates TAG_HASH. # Runs after RegisterServices and ProcessAutoconfigureAnnotations. module Athena::DependencyInjection::ServiceContainer::ProcessTags macro included macro finished {% verbatim do %} {% SERVICE_HASH.each do |service_id, definition| klass = definition["class"] normalized_tags = {} of Nil => Nil (definition["tags"] || [] of Nil).each do |tag| name, attributes = if tag.is_a?(StringLiteral) {tag, {} of Nil => Nil} elsif tag.is_a?(Path) {tag.resolve.id.stringify, {} of Nil => Nil} elsif tag.is_a?(NamedTupleLiteral) || tag.is_a?(HashLiteral) unless tag[:name] tag.raise "Failed to register service '#{service_id.id}' (#{klass}). Tag must have a name." end # Resolve a constant to its value if used as a tag name if tag["name"].is_a? Path tag["name"] = tag["name"].resolve end # TODO: Replace this with `#delete` if/when it's ever released # https://github.com/crystal-lang/crystal/pull/9837 attributes = {} of Nil => Nil tag.each do |k, v| attributes[k.id.stringify] = v unless k.id.stringify == "name" end {tag["name"], attributes} else tag.raise "Tag must be a 'StringLiteral' or 'NamedTupleLiteral', got '#{tag.class_name.id}'." end normalized_tags[name] = [] of Nil if normalized_tags[name] == nil normalized_tags[name] << attributes normalized_tags[name] = normalized_tags[name].uniq TAG_HASH[name] = [] of Nil if TAG_HASH[name] == nil TAG_HASH[name] << {service_id, attributes} TAG_HASH[name] = TAG_HASH[name].uniq end definition["tags"] = normalized_tags end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/register_services.cr ================================================ # :nodoc: # # Automatically registers types with an `ADI::Register` annotation. module Athena::DependencyInjection::ServiceContainer::RegisterServices macro included macro finished {% verbatim do %} # Register each service in the hash along with some related metadata. {% for klass in Object.all_subclasses.select &.annotation(ADI::Register) %} {% if (annotations = klass.annotations(ADI::Register)) && !annotations.empty? && !klass.abstract? %} # Raise a compile time exception if multiple services are based on this type, and not all of them specify a `name`. {% if annotations.size > 1 && !annotations.all? &.[:name] %} {% klass.raise "Failed to auto register services for '#{klass}'. Each service must explicitly provide a name when auto registering more than one service based on the same type." %} {% end %} {% for ann in annotations %} {% ann = ann %} {% klass = klass %} # Use the service name defined within the annotation, otherwise fallback on FQN snake cased {% id_key = ann[:name] || klass.name.gsub(/::/, "_").underscore %} {% service_id = id_key.is_a?(StringLiteral) ? id_key : id_key.stringify %} {% factory = if factory_ann = ann[:factory] if factory_ann.is_a? StringLiteral {klass.resolve, factory_ann} elsif factory_ann.is_a? TupleLiteral {factory_ann[0].resolve, factory_ann[1]} end elsif (class_initializer = klass.class.methods.find(&.annotation(ADI::Inject))) && (class_initializer.name.stringify != "new") # Class methods with ADI::Inject should act as a factory. # But only those not named `"new"`, as that's the default and we can't know about overloads of `initialize` at this point. {klass.resolve, class_initializer.name.stringify} else nil end # Validate the factory method exists and is a class method if one was found. if factory factory_class, factory_method = factory if factory_class.instance.has_method? factory_method raise "Failed to auto register service '#{service_id.id}'. Factory method '#{factory_method.id}' within '#{factory_class}' is an instance method." end unless factory_class.class.has_method? factory_method raise "Failed to auto register service '#{service_id.id}'. Factory method '#{factory_method.id}' within '#{factory_class}' does not exist." end end %} {% # Store raw tags - will be normalized by ProcessTags pass tags = ann["tags"] || [] of Nil unless tags.is_a? ArrayLiteral ann["tags"].raise "'tags' field of service '#{service_id.id}' must be an 'ArrayLiteral', got '#{tags.class_name.id}'." end %} # Generic services are somewhat coupled to the annotation, so do a check here in addition to those in `ResolveGenerics`. {% if !klass.type_vars.empty? && !ann["name"] ann.raise "Failed to auto register service for '#{klass}'. Generic services must explicitly provide a name." end %} # Apply calls to the underlying service, validating they exist. {% calls = [] of Nil if ann_calls = ann["calls"] ann_calls.each do |call| method = call[0] args = call[1] || nil if method.empty? method.raise "'calls' field of service '#{service_id.id}': method name cannot be empty." end unless klass.resolve.has_method?(method) method.raise "'calls' field of service '#{service_id.id}' (#{klass}): method does not exist." end calls << {method, args || [] of Nil} end end %} {% unless SERVICE_HASH[service_id].nil? ann.raise "Failed to auto register service for '#{service_id.id}' (#{klass}). It is already registered." end %} {% SERVICE_HASH[service_id] = { class: klass.resolve, factory: factory, shared: klass.class?, calls: calls, configurator: nil, tags: tags, public: ann["public"] == true, decorated_service: nil, bindings: {} of Nil => Nil, generics: ann.args, parameters: {} of Nil => Nil, referenced_services: [] of Nil, } %} {% end %} {% end %} {% end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/remove_unused_services.cr ================================================ # :nodoc: # # Removes services that are never used (not public and zero references). module Athena::DependencyInjection::ServiceContainer::RemoveUnusedServices macro included macro finished {% verbatim do %} {% SERVICE_REFERENCES.each do |service_id, ref_info| # Only remove if: not public AND no references if ref_info["public"] == false && ref_info["count"] == 0 SERVICE_HASH[service_id] = nil end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/resolve_parameter_placeholders.cr ================================================ # :nodoc: module Athena::DependencyInjection::ServiceContainer::ResolveParameterPlaceholders macro included macro finished {% verbatim do %} # Resolves `%parameter%` placeholders within configuration values. # E.g. `"https://%app.domain%/"` => `"https://example.com/"`. # # It is assumed that any user added parameters via another module have already happened. # Parameters added after this module will not be resolved. # # ## Processing strategy # # `to_process` is an array of `{key, value, collection, stack}` tuples. Since arrays are reference types, # we can push new items while iterating to achieve pseudo-recursion without actual recursion (which macros don't support). # # Each tuple tracks: # - `key`: the key within the collection to update # - `value`: the current value to inspect/resolve # - `collection`: the parent collection (CONFIG, a sub-hash, etc.) so we can write back resolved values # - `stack`: path segments for error messages (e.g., `["parameters", "app.name"]`) # # ## Supported value types # # * `StringLiteral` containing `%%` (escaped `%`) or `%param.name%` placeholders # * `HashLiteral` — each value is checked for placeholders # * NOTE: NamedTuple literals are _NOT_ supported as a terminal value, use a HashLiteral instead # * `ArrayLiteral`/`TupleLiteral` — each element is checked for placeholders # * `NamedTupleLiteral` — recursively expanded into `to_process` for its children # # ## Placeholder resolution # # `StringLiteral#gsub` with a block replaces each `%param%` with its resolved value and `%%` with a literal `%`. # # When the entire string is a single placeholder (e.g., `"%app.debug%"`), the resolved value is looked up # directly from CONFIG rather than using the gsub result. This is critical for two reasons: # 1. It preserves non-string types (a `BoolLiteral` stays a `BoolLiteral`, not `"false"`) # 2. It preserves reference semantics for collections — if `%app.array%` resolves to an `ArrayLiteral` # whose elements haven't been resolved yet, keeping the reference means those elements will be # updated in-place when they're resolved later in the loop. # # If a resolved value still contains placeholders (e.g., because it references another parameter that # hasn't been resolved yet), it is pushed back into `to_process` for another pass. # # For hash/array values, the re-process entry pushes the whole sub-collection (`h[k]`) as the value, # which matches the assignment path (`h[k][sk]` / `h[k][a_idx]`) minus the sub-key/index. {% to_process = CONFIG.to_a.map { |tup| {tup[0], tup[1], CONFIG, [tup[0]]} } to_process.each do |(k, v, h, stack)| if v.is_a?(NamedTupleLiteral) v.to_a.each do |(sk, sv)| to_process << {sk, sv, v, stack + [sk]} end else if v.is_a?(StringLiteral) && v =~ /%%|%([^%\s]++)%/ # gsub replaces each %param% with its resolved value, and %% with a literal %. # matches[1] is the captured parameter name, or nil for %% matches. new_value = v.gsub /%%|%([^%\s]++)%/ do |str, matches| if param_name = matches[1] resolved_value = CONFIG["parameters"][param_name] if resolved_value == nil path = "#{stack[0]}" stack[1..].each do |p| path += "[#{p}]" end param_name.raise "#{stack[0] == "parameters" ? "Parameter".id : "Configuration value".id} '#{path.id}' referenced unknown parameter '#{param_name.id}'." end # gsub always returns a StringLiteral, so non-string values must be stringified here. # The actual type is preserved below for single-placeholder values. if resolved_value.is_a?(StringLiteral) resolved_value else resolved_value.stringify end else '%' end end # When the entire value is a single placeholder (e.g., "%app.debug%"), replace the gsub # result with a direct lookup. This preserves non-string types (BoolLiteral, NumberLiteral, # etc.) and, critically, reference semantics for collections — an ArrayLiteral whose elements # haven't been resolved yet will be updated in-place when the loop processes them later. if v =~ /^%([^%\s]++)%$/ new_value = CONFIG["parameters"][v.gsub(/%/, "")] end # If fully resolved, assign it. Otherwise push back for another pass. if !new_value.is_a?(StringLiteral) || (new_value.is_a?(StringLiteral) && !(new_value =~ /%%|%([^%\s]++)%/)) h[k] = new_value else to_process << {k, new_value, h, stack} end elsif v.is_a?(HashLiteral) # Same placeholder resolution as above, applied to each hash value. v.each do |sk, sv| if sv.is_a?(StringLiteral) && sv =~ /%%|%([^%\s]++)%/ new_value = sv.gsub /%%|%([^%\s]++)%/ do |str, matches| if param_name = matches[1] resolved_value = CONFIG["parameters"][param_name] if resolved_value == nil path = "#{stack[0]}" stack[1..].each do |p| path += "[#{p}]" end param_name.raise "#{stack[0] == "parameters" ? "Parameter".id : "Configuration value".id} '#{path.id}[#{sk}]' referenced unknown parameter '#{param_name.id}'." end if resolved_value.is_a?(StringLiteral) resolved_value else resolved_value.stringify end else '%' end end # See single-placeholder comment above — same type/reference preservation applies. if sv =~ /^%([^%\s]++)%$/ new_value = CONFIG["parameters"][sv.gsub(/%/, "")] end if !new_value.is_a?(StringLiteral) || (new_value.is_a?(StringLiteral) && !(new_value =~ /%%|%([^%\s]++)%/)) h[k][sk] = new_value else # Re-process the whole hash, not just the single value, since h[k][sk] is the assignment path. to_process << {k, h[k], h, stack} end end end elsif v.is_a?(ArrayLiteral) || v.is_a?(TupleLiteral) # Same placeholder resolution as above, applied to each array/tuple element. v.each_with_index do |av, a_idx| if av.is_a?(StringLiteral) && av =~ /%%|%([^%\s]++)%/ new_value = av.gsub /%%|%([^%\s]++)%/ do |str, matches| if param_name = matches[1] resolved_value = CONFIG["parameters"][param_name] if resolved_value == nil path = "#{stack[0]}" stack[1..].each do |p| path += "[#{p}]" end param_name.raise "#{stack[0] == "parameters" ? "Parameter".id : "Configuration value".id} '#{path.id}[#{a_idx}]' referenced unknown parameter '#{param_name.id}'." end if resolved_value.is_a?(StringLiteral) resolved_value else resolved_value.stringify end else '%' end end # See single-placeholder comment above — same type/reference preservation applies. if av =~ /^%([^%\s]++)%$/ new_value = CONFIG["parameters"][av.gsub(/%/, "")] end if !new_value.is_a?(StringLiteral) || (new_value.is_a?(StringLiteral) && !(new_value =~ /%%|%([^%\s]++)%/)) h[k][a_idx] = new_value else to_process << {k, h[k], h, [] of Nil} end end end end end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/resolve_tagged_iterators.cr ================================================ # :nodoc: # # Sets the value of parameters with the `ADI::TaggedIterator` annotation automatically. # See also DefineTaggedIterators for how `Iterator` types are handled. module Athena::DependencyInjection::ServiceContainer::ResolveTaggedIterators macro included macro finished {% verbatim do %} {% iterator_service_map = {} of Nil => Nil SERVICE_HASH.each do |service_id, definition| definition["parameters"].each do |_, param| if ann = param["declaration"].annotation ADI::TaggedIterator param_type = param["resolved_restriction"] base_collection_type = param_type.name(generic_args: false).stringify unless {"Enumerable", "Iterator", "Indexable"}.includes? base_collection_type param["declaration"].raise <<-TEXT Failed to register service '#{service_id.id}' (#{definition["class"]}). \ Collection type must be one of 'Indexable', 'Iterator', or 'Enumerable'. Got '#{param_type.name(generic_args: false).id}'. TEXT end enumerable_type = param_type.type_vars.first # If no tag name was explicitly provided, assume its the FQN of the enumerable type tag_name = if name = ann[0] if name.is_a?(Path) name.resolve else name end elsif enumerable_type.union? ann.raise "Unable to support unions" else enumerable_type.stringify end iterator_service_map[iterator_id = "#{service_id.id}_iterator"] = { type: enumerable_type, services: (TAG_HASH[tag_name] || [] of Nil) .sort_by { |(_tmp, attributes)| -(attributes["priority"] || 0) } .map(&.first.id), } param["value"] = "@#{iterator_id.id}" end end end %} # Define iterator types {% for name, metadata in iterator_service_map %} ADI.service_iterator({{name}}, {{metadata["services"]}}) {% end %} # Register iterator services {% iterator_service_map.each do |name, metadata| SERVICE_HASH[name] = { class: name.camelcase, bindings: {} of Nil => Nil, generics: [metadata["type"], metadata["services"].size] of Nil, calls: [] of Nil, referenced_services: metadata["services"], parameters: { container: {value: "self".id}, }, } end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/resolve_values.cr ================================================ # :nodoc: module Athena::DependencyInjection::ServiceContainer::ResolveValues macro included macro finished {% verbatim do %} # Resolves the constructor arguments for each service in the container. # The values should be provided in priority order: # # 1. Explicit value on annotation => _id # 2. Bindings (typed and untyped) => `ADI.bind` > `ADI::Autoconfigure` # 3. Autowire => By direct type, or parameter name # 4. Service Alias => Service registered with `AsAlias` of a specific interface # 5. Default value => some_value : Int32 = 123 # 6. Nilable Type => nil {% SERVICE_HASH.each do |service_id, definition| # Use a dedicated array var such that we can use the pseudo recursion trick parameters = definition["parameters"].map { |_tmp, param| {param["value"], param, nil} } parameters.each do |(unresolved_value, param, reference)| # Parameter reference if unresolved_value.is_a?(StringLiteral) && unresolved_value.starts_with?('%') && unresolved_value.ends_with?('%') resolved_value = CONFIG["parameters"][unresolved_value[1..-2]] # Service reference elsif unresolved_value.is_a?(StringLiteral) && unresolved_value.starts_with?('@') service_name = unresolved_value[1..-1] # Resolve the alias ID to its underlying ID # For explicit @service_name references, take the first alias entry if aliases = ALIASES[service_name] service_name = aliases.first["id"] end if SERVICE_HASH[service_name].nil? unresolved_value.raise "Service '#{service_id.id}' (#{definition["class"]}) references undefined service '#{service_name.id}'." end resolved_value = service_name.id # Tagged services elsif unresolved_value.is_a?(StringLiteral) && unresolved_value.starts_with?('!') tag_name = unresolved_value[1..] # Sort based on tag priority. Services without a priority will be last in order of definition tagged_services = (TAG_HASH[tag_name] || [] of Nil).sort_by { |(_tmp, attributes)| -(attributes["priority"] || 0) } if param["resolved_restriction"].type_vars.first.resolve < ADI::Proxy # Track proxy references to ensure getters are generated definition["referenced_services"] = [] of Nil unless definition["referenced_services"] tagged_services.each do |(id, attributes)| definition["referenced_services"] << id end tagged_services = tagged_services.map do |(id, attributes)| {"ADI::Proxy.new(#{id}, ->#{id.id})".id} end end resolved_value = tagged_services.map &.first.id # Array, could contain nested references elsif unresolved_value.is_a?(ArrayLiteral) || unresolved_value.is_a?(TupleLiteral) # Pseudo recurse over each array element resolved_value = unresolved_value unresolved_value.each_with_index do |v, idx| parameters << {v, param, {type: "array", key: idx, value: resolved_value}} end # Hash, could contain nested references elsif unresolved_value.is_a?(HashLiteral) # Pseudo recurse over each key/value pair resolved_value = unresolved_value unresolved_value.each do |k, v| parameters << {v, param, {type: "hash", key: k, value: resolved_value}} end # Bound value, only apply if value was not already resolved # Value is re-processed to resolve the underlying value, use the reference value to know not to do it again elsif (bv = definition["bindings"][param["name"].id]) != nil && !reference resolved_value = nil parameters << {bv, param, {type: "scalar"}} # Scalar value else resolved_value = unresolved_value end if reference && ("array" == reference["type"] || "hash" == reference["type"]) reference["value"][reference[:key]] = resolved_value else param["value"] = resolved_value end # Clear temp vars to avoid confusion resolved_value = nil unresolved_value = nil end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/validate_arguments.cr ================================================ # :nodoc: # # Compiler pass that validates user-provided configuration against extension schemas. # # Uses a queue-based approach (values_to_resolve) to handle nested validation: # - Start with the top-level config value # - For array_of/map_of/object_of, queue each element/member for validation # - Process until queue is empty # # Key concepts: # - prop_type: Can be TypeNode (for type checking) or NamedTupleLiteral (member map for nested objects) # - schema_member_map_prop_cache: Prevents re-processing the same property's members (since queued items share the same `prop`, we only want to expand members once) # - map_of properties identified by `prop["type"] <= Hash` # - Member entries can be TypeDeclaration (simple) or NamedTupleLiteral (object_schema ref) module Athena::DependencyInjection::ServiceContainer::ValidateArguments macro included macro finished {% verbatim do %} # Validate the user provided configuration against the defined schema first, # before validating service arguments. This ensures config validation errors # are reported before service parameter errors that may result from bad config. {% _nil = nil # This is mostly copied from `MergeExtensionConfig` code, # ideally would be nice to be able to not share state like this but :shrug: this works for now. # # That would be easiest with some macros defs to share the macro logic of building out this map. EXTENSION_SCHEMA_PROPERTIES_MAP.each do |ext_name, schema_properties| user_provided_extension_config = CONFIG[ext_name] schema_member_map_prop_cache = {} of Nil => Nil schema_properties.each do |(prop, ext_path)| user_provided_extension_config_for_current_property = user_provided_extension_config ext_path.each do |p| user_provided_extension_config_for_current_property = user_provided_extension_config_for_current_property[p] if user_provided_extension_config_for_current_property end # If this schema property maps to an actual property, and the user provided some configuration value for that property, move onto validating the provided value's validity. # Otherwise, if that property was not provided, and is required, raise an exception. if prop # If the configuration property was not provided and is required, throw an error if !user_provided_extension_config_for_current_property if prop["default"].is_a?(Nop) && !prop["type"].resolve.nilable? path = [ext_name] unless ext_path.empty? ext_path.each do |p| path << if p.is_a?(NumberLiteral) "[#{p}]" else "#{p}" end end end prop["root"].raise "Required configuration property '#{path.join('.').id}.#{prop["name"]} : #{prop["type"]}' must be provided." end else if (config_value = user_provided_extension_config_for_current_property[prop["name"]]) != nil config_value = config_value.is_a?(Path) ? config_value.resolve : config_value # Tuple of: # 0 - type of the property in the schema # 1 - the value # 2 - an array representing the path to this property in the schema values_to_resolve = [{prop["type"], config_value, ext_path + [prop["name"]]}] values_to_resolve.each_with_index do |(prop_type, cfv, stack), idx| resolved_type = if cfv.nil? Nil elsif cfv.is_a?(BoolLiteral) Bool elsif cfv.is_a?(StringLiteral) String elsif cfv.is_a?(SymbolLiteral) Symbol elsif cfv.is_a?(RegexLiteral) Regex elsif cfv.is_a?(ArrayLiteral) # Because each value to resolve has the same `prop`, we only want to process the prop's members once. # Otherwise next iterations cfv will be correct, but the prop_type will be a named tuple literal. if schema_member_map_prop_cache[prop["name"]] == nil && (member_map = prop["members"]) schema_member_map_prop_cache[prop["name"]] = true cfv.each_with_index do |v, v_idx| values_to_resolve << {member_map, v, stack + [v_idx]} end Array else # If the type is a union, extract the first non-nilable type. # Then fallback on the type of the property if no type could be extracted/was provided non_nilable_prop_type = prop_type.union? ? prop_type.union_types.reject(&.nilable?).first : prop_type array_type = (cfv.of || cfv.type) || non_nilable_prop_type.type_vars.first cfv.each_with_index do |v, v_idx| values_to_resolve << {array_type.resolve, v, stack + [v_idx]} end parse_type("Array(#{array_type})").resolve end elsif cfv.is_a?(NumberLiteral) kind = cfv.kind if kind.starts_with? 'i' parse_type("Int#{kind[1..].id}").resolve elsif kind.starts_with? 'u' parse_type("UInt#{kind[1..].id}").resolve elsif kind.starts_with? 'f' parse_type("Float#{kind[1..].id}").resolve else cfv.raise "BUG: Unexpected number literal value" end elsif cfv.is_a?(TypeNode) cfv elsif cfv.is_a?(NamedTupleLiteral) # NamedTupleLiteral handles: map_of values, object_of values, and inline NamedTuples. # # Check if this is a map_of property (type is Hash and has members) if prop["type"] <= Hash && schema_member_map_prop_cache[prop["name"]] == nil && (member_map = prop["members"]) schema_member_map_prop_cache[prop["name"]] = true cfv.each do |hash_key, v| if hash_key != "__nil" values_to_resolve << {member_map, v, stack + [hash_key]} end end Hash else # Because each value to resolve has the same `prop`, we only want to process the prop's members once. # Otherwise next iterations cfv will be correct, but the prop_type will be a named tuple literal. if schema_member_map_prop_cache[prop["name"]] == nil && (member_map = prop["members"]) schema_member_map_prop_cache[prop["name"]] = true prop_type = member_map end cfv.each do |k, v| nt_key_type = prop_type[k] if nt_key_type == nil && k != "__nil" path = "#{stack[0]}" stack[1..].each do |p| path += if p.is_a?(NumberLiteral) "[#{p}]" else ".#{p}" end end # Filter out internal __nil key for cleaner error message display_type = "{#{prop_type.keys.reject { |dk| dk.stringify == "__nil" }.map { |dk| "#{dk}: #{prop_type[dk]}" }.join(", ").id}}" cfv.raise "Expected configuration value '#{ext_name.id}.#{path.id}' to be a '#{display_type.id}', but encountered unexpected key '#{k}'." elsif k == "__nil" # no-op else type = if nt_key_type.is_a?(TypeDeclaration) nt_key_type.type.resolve elsif nt_key_type.is_a?(NamedTupleLiteral) # Nested object_schema reference - pass the members map nt_key_type["members"] else nt_key_type.resolve end values_to_resolve << {type, v, stack + [k]} end end missing_keys = prop_type.keys.reject { |k| k.stringify == "__nil" } - cfv.keys unless missing_keys.empty? missing_keys.each do |mk| mt = prop_type[mk] can_be_missing = if mt.is_a?(TypeNode) mt.nilable? elsif mt.is_a?(TypeDeclaration) mt.type.resolve.nilable? || !mt.value.is_a?(Nop) elsif mt.is_a?(NamedTupleLiteral) # For nested object_schema references !mt["value"].is_a?(Nop) else false end unless can_be_missing path = "#{stack[0]}" stack[1..].each do |p| path += if p.is_a?(NumberLiteral) "[#{p}]" else ".#{p}" end end type = prop_type[mk] type = type.is_a?(TypeDeclaration) ? type.type : type cfv.raise "Configuration value '#{ext_name.id}.#{path.id}' is missing required value for '#{mk}' of type '#{type}'." end end end nil end end if resolved_type # Handles outer most typing issues. # Skip type check when prop_type is a NamedTupleLiteral (member map for nested validation) if resolved_type.is_a?(TypeNode) && prop_type.is_a?(TypeNode) && !(resolved_type <= prop_type) path = "#{stack[0]}" stack[1..].each do |p| path += if p.is_a?(NumberLiteral) "[#{p}]" else ".#{p}" end end cfv.raise "Expected configuration value '#{ext_name.id}.#{path.id}' to be a '#{prop_type}', but got '#{resolved_type}'." end end end elsif prop["default"].is_a?(Nop) && !prop["type"].resolve.nilable? path = [ext_name] unless ext_path.empty? ext_path.each do |p| path << if p.is_a?(NumberLiteral) "[#{p}]" else "#{p}" end end end prop["root"].raise "Required configuration property '#{path.join('.').id}.#{prop["name"]} : #{prop["type"]}' must be provided." end end end end end %} # Validate the arguments for each service {% SERVICE_HASH.each do |service_id, definition| definition["parameters"].each do |_, param| error = nil # Type of the resolved argument matches the method param restriction if param["value"] != nil value = param["value"] restriction = param["resolved_restriction"] if restriction && restriction <= String && (!value.is_a?(StringLiteral) && !value.is_a?(Call)) type_name = if value.is_a?(NumberLiteral) kind = value.kind if kind.starts_with? 'i' "Int#{kind[1..].id}" elsif kind.starts_with? 'u' "UInt#{kind[1..].id}" elsif kind.starts_with? 'f' "Float#{kind[1..].id}" else value.class_name end elsif value.is_a?(BoolLiteral) "Bool" elsif value.is_a?(SymbolLiteral) "Symbol" else value.class_name end error = "Service '#{service_id.id}' (#{definition["class"]}): parameter expects a 'String' but got '#{type_name.id}'." end if (s = SERVICE_HASH[value.stringify]) && (klass = s["class"]).is_a?(TypeNode) && !(klass <= restriction) error = "Service '#{service_id.id}' (#{definition["class"]}): parameter expects '#{restriction}' but" \ " the resolved service '#{value.id}' is of type '#{s["class"].id}'." end elsif !param["resolved_restriction"].nilable? error = "Failed to resolve argument for service '#{service_id.id}' (#{definition["class"]})." end if error param["declaration"].raise error end end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/compiler_passes/validate_generics.cr ================================================ # :nodoc: # # Validate generic services. module Athena::DependencyInjection::ServiceContainer::ValidateGenerics macro included macro finished {% verbatim do %} {% SERVICE_HASH.each do |service_id, definition| klass = definition["class"] generics = definition["generics"] if !klass.type_vars.empty? && generics.empty? klass.raise "Failed to register service '#{service_id.id}'. Generic services must provide the types to use via the 'generics' field." end if klass.type_vars.size != generics.size klass.raise "Failed to register service '#{service_id.id}'. Expected #{klass.type_vars.size} generics types got #{generics.size}." end end %} {% end %} end end end ================================================ FILE: src/components/dependency_injection/src/extension.cr ================================================ # 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: # # Array of schema property definitions. Each entry is a NamedTupleLiteral with: # - name: property name # - type: Crystal type (e.g., String, Int32, Hash for map_of) # - default: default value (Nop if required) # - root: root property name for error messages # - members: (optional) for array_of/object_of/map_of, a NamedTupleLiteral where: # - keys are member names # - values are either TypeDeclaration (simple members) or NamedTupleLiteral (object_schema references, with keys: type, value, members) # - global: whether the type uses global namespace (::) OPTIONS = [] of Nil # :nodoc: # # Registry of reusable object schemas defined via `object_schema`. # Keys are schema names (e.g., "JwtConfig"), values are NamedTupleLiterals with: # - members: member map (same structure as OPTIONS members) # - doc: documentation string OBJECT_SCHEMAS = {} of Nil => Nil # This must be public so its included in docs and mkdocs can access it. CONFIG_DOCS = [] of Nil end # Defines a reusable object schema that can be referenced by name in other schema definitions. # This is useful for defining nested object structures or sharing schemas between properties. # # ``` # module Schema # include ADI::Extension::Schema # # object_schema JwtConfig, # secret : String, # algorithm : String = "hmac.sha256" # # map_of hubs, # url : String, # jwt : JwtConfig # end # ``` # # NOTE: Object schemas must be defined before they are referenced. macro object_schema(name, *members) {% __nil = nil doc_string = "" member_doc_map = {} of Nil => Nil in_member_docblock = false current_member = nil @caller.first.doc.lines.each_with_index do |line, idx| if "---" == line in_member_docblock = true elsif in_member_docblock && line.starts_with?(">>") member_text = line[2..] colon_idx = nil member_text.chars.each_with_index { |c, i| colon_idx = i if c == ':' && colon_idx == nil } current_member = member_text[0...colon_idx] docs = member_text[(colon_idx + 1)..] member_doc_map[current_member.id.stringify] = "#{docs.id}\\n" elsif current_member member_doc_map[current_member.id.stringify] += "#{line.id}\\n" elsif "---" == line && in_member_docblock in_member_docblock = false current_member = nil else doc_string += "#{idx == 0 ? "".id : "\# ".id}#{line.id}\n" end end 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 # Check if this member type references another object_schema member_type = m.type if nested_schema = OBJECT_SCHEMAS[member_type.id.stringify] member_map[m.var.id] = {type: m.type, value: m.value, members: nested_schema["members"]} else member_map[m.var.id] = m end if nested = OBJECT_SCHEMAS[member_type.id.stringify] nested_doc = (nested["doc"] || "").strip.gsub(/\n\# ?/, "\\n").gsub(/\n/, "\\n") member_doc = (member_doc_map[m.var.stringify] || nested_doc).strip.gsub(/"/, "\\\"") members_string += %({"name":"#{m.var.id}","type":"`#{m.type.id}`","default":"`#{m.value.id}`","doc":"#{member_doc.id}","members":#{nested["members_string"].id}}) else 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}"}) end members_string += "," unless idx == members.size - 1 end members_string += "]" OBJECT_SCHEMAS[name.id.stringify] = {members: member_map, doc: doc_string, members_string: members_string} %} 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 # Special case: Allow using NoReturn to "inherit" type from the TypeDeclaration for Array types. # 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 "#{declaration.type.id}.new".id else declaration.value end OPTIONS << {name: declaration.var.id, type: declaration.type.resolve, default: default, root: declaration, global: declaration.type.is_a?(Path) && declaration.type.global?} CONFIG_DOCS << %({"name":"#{declaration.var.id}","type":"`#{declaration.type.id}`","default":"`#{default.id}`"}).id %} # {{ @caller.first.doc_comment }} 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 private macro process_object_of(name_or_assign, *members, nilable) {% __nil = nil if name_or_assign.is_a?(Assign) name = name_or_assign.target.id default = name_or_assign.value else name = name_or_assign.name default = pp # Hack to ensure the default is a Nop to differentiate it from `nil` end doc_string = "" member_doc_map = {} of Nil => Nil in_member_docblock = false current_member = nil @caller.first.doc.lines.each_with_index do |line, idx| # --- denotes member docblock start/end if "---" == line in_member_docblock = true # >> denotes start of property docs elsif in_member_docblock && line.starts_with?(">>") current_member, docs = line[2..].split(':') member_doc_map[current_member.id.stringify] = "#{docs.id}\\n" elsif current_member member_doc_map[current_member.id.stringify] += "#{line.id}\\n" elsif "---" == line && in_member_docblock in_member_docblock = false current_member = nil else # The line where the docs are added in already have a `#`, # so no need to doc_string += "#{idx == 0 ? "".id : "\# ".id}#{line.id}\n" end end 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 # Check if this member type references an object_schema member_type = m.type if nested_schema = OBJECT_SCHEMAS[member_type.id.stringify] member_map[m.var.id] = {type: m.type, value: m.value, members: nested_schema["members"]} else member_map[m.var.id] = m end if nested = OBJECT_SCHEMAS[member_type.id.stringify] nested_doc = (nested["doc"] || "").strip.gsub(/\n\# ?/, "\\n").gsub(/\n/, "\\n") member_doc = (member_doc_map[m.var.stringify] || nested_doc).strip.gsub(/"/, "\\\"") members_string += %({"name":"#{m.var.id}","type":"`#{m.type.id}`","default":"`#{m.value.id}`","doc":"#{member_doc.id}","members":#{nested["members_string"].id}}) else 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}"}) end members_string += "," unless idx == members.size - 1 end members_string += "]" OPTIONS << {name: name, type: (type = (nilable ? parse_type("NamedTuple?").resolve : NamedTuple)), default: nilable ? nil : default, root: name, members: member_map, global: type.is_a?(Path) && type.global?} CONFIG_DOCS << %({"name":"#{name.id}","type":"`#{type.id}`","default":"`#{(nilable && default.is_a?(Nop) ? nil : default).id}`","members":#{members_string.id}}).id %} # {{ doc_string.strip.id }} abstract def {{name.id}} end # 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 private macro process_array_of(name_or_assign, *members, nilable) {% __nil = nil if name_or_assign.is_a?(Assign) name = name_or_assign.target.id default = name_or_assign.value else name = name_or_assign.name default = [] of NoReturn end doc_string = "" member_doc_map = {} of Nil => Nil in_member_docblock = false current_member = nil @caller.first.doc.lines.each_with_index do |line, idx| # --- denotes member docblock start/end if "---" == line in_member_docblock = true # >> denotes start of property docs elsif in_member_docblock && line.starts_with?(">>") current_member, docs = line[2..].split(':') member_doc_map[current_member.id.stringify] = "#{docs.id}\\n" elsif current_member member_doc_map[current_member.id.stringify] += "#{line.id}\\n" elsif "---" == line && in_member_docblock in_member_docblock = false current_member = nil else # The line where the docs are added in already have a `#`, # so no need to doc_string += "#{idx == 0 ? "".id : "\# ".id}#{line.id}\n" end end 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 # Check if this member type references an object_schema member_type = m.type if nested_schema = OBJECT_SCHEMAS[member_type.id.stringify] member_map[m.var.id] = {type: m.type, value: m.value, members: nested_schema["members"]} else member_map[m.var.id] = m end if nested = OBJECT_SCHEMAS[member_type.id.stringify] nested_doc = (nested["doc"] || "").strip.gsub(/\n\# ?/, "\\n").gsub(/\n/, "\\n") member_doc = (member_doc_map[m.var.stringify] || nested_doc).strip.gsub(/"/, "\\\"") members_string += %({"name":"#{m.var.id}","type":"`#{m.type.id}`","default":"`#{m.value.id}`","doc":"#{member_doc.id}","members":#{nested["members_string"].id}}) else 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}"}) end members_string += "," unless idx == members.size - 1 end members_string += "]" OPTIONS << {name: name, type: (type = (nilable ? parse_type("Array?").resolve : Array)), default: nilable ? nil : default, root: name, members: member_map, global: type.is_a?(Path) && type.global?} CONFIG_DOCS << %({"name":"#{name.id}","type":"`#{type.id}`","default":"`#{(nilable && default.empty? ? nil : default).id}`","members":#{members_string.id}}).id %} # {{ doc_string.strip.id }} abstract def {{name.id}} end # Defines a map where keys are arbitrary names and values follow a typed object schema. # This is useful for configuration patterns where named entries share a common structure. # ``` # module Schema # include ADI::Extension::Schema # # map_of hubs, # url : String, # port : Int32 = 5432 # end # # ADI.register_extension "test", Schema # # ADI.configure({ # test: { # hubs: { # primary: {url: "localhost"}, # secondary: {url: "remote", port: 5433}, # }, # }, # }) # ``` # # If not provided, the property defaults to an empty map. macro map_of(name, *members) process_map_of({{name}}, {{members.splat}}, nilable: false) end # Same as `#map_of` but makes the default value of the property `nil`. macro map_of?(name, *members) process_map_of({{name}}, {{members.splat}}, nilable: true) end private macro process_map_of(name_or_assign, *members, nilable) {% __nil = nil if name_or_assign.is_a?(Assign) name = name_or_assign.target.id default = name_or_assign.value else name = name_or_assign.name default = {__nil: nil} end doc_string = "" member_doc_map = {} of Nil => Nil in_member_docblock = false current_member = nil @caller.first.doc.lines.each_with_index do |line, idx| if "---" == line in_member_docblock = true elsif in_member_docblock && line.starts_with?(">>") member_text = line[2..] colon_idx = nil member_text.chars.each_with_index { |c, i| colon_idx = i if c == ':' && colon_idx == nil } current_member = member_text[0...colon_idx] docs = member_text[(colon_idx + 1)..] member_doc_map[current_member.id.stringify] = "#{docs.id}\\n" elsif current_member member_doc_map[current_member.id.stringify] += "#{line.id}\\n" elsif "---" == line && in_member_docblock in_member_docblock = false current_member = nil else doc_string += "#{idx == 0 ? "".id : "\# ".id}#{line.id}\n" end end members_string = "[" member_map = {__nil: nil} # Build the member_map which describes the schema for each map entry's value. # Each member becomes either: # - A TypeDeclaration directly (e.g., `url : String`) for simple types # - A NamedTupleLiteral with {type:, value:, members:} for object_schema references members.each_with_index do |m, idx| m.raise "All members must be `TypeDeclaration`s." unless m.is_a? TypeDeclaration # Check if this member type references an object_schema member_type = m.type if nested_schema = OBJECT_SCHEMAS[member_type.id.stringify] member_map[m.var.id] = {type: m.type, value: m.value, members: nested_schema["members"]} else member_map[m.var.id] = m end if nested = OBJECT_SCHEMAS[member_type.id.stringify] nested_doc = (nested["doc"] || "").strip.gsub(/\n\# ?/, "\\n").gsub(/\n/, "\\n") member_doc = (member_doc_map[m.var.stringify] || nested_doc).strip.gsub(/"/, "\\\"") members_string += %({"name":"#{m.var.id}","type":"`#{m.type.id}`","default":"`#{m.value.id}`","doc":"#{member_doc.id}","members":#{nested["members_string"].id}}) else 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}"}) end members_string += "," unless idx == members.size - 1 end members_string += "]" # map_of uses Hash as type marker (checked in compiler passes via `prop["type"] <= Hash`) OPTIONS << {name: name, type: (type = (nilable ? parse_type("Hash?").resolve : Hash)), default: nilable ? nil : default, root: name, members: member_map, global: type.is_a?(Path) && type.global?} CONFIG_DOCS << %({"name":"#{name.id}","type":"`#{type.id}`","default":"`#{(nilable && default.keys.reject { |k| k.stringify == "__nil" }.empty? ? nil : default).id}`","members":#{members_string.id}}).id %} # {{ doc_string.strip.id }} abstract def {{name.id}} end end ================================================ FILE: src/components/dependency_injection/src/proxy.cr ================================================ # Represents a lazily initialized service. # See the "Service Proxies" section within `ADI::Register`. struct Athena::DependencyInjection::Proxy(O) forward_missing_to self.instance # :nodoc: delegate :==, :===, :=~, :hash, :tap, :not_nil!, :dup, :clone, :try, to: self.instance # Returns proxied service `O`; instantiating it if it has not already been. getter instance : O { @instantiated = true; @loader.call } # Returns the service ID (name) of the proxied service. getter service_id : String # Returns whether the proxied service has been instantiated yet. getter? instantiated : Bool = false def initialize(@service_id : String, @loader : Proc(O)); end # Returns the type of the proxied service. def service_type : O.class O end end ================================================ FILE: src/components/dependency_injection/src/service_container.cr ================================================ class Athena::DependencyInjection::ServiceContainer; end require "./compiler_passes/*" # Where the instantiated services live. # # If a service is public, a getter based on the service's name as well as its type is defined. Otherwise, services are only available via constructor DI. # # TODO: Reduce the amount of duplication when [this issue](https://github.com/crystal-lang/crystal/pull/9091) is resolved. class Athena::DependencyInjection::ServiceContainer # :nodoc: # # Define a hash to store services while the container is being built # Key is the ID of the service and the value is another hash containing its arguments, type, etc. SERVICE_HASH = {} of Nil => Nil # :nodoc: # # Maps services to their aliases # # Hash(String | TypeNode, Array(NamedTuple(id: String, public: Bool, name: String?))) ALIASES = {} of Nil => Nil # Define a hash to store the service ids for each tag. # # Tag Name, service_id, array attributes # Hash(String, Hash(String, Array(NamedTuple))) private TAG_HASH = {} of Nil => Nil # :nodoc: EXTENSIONS = {} of Nil => Nil # :nodoc: # # Tracks service reference counts for optimization passes. # Populated by AnalyzeServiceReferences pass. # Hash(String, NamedTuple(count: Int32, referenced_by: Array(String), public: Bool)) SERVICE_REFERENCES = {} of Nil => Nil # :nodoc: # # Holds the compiler pass configuration, including the type of each pass, and the default order the built-in ones execute in. PASS_CONFIG = { # Global pre-optimization modules # Sets up common concepts so that future passes can leverage them before_optimization: { 1028 => [ # Ensure merged configuration is available MergeConfigs, MergeExtensionConfig, ], 100 => [ NormalizeDefinitions, RegisterServices, ProcessAliases, ProcessAutoconfigureAnnotations, ProcessTags, ProcessParameters, ValidateGenerics, ], }, # Prepare the services for usage by resolving arguments, parameters, and ensure validity of each service optimization: { 0 => [ ResolveParameterPlaceholders, ProcessBindings, ProcessAnnotationBindings, AutoWire, ResolveTaggedIterators, ResolveValues, ValidateArguments, ], }, # Determine what could be removed? before_removing: { # Framework passes (RegisterCommands, RegisterEventListenersPass) run at priority 0; analysis must run after them -50 => [AnalyzeServiceReferences], }, # Cleanup the container, removing unused services and such removing: { 0 => [RemoveUnusedServices], -10 => [InlineServiceDefinitions], }, # Codegen things that create types/methods within the container instance, such as the getters for each service after_removing: { -100 => [ DefineGetters, ], }, } macro finished {% passes = [] of Nil PASS_CONFIG.keys.each do |type| (p = PASS_CONFIG[type]).keys.sort_by { |tk| -tk }.each do |k| p[k].each do |pass| passes << pass end end end %} {% for pass in passes %} include {{pass.id}} {% end %} end end ================================================ FILE: src/components/dependency_injection/src/spec.cr ================================================ # A set of testing utilities/types to aid in testing `Athena::DependencyInjection` related types. # # ### Getting Started # # Require this module in your `spec_helper.cr` file. # # ``` # # This also requires "spec". # require "athena-dependency_injection/spec" # ``` module Athena::DependencyInjection::Spec # A mock implementation of `ADI::ServiceContainer` that be used within a testing context to allow for mocking out services without affecting the actual container outside of tests. # # An example of this is when integration testing service based [ATH::Controller][Athena::Framework::Controller]s. # Service dependencies that interact with an external source, like a third party API or a database, should most likely be mocked out. # However your other services should be left as is in order to get the most benefit from the test. # # ## Mocking # # The `ADI::ServiceContainer` is nothing more than a normal Crystal class with some instance variables and methods. # As such, mocking services is as easy as monkey patching `self` with the mocked versions, assuming of course they are of a compatible type. # # Given Crystal's lack of a robust mocking shard, it isn't as straightforward as other languages. # The best way at the moment is either using inheritance or interfaces (modules) to manually create a concrete test class/struct; # with the latter option being preferred as it would work for both structs and classes. # # For example, we can create a mock implementation of a type by extending it: # ``` # class MockMyService < MyService # def get_value # # Can now just return a static expected value. # # Test properties/constructor(s) can also be added to make it a bit more generic. # 1234 # end # end # ``` # # Because our mock extends `MyService`, it is a compatible type for anything typed as `MyService`. # # Another way to handle mocking is via interfaces (modules). # # ``` # module SomeInterface; end # # struct MockMyService # include SomeInterface # end # ``` # # Because our mock implements `SomeInterface`, it is a compatible type for anything typed as `SomeInterface`. # # NOTE: Service mocks do not need to registered as services themselves since they will need to be configured manually. # NOTE: The `type` argument as part of the `ADI::Register` annotation can be used to set the type of a service within the container. # See `ADI::Register@customizing-services-type` for more details. # # ### Dynamic Mocks # # A dynamic mock consists of adding a `setter` to `self` that allows setting the mocked service dynamically at runtime, # while keeping the original up until if/when it is replaced. # # ``` # class ADI::Spec::MockableServiceContainer # # The setter should be nilable as they're lazily initialized within the container. # setter my_service : MyServiceInterface? # end # # # ... # # # Now the `my_service` service can be replaced at runtime. # mock_container.my_service = MockMyService.new # # # ... # ``` # # ### Global Mocks # # Global mocks totally replace the original service, i.e. always return the mocked service. # # ``` # class ADI::Spec::MockableServiceContainer # # Global mocks should use the block based `getter` macro. # getter my_service : MyServiceInterface { MockMyService.new } # end # # # `MockMyService` will now be injected across the board when using `self`. # # ... # ``` # # ### Hybrid Mocks # # Dynamic and Global mocking can also be combined to allow having a default mock, but allow overriding if/when needed. # This can be accomplished by adding both a getter and setter to `self.` # # ``` # class ADI::Spec::MockableServiceContainer # # Hybrid mocks should use the block based `property` macro. # property my_service : MyServiceInterface { DefaultMockService.new } # end # # # ... # # # `DefaultMockService` will now be injected across the board by when using `self`. # # # But can still be replaced at runtime. # mock_container.my_service = CustomMockService.new # # # ... # ``` class MockableServiceContainer < ADI::ServiceContainer; end end ================================================ FILE: src/components/dotenv/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/dotenv/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/dotenv/CHANGELOG.md ================================================ # Changelog ## [0.2.1] - 2025-11-09 ### Fixed - Fix being unable to call `Athena::Dotenv.load` with a single file ([#609]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/dotenv/releases/tag/v0.2.1 [#609]: https://github.com/athena-framework/athena/pull/609 ## [0.2.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/dotenv/releases/tag/v0.2.0 [#428]: https://github.com/athena-framework/athena/pull/428 ## [0.1.3] - 2024-07-31 ### Changed - Update minimum `crystal` version to `~> 1.13.0` ([#433]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/dotenv/releases/tag/v0.1.3 [#433]: https://github.com/athena-framework/athena/pull/433 ## [0.1.2] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) ### Added - Add helper `Athena::Dotenv.load` method to create and load `.env` files in one call ([#363]) (George Dietrich) ### Fixed - Fixed error parsing ENV vars starting with `_` ([#346]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/dotenv/releases/tag/v0.1.2 [#346]: https://github.com/athena-framework/athena/pull/346 [#363]: https://github.com/athena-framework/athena/pull/363 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.1.1] - 2023-10-09 _Administrative release, no functional changes_ [0.1.1]: https://github.com/athena-framework/dotenv/releases/tag/v0.1.1 ## [0.1.0] - 2023-04-23 _Initial release._ [0.1.0]: https://github.com/athena-framework/dotenv/releases/tag/v0.1.0 ================================================ FILE: src/components/dotenv/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/dotenv/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2023 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/dotenv/README.md ================================================ # Dotenv [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/dotenv.svg)](https://github.com/athena-framework/dotenv/releases) Registers environment variables from a .env file. ## Getting Started Checkout the [Documentation](https://athenaframework.org/Dotenv). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/dotenv/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.2.0 ### Normalization of Exception types The namespace exception types live in has changed from `Athena::Dotenv::Exceptions` to `Athena::Dotenv::Exception`. Any usages of `dotenv` exception types will need to be updated. If using a `rescue` statement with a parent exception type, either from the `console` component or Crystal stdlib, double check it to ensure it'll still rescue what you are expecting it will. ================================================ FILE: src/components/dotenv/docs/README.md ================================================ The `Athena::Dotenv` component parses the `.env` files to make ENV vars stored within them accessible. Using [Environment variables](https://en.wikipedia.org/wiki/Environment_variable) (ENV vars) is a common practice to configure options that depend on where the application is run; allowing the application's configuration to be de-coupled from its code. E.g. anything that changes from one machine to another, such as database credentials. `.env` files are a convenient way to get the benefits of ENV vars, without taking on the extra complexity of other tools/abstractions until if/when they are needed. The file(s) can be defined at the root of your project for development, or placed next to the binary if running outside of a dev environment. ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-dotenv: github: athena-framework/dotenv version: ~> 0.2.0 ``` ## Usage In most cases all that needs to be done is: ```crystal require "athena-dotenv" # For most use cases, returns a `Athena::Dotenv` instance. dotenv = Athena::Dotenv.load # Loads .env # Multiple files may also be loaded if needed Athena::Dotenv.load ".env", ".env.local" ``` For more complex setups, the [Athena::Dotenv](/Dotenv/top_level/) instance can be manually instantiated. E.g. to use the other helper methods such as [#load_environment](), [#overload](), or [#populate]() ```crystal require "athena-dotenv" dotenv = Athena::Dotenv.new # Overrides existing variables dotenv.overload ".env.overrides" # Load all files for the current $APP_ENV # .env, .env.local, and .env.$APP_ENV.local or .env.$APP_ENV dotenv.load_environment ".env" ``` [Athena::Dotenv::Exception::Path](/Dotenv/Exception/Path/) error will be raised if the provided file was not found, or is not readable. ================================================ FILE: src/components/dotenv/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Dotenv site_url: https://athenaframework.org/Dotenv/ repo_url: https://github.com/athena-framework/dotenv nav: - Introduction: README.md - Back to Manual: project://. - API: - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-dotenv/src/athena-dotenv.cr source_locations: lib/athena-dotenv: https://github.com/athena-framework/dotenv/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/dotenv/shard.yml ================================================ name: athena-dotenv version: 0.2.1 crystal: ~> 1.13 license: MIT repository: https://github.com/athena-framework/dotenv documentation: https://athenaframework.org/Dotenv description: | Registers environment variables from a .env file. authors: - George Dietrich ================================================ FILE: src/components/dotenv/spec/athena-dotenv_spec.cr ================================================ require "./spec_helper" struct DotEnvTest < ASPEC::TestCase def initialize ENV.clear end @[DataProvider("env_data")] def test_parse(data : String, expected : Hash(String, String)) : Nil ENV["LOCAL"] = "local" ENV["REMOTE"] = "remote" Athena::Dotenv.new.parse(data).should eq expected end def env_data : Array tests = [ # Backslashes {"FOO=foo\\\\bar", {"FOO" => "foo\\bar"}}, {"FOO='foo\\\\bar'", {"FOO" => "foo\\\\bar"}}, {"FOO=\"foo\\\\bar\"", {"FOO" => "foo\\bar"}}, # Escaped backslash in front of variable {"BAR=bar\nFOO=foo\\\\$BAR", {"BAR" => "bar", "FOO" => "foo\\bar"}}, {"BAR=bar\nFOO='foo\\\\$BAR'", {"BAR" => "bar", "FOO" => "foo\\\\$BAR"}}, {"BAR=bar\nFOO=\"foo\\\\$BAR\"", {"BAR" => "bar", "FOO" => "foo\\bar"}}, {"FOO=foo\\\\\\$BAR", {"FOO" => "foo\\$BAR"}}, {"FOO='foo\\\\\\$BAR'", {"FOO" => "foo\\\\\\$BAR"}}, {"FOO=\"foo\\\\\\$BAR\"", {"FOO" => "foo\\$BAR"}}, # Spaces {"FOO=bar", {"FOO" => "bar"}}, {" FOO=bar ", {"FOO" => "bar"}}, {"FOO=", {"FOO" => ""}}, {"FOO=\n\n\nBAR=bar", {"FOO" => "", "BAR" => "bar"}}, {"FOO= ", {"FOO" => ""}}, {"FOO=\nBAR=bar", {"FOO" => "", "BAR" => "bar"}}, # Newlines {"\n\nFOO=bar\r\n\n", {"FOO" => "bar"}}, {"FOO=bar\r\nBAR=foo", {"FOO" => "bar", "BAR" => "foo"}}, {"FOO=bar\rBAR=foo", {"FOO" => "bar", "BAR" => "foo"}}, {"FOO=bar\nBAR=foo", {"FOO" => "bar", "BAR" => "foo"}}, # Quotes {"FOO=\"bar\"\n", {"FOO" => "bar"}}, {"FOO=\"bar'foo\"\n", {"FOO" => "bar'foo"}}, {"FOO='bar'\n", {"FOO" => "bar"}}, {"FOO='bar\"foo'\n", {"FOO" => "bar\"foo"}}, {"FOO=\"bar\\\"foo\"\n", {"FOO" => "bar\"foo"}}, {"FOO=\"bar\nfoo\"", {"FOO" => "bar\nfoo"}}, {"FOO=\"bar\\rfoo\"", {"FOO" => "bar\rfoo"}}, # Double quote expands to real `\r` {"FOO='bar\nfoo'", {"FOO" => "bar\nfoo"}}, {"FOO='bar\\rfoo'", {"FOO" => "bar\\rfoo"}}, # Single quotes keep the literal `\r` {"FOO='bar\nfoo'", {"FOO" => "bar\nfoo"}}, {"FOO=\" FOO \"", {"FOO" => " FOO "}}, {"FOO=\" \"", {"FOO" => " "}}, {"PATH=\"c:\\\\\"", {"PATH" => "c:\\"}}, {"FOO=\"bar\nfoo\"", {"FOO" => "bar\nfoo"}}, {"FOO=BAR\\\"", {"FOO" => "BAR\""}}, {"FOO=BAR\\'BAZ", {"FOO" => "BAR'BAZ"}}, {"FOO=\\\"BAR", {"FOO" => "\"BAR"}}, # Concatenated values {"FOO='bar''foo'\n", {"FOO" => "barfoo"}}, {"FOO='bar '' baz'", {"FOO" => "bar baz"}}, {"FOO=bar\nBAR='baz'\"$FOO\"", {"FOO" => "bar", "BAR" => "bazbar"}}, {"FOO='bar '\\'' baz'", {"FOO" => "bar ' baz"}}, # Comments {"#FOO=bar\nBAR=foo", {"BAR" => "foo"}}, {"#FOO=bar # Comment\nBAR=foo", {"BAR" => "foo"}}, {"FOO='bar foo' # Comment", {"FOO" => "bar foo"}}, {"FOO='bar#foo' # Comment", {"FOO" => "bar#foo"}}, {"# Comment\r\nFOO=bar\n# Comment\nBAR=foo", {"FOO" => "bar", "BAR" => "foo"}}, {"FOO=bar # Another comment\nBAR=foo", {"FOO" => "bar", "BAR" => "foo"}}, {"FOO=\n\n# comment\nBAR=bar", {"FOO" => "", "BAR" => "bar"}}, {"FOO=NOT#COMMENT", {"FOO" => "NOT#COMMENT"}}, {"FOO= # Comment", {"FOO" => ""}}, # Edge cases - no conversions, only strings as values {"FOO=0", {"FOO" => "0"}}, {"FOO=false", {"FOO" => "false"}}, {"FOO=null", {"FOO" => "null"}}, # Export {"export FOO=bar", {"FOO" => "bar"}}, {" export FOO=bar", {"FOO" => "bar"}}, # Variable expansion {"FOO=BAR\nBAR=$FOO", {"FOO" => "BAR", "BAR" => "BAR"}}, {"FOO=BAR\nBAR=\"$FOO\"", {"FOO" => "BAR", "BAR" => "BAR"}}, {"FOO=BAR\nBAR='$FOO'", {"FOO" => "BAR", "BAR" => "$FOO"}}, {"FOO_BAR9=BAR\nBAR=$FOO_BAR9", {"FOO_BAR9" => "BAR", "BAR" => "BAR"}}, {"FOO=BAR\nBAR=${FOO}Z", {"FOO" => "BAR", "BAR" => "BARZ"}}, {"FOO=BAR\nBAR=$FOO}", {"FOO" => "BAR", "BAR" => "BAR}"}}, {"FOO=BAR\nBAR=\\$FOO", {"FOO" => "BAR", "BAR" => "$FOO"}}, {"FOO=\" \\$ \"", {"FOO" => " $ "}}, {"FOO=\" $ \"", {"FOO" => " $ "}}, {"BAR=$LOCAL", {"BAR" => "local"}}, {"BAR=$REMOTE", {"BAR" => "remote"}}, {"FOO=$NOTDEFINED", {"FOO" => ""}}, {"FOO=BAR\nBAR=${FOO:-TEST}", {"FOO" => "BAR", "BAR" => "BAR"}}, {"FOO=BAR\nBAR=${NOTDEFINED:-TEST}", {"FOO" => "BAR", "BAR" => "TEST"}}, {"FOO=\nBAR=${FOO:-TEST}", {"FOO" => "", "BAR" => "TEST"}}, {"FOO=\nBAR=$FOO:-TEST}", {"FOO" => "", "BAR" => "TEST}"}}, {"FOO=BAR\nBAR=${FOO:=TEST}", {"FOO" => "BAR", "BAR" => "BAR"}}, {"FOO=BAR\nBAR=${NOTDEFINED:=TEST}", {"FOO" => "BAR", "NOTDEFINED" => "TEST", "BAR" => "TEST"}}, {"FOO=\nBAR=${FOO:=TEST}", {"FOO" => "TEST", "BAR" => "TEST"}}, {"FOO=\nBAR=$FOO:=TEST}", {"FOO" => "TEST", "BAR" => "TEST}"}}, {"FOO=foo\nFOOBAR=${FOO}${BAR}", {"FOO" => "foo", "FOOBAR" => "foo"}}, # Underscores {"_FOO=BAR", {"_FOO" => "BAR"}}, {"_FOO_BAR=FOOBAR", {"_FOO_BAR" => "FOOBAR"}}, ] of {String, Hash(String, String)} {% if flag? :unix %} tests.push( {"FOO=$(echo foo)", {"FOO" => "foo"}}, {"FOO=$((1+2))", {"FOO" => "3"}}, {"FOO=FOO$((1+2))BAR", {"FOO" => "FOO3BAR"}}, {"FOO=$(echo \"$(echo \"$(echo \"$(echo foo)\")\")\")", {"FOO" => "foo"}}, {"FOO=$(echo \"Quotes won't be a problem\")", {"FOO" => "Quotes won't be a problem"}}, {"FOO=bar\nBAR=$(echo \"FOO is $FOO\")", {"FOO" => "bar", "BAR" => "FOO is bar"}}, ) {% end %} tests end @[DataProvider("env_data_with_format_errors")] def test_parse_with_format_error(data : String, error_message : String | Regex) : Nil dotenv = Athena::Dotenv.new expect_raises Athena::Dotenv::Exception::Format, error_message do dotenv.parse data end end def env_data_with_format_errors : Array tests = [ {"FOO=BAR BAZ", "A value containing spaces must be surrounded by quotes in '.env' at line 1.\n...FOO=BAR BAZ...\n ^ line 1 offset 11"}, {"FOO BAR=BAR", "Whitespace characters are not supported after the variable name in '.env' at line 1.\n...FOO BAR=BAR...\n ^ line 1 offset 3"}, {"FOO", "Missing = in the environment variable declaration in '.env' at line 1.\n...FOO...\n ^ line 1 offset 3"}, {"FOO=\"foo", "Missing quote to end the value in '.env' at line 1.\n...FOO=\"foo...\n ^ line 1 offset 8"}, {"FOO='foo", "Missing quote to end the value in '.env' at line 1.\n...FOO='foo...\n ^ line 1 offset 8"}, {"FOO=\"foo\nBAR=\"bar\"", "Missing quote to end the value in '.env' at line 1.\n...FOO=\"foo\\nBAR=\"bar\"...\n ^ line 1 offset 18"}, {"FOO='foo\n", "Missing quote to end the value in '.env' at line 1.\n...FOO='foo\\n...\n ^ line 1 offset 9"}, {"export FOO", "Unable to unset an environment variable in '.env' at line 1.\n...export FOO...\n ^ line 1 offset 10"}, {"FOO=${FOO", "Unclosed braces on variable expansion in '.env' at line 1.\n...FOO=${FOO...\n ^ line 1 offset 9"}, {"FOO= BAR", "Whitespace is not supported before the value in '.env' at line 1.\n...FOO= BAR...\n ^ line 1 offset 4"}, {"Стасян", "Invalid character in variable name in '.env' at line 1.\n...Стасян...\n ^ line 1 offset 0"}, {"FOO!", "Missing = in the environment variable declaration in '.env' at line 1.\n...FOO!...\n ^ line 1 offset 3"}, {"FOO=$(echo foo", "Missing closing parenthesis in '.env' at line 1.\n...FOO=$(echo foo...\n ^ line 1 offset 14"}, {"FOO=$(echo foo\n", "Missing closing parenthesis in '.env' at line 1.\n...FOO=$(echo foo\\n...\n ^ line 1 offset 14"}, {"FOO=\nBAR=${FOO:-\\'a{a}a}", "Unsupported character ''' found in the default value of variable '$FOO' in '.env' at line 2.\n...\\nBAR=${FOO:-\\'a{a}a}...\n ^ line 2 offset 24"}, {"FOO=\nBAR=${FOO:-a$a}", "Unsupported character '$' found in the default value of variable '$FOO' in '.env' at line 2.\n...FOO=\\nBAR=${FOO:-a$a}...\n ^ line 2 offset 20"}, {"FOO=\nBAR=${FOO:-a\"a}", "Unclosed braces on variable expansion in '.env' at line 2.\n...FOO=\\nBAR=${FOO:-a\"a}...\n ^ line 2 offset 17"}, {"_=FOO", "Invalid character in variable name in '.env' at line 1.\n..._=FOO...\n ^ line 1 offset 0"}, ] of {String, String | Regex} {% if flag? :unix %} tests << {"FOO=$((1dd2))", /Issue expanding a command \(.*\n\) in '\.env' at line 1\.\n\.\.\.FOO=\$\(\(1dd2\)\)\.\.\.\n \^ line 1 offset 13/} {% end %} tests end def test_load : Nil ENV.delete "FOO" ENV.delete "BAR" file1 = File.tempfile do |f| f.puts "FOO=BAR" end file2 = File.tempfile do |f| f.puts "BAR=BAZ" end Athena::Dotenv.new.load file1.path, file2.path ENV["FOO"]?.should eq "BAR" ENV["BAR"]?.should eq "BAZ" ENV.delete "FOO" ENV.delete "BAR" file1.delete file2.delete end def test_class_load : Nil ENV.delete "FOO" ENV.delete "BAR" file1 = File.tempfile do |f| f.puts "FOO=BAR" end file2 = File.tempfile do |f| f.puts "BAR=BAZ" end Athena::Dotenv.load file1.path, file2.path ENV["FOO"]?.should eq "BAR" ENV["BAR"]?.should eq "BAZ" ENV.delete "FOO" ENV.delete "BAR" file1.delete file2.delete end def test_class_load_single_file : Nil ENV.delete "FOO" file = File.tempfile do |f| f.puts "FOO=BAR" end Athena::Dotenv.load file.path ENV["FOO"]?.should eq "BAR" ENV.delete "FOO" file.delete end def test_class_load_defaults : Nil ENV.delete "BAZ" file = File.open ".env", "w" file.puts "BAZ=BAZ" file.flush Athena::Dotenv.load ENV["BAZ"]?.should eq "BAZ" ENV.delete "BAZ" file.delete end def test_load_environment : Nil reset_context = Proc(Nil).new do ENV.delete "ATHENA_DOTENV_VARS" ENV.delete "FOO" ENV.delete "TEST_APP_ENV" ENV["EXISTING_KEY"] = "EXISTING_VALUE" end path = File.tempname # .env reset_context.call File.write path, "FOO=BAR\nEXISTING_KEY=NEW_VALUE" Athena::Dotenv.new.load_environment path, "TEST_APP_ENV" ENV["FOO"]?.should eq "BAR" ENV["TEST_APP_ENV"]?.should eq "dev" ENV["EXISTING_KEY"]?.should eq "EXISTING_VALUE" reset_context.call Athena::Dotenv.new.load_environment path, "TEST_APP_ENV", override_existing_vars: true ENV["FOO"]?.should eq "BAR" ENV["TEST_APP_ENV"]?.should eq "dev" ENV["EXISTING_KEY"]?.should eq "NEW_VALUE" # .env.local reset_context.call ENV["TEST_APP_ENV"] = "local" File.write "#{path}.local", "FOO=localBAR\nEXISTING_KEY=localNEW_VALUE" Athena::Dotenv.new.load_environment path, "TEST_APP_ENV" ENV["FOO"]?.should eq "localBAR" ENV["EXISTING_KEY"]?.should eq "EXISTING_VALUE" reset_context.call ENV["TEST_APP_ENV"] = "local" Athena::Dotenv.new.load_environment path, "TEST_APP_ENV", override_existing_vars: true ENV["FOO"]?.should eq "localBAR" ENV["EXISTING_KEY"]?.should eq "localNEW_VALUE" # Special case for test reset_context.call ENV["TEST_APP_ENV"] = "test" Athena::Dotenv.new.load_environment path, "TEST_APP_ENV" ENV["FOO"]?.should eq "BAR" ENV["EXISTING_KEY"]?.should eq "EXISTING_VALUE" reset_context.call ENV["TEST_APP_ENV"] = "test" Athena::Dotenv.new.load_environment path, "TEST_APP_ENV", override_existing_vars: true ENV["FOO"]?.should eq "BAR" ENV["EXISTING_KEY"]?.should eq "NEW_VALUE" # .env.dev reset_context.call File.write "#{path}.dev", "FOO=devBAR\nEXISTING_KEY=devNEW_VALUE" Athena::Dotenv.new.load_environment path, "TEST_APP_ENV" ENV["FOO"]?.should eq "devBAR" ENV["EXISTING_KEY"]?.should eq "EXISTING_VALUE" reset_context.call Athena::Dotenv.new.load_environment path, "TEST_APP_ENV", override_existing_vars: true ENV["FOO"]?.should eq "devBAR" ENV["EXISTING_KEY"]?.should eq "devNEW_VALUE" # .env.dev.local reset_context.call File.write "#{path}.dev.local", "FOO=devlocalBAR\nEXISTING_KEY=devlocalNEW_VALUE" Athena::Dotenv.new.load_environment path, "TEST_APP_ENV" ENV["FOO"]?.should eq "devlocalBAR" ENV["EXISTING_KEY"]?.should eq "EXISTING_VALUE" reset_context.call Athena::Dotenv.new.load_environment path, "TEST_APP_ENV", override_existing_vars: true ENV["FOO"]?.should eq "devlocalBAR" ENV["EXISTING_KEY"]?.should eq "devlocalNEW_VALUE" File.delete? "#{path}.local" File.delete? "#{path}.dev" File.delete? "#{path}.dev.local" # .env.dist reset_context.call File.write "#{path}.dist", "FOO=distBAR\nEXISTING_KEY=distNEW_VALUE" Athena::Dotenv.new.load_environment path, "TEST_APP_ENV" ENV["FOO"]?.should eq "distBAR" ENV["EXISTING_KEY"]?.should eq "EXISTING_VALUE" reset_context.call Athena::Dotenv.new.load_environment path, "TEST_APP_ENV", override_existing_vars: true ENV["FOO"]?.should eq "distBAR" ENV["EXISTING_KEY"]?.should eq "distNEW_VALUE" File.delete "#{path}.dist" reset_context.call ENV.delete "EXISTING_KEY" File.delete? path end def test_overload : Nil ENV.delete "FOO" ENV.delete "BAR" ENV["FOO"] = "initial_foo_value" ENV["BAR"] = "initial_bar_value" file1 = File.tempfile do |f| f.puts "FOO=BAR" end file2 = File.tempfile do |f| f.puts "BAR=BAZ" end Athena::Dotenv.new.overload file1.path, file2.path ENV["FOO"]?.should eq "BAR" ENV["BAR"]?.should eq "BAZ" ENV.delete "FOO" ENV.delete "BAR" file1.delete file2.delete end def test_load_directory : Nil expect_raises Athena::Dotenv::Exception::Path do Athena::Dotenv.new.load __DIR__ end end def test_does_not_override_by_default : Nil ENV["TEST_ENV_VAR"] = "original_value" Athena::Dotenv.new.populate({"TEST_ENV_VAR" => "new_value"}) ENV["TEST_ENV_VAR"]?.should eq "original_value" ENV.delete "TEST_ENV_VAR" end def test_allows_override : Nil ENV["TEST_ENV_VAR"] = "original_value" Athena::Dotenv.new.populate({"TEST_ENV_VAR" => "new_value"}, true) ENV["TEST_ENV_VAR"]?.should eq "new_value" ENV.delete "TEST_ENV_VAR" end def test_memorizing_loaded_var_names_in_special_variable : Nil # Does not already exist ENV.delete "ATHENA_DOTENV_VARS" ENV.delete "APP_DEBUG" ENV.delete "FOO" Athena::Dotenv.new.populate({"APP_DEBUG" => "1", "FOO" => "BAR"}) ENV["ATHENA_DOTENV_VARS"]?.should eq "APP_DEBUG,FOO" # Already exists ENV["ATHENA_DOTENV_VARS"] = "APP_ENV" ENV["APP_DEBUG"] = "1" ENV.delete "FOO" dotenv = Athena::Dotenv.new dotenv.populate({"APP_DEBUG" => "0", "FOO" => "BAR"}) dotenv.populate({"FOO" => "BAZ"}) ENV["ATHENA_DOTENV_VARS"]?.should eq "APP_ENV,FOO" end def test_overriding_env_vars_with_names_memorized_in_special_variable : Nil ENV["ATHENA_DOTENV_VARS"] = "FOO,BAR,BAZ" ENV["FOO"] = "foo" ENV["BAR"] = "bar" ENV["BAZ"] = "bar" ENV["DOCUMENT_ROOT"] = "/var/www" Athena::Dotenv.new.populate({ "FOO" => "foo1", "BAR" => "bar1", "BAZ" => "baz1", "DOCUMENT_ROOT" => "/boot", }) ENV["FOO"]?.should eq "foo1" ENV["BAR"]?.should eq "bar1" ENV["BAZ"]?.should eq "baz1" ENV["DOCUMENT_ROOT"]?.should eq "/var/www" end end ================================================ FILE: src/components/dotenv/spec/spec_helper.cr ================================================ require "spec" require "athena-spec" require "../src/athena-dotenv" ASPEC.run_all ================================================ FILE: src/components/dotenv/src/athena-dotenv.cr ================================================ class Athena::Dotenv; end require "./exception/*" # All usage involves using an `Athena::Dotenv` instance. # For example: # # ``` # require "athena-dotenv" # # # Create a new instance # dotenv = Athena::Dotenv.new # # # Load a file # dotenv.load "./.env" # # # Load multiple files # dotenv.load "./.env", "./.env.dev" # # # Overrides existing variables # dotenv.overload "./.env" # # # Load all files for the current $APP_ENV # # .env, .env.local, and .env.$APP_ENV.local or .env.$APP_ENV # dotenv.load_environment "./.env" # ``` # A `Athena::Dotenv::Exception::Path` error will be raised if the provided file was not found, or is not readable. # # ## Syntax # # ENV vars should be defined one per line. # There should be no space between the `=` between the var name and its value. # # ```text # DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name # ``` # # A`Athena::Dotenv::Exception::Format` error will be raised if a formatting/parsing error is encountered. # # ### Comments # # Comments can be defined by prefixing them with a `#` character. # Comments can defined on its own line, or inlined after an ENV var definition. # # ```text # # Single line comment # FOO=BAR # # BAR=BAZ # Inline comment # ``` # # ### Quotes # # Unquoted values, or those quoted with single (`'`) quotes behave as literals while double (`"`) quotes will have special chars expanded. # For example, given the following `.env` file: # # ```text # UNQUOTED=FOO\nBAR # SINGLE_QUOTES='FOO\nBAR' # DOUBLE_QUOTES="FOO\nBAR" # ``` # ``` # require "athena-dotenv" # # Athena::Dotenv.new.load "./.env" # # ENV["UNQUOTED"] # => "FOO\\nBAR" # ENV["SINGLE_QUOTES"] # => "FOO\\nBAR" # ENV["DOUBLE_QUOTES"] # => "FOO\n" + "BAR" # ``` # # Notice how only the double quotes version actually expands `\n` into a newline, whereas the others treat it as a literal `\n`. # # Quoted values may also extend over multiple lines: # # ```text # FOO="FOO # BAR\n # BAZ" # ``` # # Both single and double quotes will include the actual newline characters, however only double quotes would expand the extra newline in `BAR\n`. # # ### Variables # # ENV vars can be used in values by prefixing the variable name with a `$` with optional opening and closing `{}`. # # ```text # FOO=BAR # BAZ=$FOO # BIZ=${BAZ} # ``` # # WARNING: The order is important when using variables. # In the previous example `FOO` must be defined `BAZ` which must be defined before `BIZ`. # This also extends to when loading multiple files, where a variable may use the value in another file. # # Default values may also be defined in case the related ENV var is not set: # # ```text # DB_USER=${DB_USER:-root} # ``` # # This would set the value of `DB_USER` to be `root`, unless `DB_USER` is defined elsewhere in which case it would use the value of that variable. # # ### Commands # # Shell commands can be evaluated via `$()`. # # NOTE: Commands are currently not supported on Windows. # # ```text # DATE=$(date) # ``` # # ## File Precedence # # The default `.env` file defines _ALL_ ENV vars used within an application, with sane defaults. # This file should be committed and should not contain any sensitive values. # # However in some cases you may need to define values to override those in `.env`, # whether that be only for a single machine, or all machines in a specific environment. # # For these purposes there are other `.env` files that are loaded in a specific order to allow for just this use case: # # * `.env` - Defines all ENV vars, and their default values, used by the application. # * `.env.local` - Overrides ENV vars for all environments, but only for the machine that contains the file. # This file should _NOT_ be committed, and is ignored in the `test` environment to ensure reproducibility. # * `.env.` (e.g. `.env.test`) - Overrides ENV vars for only this one environment. These files _SHOULD_ be committed. # * `.env..local` (e.g. `.env.test.local`) - Machine-specific overrides, but only for a single environment. This file should _NOT_ be committed. # # See `#load_environment` for more information. # # NOTE: Real ENV vars always win against those created in any `.env` file. # # TIP: Environment specific `.env` files should _ONLY_ to override values defined within the default `.env` file and _NOT_ as a replacement to it. # This ensures there is still a single source of truth and removes the need to duplicate everything for each environment. # # ## Production # # `.env` files are mainly intended for non-production environments in order to give the benefits of using ENV vars, but be more convenient/easier to use. # They can of course continue to be used in production by distributing the base `.env` file along with the binary, then creating a `.env.local` on the production server and including production values within it. # This can work quite well for simple applications, but ultimately a more robust solution that best leverages the features of the server the application is running on is best. class Athena::Dotenv VERSION = "0.2.1" # Both acts as a namespace for exceptions related to the `Athena::Dotenv` component, as well as a way to check for exceptions from the component. module Exception; end private VARNAME_REGEX = /(?i:_?[A-Z][A-Z0-9_]*+)/ private enum State VARNAME VALUE end # Convenience method that loads the file at the provided *path*, defaulting to `.env`. def self.load(path : String | ::Path = ".env") : self instance = new instance.load path instance end # Convenience method that loads one or more `.env` files, defaulting to `.env`. def self.load(path : String | ::Path = ".env", *paths : String | ::Path) : self instance = new instance.load path, *paths instance end @path : String | ::Path = "" @data = "" @values = Hash(String, String).new @reader : Char::Reader @line_number = 1 def initialize( @env_key : String = "APP_ENV", ) # Can't use a `getter!` macro since that would return a copy of the reader each time :/ @reader = uninitialized Char::Reader end # Loads each `.env` file within the provided *paths*. # # ``` # require "athena-dotenv" # # dotenv = Athena::Dotenv.new # # dotenv.load "./.env" # dotenv.load "./.env", "./.env.dev" # ``` def load(*paths : String | ::Path) : Nil self.load false, paths end # Loads a `.env` file and its related additional files based on their [precedence][Athena::Dotenv--file-precedence] if they exist. # # The current ENV is determined by the value of `APP_ENV`, which is configurable globally via `.new`, or for a single load via the *env_key* parameter. # If no environment ENV var is defined, *default_environment* will be used. # The `.env.local` file will _NOT_ be loaded if the current environment is included within *test_environments*. # # Existing ENV vars may optionally be overridden by passing `true` to *override_existing_vars*. # # ``` # require "athena-dotenv" # # dotenv = Athena::Dotenv.new # # # Use `APP_ENV`, or `dev` # dotenv.load_environment "./.env" # # # Custom *env_key* and *default_environment* # dotenv.load_environment "./.env", "ATHENA_ENV", "qa" # ``` def load_environment( path : String | ::Path, env_key : String? = nil, default_environment : String = "dev", test_environments : Enumerable(String) = {"test"}, override_existing_vars : Bool = false, ) : Nil env_key = env_key || @env_key dist_path = "#{path}.dist" if File.file?(path) && !File.file?(dist_path) self.load override_existing_vars, {path} else self.load override_existing_vars, {dist_path} end unless env = ENV[env_key]? self.populate({env_key => env = default_environment}, override_existing_vars) end local_path = "#{path}.local" if !test_environments.includes?(env) && File.file?(local_path) self.load override_existing_vars, {local_path} env = ENV.fetch env_key, env end return if "local" == env if File.file? p = "#{path}.#{env}" self.load override_existing_vars, {p} end if File.file? p = "#{path}.#{env}.local" self.load override_existing_vars, {p} end end # Same as `#load`, but will override existing ENV vars. def overload(*paths : String | ::Path) : Nil self.load true, paths end # Parses and returns a Hash based on the string contents of the provided *data* string. # The original `.env` file path may also be provided to *path* for more meaningful error messages. # # ``` # require "athena-dotenv" # # path = "/path/to/.env" # dotenv = Athena::Dotenv.new # # File.write path, "FOO=BAR" # # dotenv.parse File.read(path), path # => {"FOO" => "BAR"} # ``` def parse(data : String, path : String | ::Path = ".env") : Hash(String, String) @path = path @data = data = data.gsub("\r\n", "\n").gsub("\r", "\n") @reader = Char::Reader.new data @values.clear state : State = :varname name = "" self.skip_empty_lines while @reader.has_next? case state in .varname? name = self.lex_varname state = :value in .value? @values[name] = self.lex_value state = :varname end end if state.value? @values[name] = "" end begin @values.dup ensure @values.clear @reader = uninitialized Char::Reader end end # Populates the provides *values* into the environment. # # Existing ENV vars may optionally be overridden by passing `true` to *override_existing_vars*. # # ``` # require "athena-dotenv" # # ENV["FOO"]? # => nil # # Athena::Dotenv.new.populate({"FOO" => "BAR"}) # # ENV["FOO"]? # => "BAR" # ``` def populate(values : Hash(String, String), override_existing_vars : Bool = false) : Nil update_loaded_vars = false loaded_vars = ENV.fetch("ATHENA_DOTENV_VARS", "").split(',').to_set values.each do |name, value| if !loaded_vars.includes?(name) && !override_existing_vars && ENV.has_key?(name) next end ENV[name] = value if !loaded_vars.includes?(name) loaded_vars << name update_loaded_vars = true end end if update_loaded_vars loaded_vars.delete "" ENV["ATHENA_DOTENV_VARS"] = loaded_vars.join ',' end end private def advance_reader(string : String) : Nil @reader.pos += string.size @line_number += string.count '\n' end private def create_format_exception(message : String) : Athena::Dotenv::Exception::Format Athena::Dotenv::Exception::Format.new( message, Athena::Dotenv::Exception::Format::Context.new( @data, @path, @line_number, @reader.pos ) ) end private def lex_nested_expression : String char = @reader.next_char value = "" until char.in? '\n', ')' value += char if '(' == char value += "#{self.lex_nested_expression})" end char = @reader.next_char unless @reader.has_next? raise self.create_format_exception "Missing closing parenthesis" end end if '\n' == char raise self.create_format_exception "Missing closing parenthesis" end value end private def lex_varname : String unless match = /(export[ \t]++)?(#{VARNAME_REGEX})/.match(@data, @reader.pos, Regex::MatchOptions[:anchored]) raise self.create_format_exception "Invalid character in variable name" end self.advance_reader match[0] if !@reader.has_next? || @reader.current_char.in? '\n', '#' raise self.create_format_exception "Unable to unset an environment variable" if match[1]? raise self.create_format_exception "Missing = in the environment variable declaration" end if @reader.current_char.whitespace? raise self.create_format_exception "Whitespace characters are not supported after the variable name" end if '=' != @reader.current_char raise self.create_format_exception "Missing = in the environment variable declaration" end @reader.pos += 1 match[2] end # ameba:disable Metrics/CyclomaticComplexity private def lex_value : String if match = (/[ \t]*+(?:#.*)?$/m).match(@data, @reader.pos, Regex::MatchOptions[:anchored]) self.advance_reader match[0] self.skip_empty_lines return "" end if @reader.current_char.whitespace? raise self.create_format_exception "Whitespace is not supported before the value" end loaded_vars = ENV.fetch("ATHENA_DOTENV_VARS", "").split(',').to_set loaded_vars.delete "" v = "" loop do case char = @reader.current_char when '\'' len = 0 loop do if @reader.pos + (len += 1) == @data.size @reader.pos += len raise self.create_format_exception "Missing quote to end the value" end break if @data[@reader.pos + len] == '\'' end v += @data[1 + @reader.pos, len - 1] @reader.pos += 1 + len when '"' value = "" char = @reader.next_char unless @reader.has_next? raise self.create_format_exception "Missing quote to end the value" end while '"' != char || ('\\' == @data[@reader.pos - 1] && '\\' != @data[@reader.pos - 2]) value += char char = @reader.next_char unless @reader.has_next? raise self.create_format_exception "Missing quote to end the value" end end @reader.next_char value = value.gsub(%(\\"), '"').gsub("\\r", "\r").gsub("\\n", "\n") resolved_value = value resolved_value = self.resolve_commands resolved_value, loaded_vars resolved_value = self.resolve_variables resolved_value, loaded_vars resolved_value = resolved_value.gsub "\\\\", "\\" v += resolved_value else value = "" previous_char = @reader.previous_char char = @reader.next_char while @reader.has_next? && !char.in?('\n', '"', '\'') && !((previous_char.in?(' ', '\t')) && '#' == char) if '\\' == char && @reader.has_next? && @reader.peek_next_char.in? '\'', '"' char = @reader.next_char end value += (previous_char = char) if '$' == char && @reader.has_next? && '(' == @reader.peek_next_char @reader.next_char value += "(#{self.lex_nested_expression})" end char = @reader.next_char end value = value.strip resolved_value = value resolved_value = self.resolve_commands resolved_value, loaded_vars resolved_value = self.resolve_variables resolved_value, loaded_vars resolved_value = resolved_value.gsub "\\\\", "\\" if resolved_value == value && value.each_char.any? &.whitespace? raise self.create_format_exception "A value containing spaces must be surrounded by quotes" end v += resolved_value if @reader.has_next? && '#' == char break end end break unless @reader.has_next? && @reader.current_char != '\n' end self.skip_empty_lines v end private def load(override_existing_vars : Bool, paths : Enumerable(String | ::Path)) : Nil paths.each do |path| if !File::Info.readable?(path) || File.directory?(path) raise Athena::Dotenv::Exception::Path.new path end self.populate(self.parse(File.read(path), path), override_existing_vars) end end private def resolve_commands(value : String, loaded_vars : Set(String)) : String return value unless value.includes? '$' regex = / (\\\\)? # escaped with a backslash? \$ (? \( # require opening parenthesis ([^()]|\g)+ # allow any number of non-parens, or balanced parens (by nesting the expression recursively) \) # require closing paren ) /x value.gsub regex do |_, match| if '\\' == match[1]? next match[0][1..] end {% if flag? :win32 %} # TODO: Support windows? raise RuntimeError.new "Resolving commands is not supported on Windows." {% end %} env = {} of String => String @values.each do |k, v| if loaded_vars.includes?(k) || !ENV.has_key?(k) env[k] = v end end output = IO::Memory.new error = IO::Memory.new status = Process.run( "echo #{match[0]}", shell: true, env: env, output: output, error: error ) unless status.success? raise self.create_format_exception "Issue expanding a command (#{error})" end output.to_s.gsub /[\r\n]+$/, "" end end # ameba:disable Metrics/CyclomaticComplexity private def resolve_variables(value : String, loaded_vars : Set(String)) : String return value unless value.includes? '$' regex = / (?\\*) # escaped with a backslash? \$ (?!\() # no opening parenthesis (?P\{)? # optional brace (?P(?i:[A-Z][A-Z0-9_]*+))? # var name (?P:[-=][^\}]++)? # optional default value (?P\})? # optional closing brace /x value.gsub regex do |_, match| if match["backslashes"].size.odd? next match[0][1..] end # Unescaped $ not followed by var name if match["name"]?.nil? next match[0] end if "{" == match["opening_brace"]? && match["closing_brace"]?.nil? raise self.create_format_exception "Unclosed braces on variable expansion" end name = match["name"] value = if loaded_vars.includes?(name) && @values.has_key?(name) @values[name] elsif @values.has_key? name @values[name] else ENV.fetch name, "" end if value.empty? && (default_value = match["default_value"]?.presence) if unsupported_char = default_value.each_char.find &.in?('\'', '"', '{', '$') raise self.create_format_exception "Unsupported character '#{unsupported_char}' found in the default value of variable '$#{name}'" end value = match["default_value"][2..] if '=' == match["default_value"][1] @values[name] = value end end if !match["opening_brace"]?.presence && !match["closing_brace"]?.nil? value += '}' end "#{match["backslashes"]}#{value}" end end private def skip_empty_lines : Nil if match = (/(?:\s*+(?:#[^\n]*+)?+)++/).match(@data, @reader.pos, Regex::MatchOptions[:anchored]) self.advance_reader match[0] end end end ================================================ FILE: src/components/dotenv/src/exception/format.cr ================================================ require "./logic" # Raised when there is a parsing error within a `.env` file. class Athena::Dotenv::Exception::Format < Athena::Dotenv::Exception::Logic # Stores contextual information related to an `Athena::Dotenv::Exception::Format`. # # ``` # begin # dotenv = Athena::Dotenv.new.parse "NAME=Jim\nFOO=BAR BAZ" # rescue ex : Athena::Dotenv::Exception::Format # ctx = ex.context # # ctx.path # => ".env" # ctx.line_number # => 2 # ctx.details # => "...NAME=Jim\nFOO=BAR BAZ...\n ^ line 2 offset 20" # end # ``` struct Context # Returns the path to the improperly formatted `.env` file. getter path : String # Returns the line number of the format error. getter line_number : Int32 def initialize( @data : String, path : ::Path | String, @line_number : Int32, @offset : Int32, ) @path = path.to_s end # Returns a details string that includes the markup before/after the error, along with what line number and offset the error occurred at. def details : String before = @data[Math.max(0, @offset - 20), Math.min(20, @offset)].gsub "\n", "\\n" after = @data[@offset, 20].gsub "\n", "\\n" %(...#{before}#{after}...\n#{" " * (before.size + 2)}^ line #{@line_number} offset #{@offset}) end end # Returns an object containing contextual information about this error. getter context : Athena::Dotenv::Exception::Format::Context def initialize(message : String, @context : Athena::Dotenv::Exception::Format::Context, cause : ::Exception? = nil) super "#{message} in '#{@context.path}' at line #{@context.line_number}.\n#{@context.details}", cause end end ================================================ FILE: src/components/dotenv/src/exception/logic.cr ================================================ # Represents a code logic error that should lead directly to a fix in your code. class Athena::Dotenv::Exception::Logic < ::Exception include Athena::Dotenv::Exception end ================================================ FILE: src/components/dotenv/src/exception/path.cr ================================================ # Raised when a `.env` file is unable to be read, or non-existent. class Athena::Dotenv::Exception::Path < RuntimeError include Athena::Dotenv::Exception def initialize(path : String | Path, cause : ::Exception? = nil) super "Unable to read the '#{path}' environment file.", cause end end ================================================ FILE: src/components/event_dispatcher/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/event_dispatcher/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/event_dispatcher/CHANGELOG.md ================================================ # Changelog ## [0.4.1] - 2026-04-19 ### Changed - Improve compile time error messages ([#646]) (George Dietrich) ### Fixed - Fix compatibility with `ACTR::EventDispatcher::Event` based event types ([#656]) (George Dietrich) [0.4.1]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.4.1 [#646]: https://github.com/athena-framework/athena/pull/646 [#656]: https://github.com/athena-framework/athena/pull/656 ## [0.4.0] - 2025-09-04 ### Changed - **Breaking:** Changed interface of `AED::EventDispatcherInterface#dispatch` to accept an `ACTR::EventDispatcher::Event` vs `AED::Event` ([#544]) (George Dietrich) ### Removed - Removed `AED::StoppableEvent` in favor of `ACTR::EventDispatcher::StoppableEvent` ([#544]) (George Dietrich) [0.4.0]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.4.0 [#544]: https://github.com/athena-framework/athena/pull/544 ## [0.3.1] - 2025-01-26 _Administrative release, no functional changes_ [0.3.1]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.3.1 ## [0.3.0] - 2024-04-09 ### Changed - **Breaking:** remove `AED::EventListenerInterface` ([#391]) (George Dietrich) - Integrate website into monorepo ([#365]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.3.0 [#365]: https://github.com/athena-framework/athena/pull/365 [#391]: https://github.com/athena-framework/athena/pull/391 ## [0.2.3] - 2023-10-09 _Administrative release, no functional changes_ [0.2.3]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.2.3 ## [0.2.2] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) [0.2.2]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.2.2 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.2.1] - 2023-02-04 ### Added - Add better integration between `Athena::EventDispatcher` and `Athena::DependencyInjection` ([#259]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.2.1 [#259]: https://github.com/athena-framework/athena/pull/259 ## [0.2.0] - 2023-01-07 ### Changed - **Breaking:** refactor how listeners are registered to use the new `AEDA::AsEventListener` annotation on the method instead of the `self.subscribed_events` class method ([#236]) (George Dietrich) - **Breaking:** refactor and rename the majority of `AED::EventDispatcherInterface` API ([#236]) (George Dietrich) - **Breaking:** change the representation of a listener when returned from a dispatcher to be an `AED::Callable` instance ([#236]) (George Dietrich) - **Breaking:** refactor `AED::Event` to now be `abstract` ([#236]) (George Dietrich) ### Added - Add `AED::GenericEvent` that can be used for convenience within simple use cases ([#236]) (George Dietrich) - Add the ability to use a listener method without the `AED::EventDispatcherInterface` parameter ([#236]) (George Dietrich) ### Removed - **Breaking:** remove ability for listeners to automatically be registered with the dispatcher ([#236]) (George Dietrich) - **Breaking:** remove the `AED::EventDispatcher.new` constructor that accepts an `Array(AED::EventListenerInterface)` ([#236]) (George Dietrich) - **Breaking:** remove the `AED::EventListenerType` alias ([#236]) (George Dietrich) - **Breaking:** remove the `AED::SubscribedEvents` alias ([#236]) (George Dietrich) - **Breaking:** remove the `AED::EventListener` struct ([#236]) (George Dietrich) - **Breaking:** remove the `AED.create_listener` method ([#236]) (George Dietrich) - Remove the requirement that listeners methods need to be called `call` ([#236]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.2.0 [#236]: https://github.com/athena-framework/athena/pull/236 ## [0.1.4] - 2022-05-14 _First release a part of the monorepo._ ### Added - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Changed - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Fixed - Fix the `VERSION` constant's value ([#166]) (George Dietrich) [0.1.4]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.1.4 [#166]: https://github.com/athena-framework/athena/pull/166 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.1.3] - 2021-01-29 ### Changed - Migrate documentation to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#14]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.1.3 [#14]: https://github.com/athena-framework/event-dispatcher/pull/14 ## [0.1.2] - 2020-12-03 ### Changed - Update `crystal` version to allow version greater than `1.0.0` ([#13]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.1.2 [#13]: https://github.com/athena-framework/event-dispatcher/pull/13 ## [0.1.1] - 2020-11-12 ### Added - Add the [AED::Spec](https://athenaframework.org/EventDispatcher/Spec/) module to provide helpful testing utilities ([#11]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.1.1 [#11]: https://github.com/athena-framework/event-dispatcher/pull/11 ## [0.1.0] - 2020-01-11 _Initial release._ [0.1.0]: https://github.com/athena-framework/event-dispatcher/releases/tag/v0.1.0 ================================================ FILE: src/components/event_dispatcher/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/event_dispatcher/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2020 Blacksmoke16 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/event_dispatcher/README.md ================================================ # Event Dispatcher [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/event-dispatcher.svg?style=flat-square)](https://github.com/athena-framework/event-dispatcher/releases) A [Mediator](https://en.wikipedia.org/wiki/Mediator_pattern) and [Observer](https://en.wikipedia.org/wiki/Observer_pattern) pattern event library. ## Getting Started Checkout the [Documentation](https://athenaframework.org/EventDispatcher). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/event_dispatcher/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.3.0 ### Remove `AED::EventListenerInterface` The `AED::EventListenerInterface` no longer needs included in your event listener types, and can simply be removed. A type with one or more `AEDA::AsEventListener` annotated methods is now all that is required. ================================================ FILE: src/components/event_dispatcher/docs/README.md ================================================ Object-oriented code has helped a lot in ensuring code extensibility. By having classes with well defined responsibilities, it becomes more flexible and easily extendable to modify their behavior. However inheritance has its limits is not the best option when these modifications need to be shared between other modified subclasses. Say for example you want to do something before and after a method is executed, without interfering with the other logic. The `Athena::EventDispatcher` component is a [Mediator](https://en.wikipedia.org/wiki/Mediator_pattern) and [Observer](https://en.wikipedia.org/wiki/Observer_pattern) pattern event library. This pattern allows creating very flexibly and truly extensible applications. A good example of this is the [architecture](/getting_started/middleware#events) of the Athena Framework itself in how it uses `Athena::EventDispatcher` to dispatch events that then is able to notify all registered listeners for that event. These listeners could then make any necessary modifications seamlessly without affecting the framework logic itself, or the other listeners. ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-event_dispatcher: github: athena-framework/event-dispatcher version: ~> 0.4.0 ``` ## Usage Usage of this component centers around [AED::EventDispatcherInterface](/EventDispatcher/EventDispatcherInterface/), an extension of the base [ACTR::EventDispatcher::Interface](/Contracts/EventDispatcher/Interface/) with extra functionality, with the default implementation being [AED::EventDispatcher](/EventDispatcher/EventDispatcher/). The event dispatcher keeps track of the listeners on various [AED::Event](/EventDispatcher/Event/)s. An event is nothing more than a plain old Crystal object that provides access to data related to the event. ```crystal # Create a custom event that can be emitted when an order is placed. class OrderPlaced < AED::Event getter order : Order def initialize(@order : Order); end end ``` For simple use cases, listeners may be registered directly: ```crystal dispatcher = AED::EventDispatcher.new # Register a listener on our event directly with the dispatcher dispatcher.listener OrderPlaced do |event| pp event.order end ``` However having a dedicated type is usually the better practice. ```crystal struct SendConfirmationListener @[AEDA::AsEventListener] def order_placed(event : OrderPlaced) : Nil # Send a confirmation email to the user end end dispatcher.listener SendConfirmationListener.new ``` In either case, the dispatcher can then be used to dispatch our event. ```crystal # Assume this is a real object record Order, id : String event = OrderPlaced.new Order.new "order 1" dispatcher.dispatch Order.new # => Order(@id="order1") ``` WARNING: If using this component within the context of something that handles independent execution flows, such as a web framework, you will want there to be a dedicated dispatcher instance for each path. This ensures that one flow will not leak state to any other flow, while still allowing flow specific mutations to be used. Consider pairing this component with the [Athena::DependencyInjection](/DependencyInjection) component as a way to handle this. ## Learn More * [Listener Priority](/EventDispatcher/EventDispatcherInterface/#Athena::EventDispatcher::EventDispatcherInterface--listener-priority) * [Stoppable](/Contracts/EventDispatcher/StoppableEvent/) events * [Testing Abstractions](/EventDispatcher/Spec/TracableEventDispatcher/) ================================================ FILE: src/components/event_dispatcher/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Event Dispatcher site_url: https://athenaframework.org/EventDispatcher/ repo_url: https://github.com/athena-framework/event-dispatcher nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-contracts/src/athena-contracts.cr - ./lib/athena-event_dispatcher/src/athena-event_dispatcher.cr - ./lib/athena-event_dispatcher/src/spec.cr source_locations: lib/athena-event_dispatcher: https://github.com/athena-framework/event-dispatcher/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/event_dispatcher/shard.yml ================================================ name: athena-event_dispatcher version: 0.4.1 crystal: ~> 1.4 license: MIT repository: https://github.com/athena-framework/event-dispatcher documentation: https://athenaframework.org/EventDispatcher description: | A Mediator and Observer pattern event library. authors: - George Dietrich dependencies: athena-contracts: github: athena-framework/contracts version: ~> 0.1.0 ================================================ FILE: src/components/event_dispatcher/spec/callable_spec.cr ================================================ require "./spec_helper" private class TestListener end describe AED::Callable do describe "#name" do it "defaults to a generic name if not supplied" do callable = AED::Callable::Event(AED::GenericEvent(String, String)).new( Proc(AED::GenericEvent(String, String), Nil).new { }, 0, nil, ) callable.name.should eq "unknown callable" end it "EventListenerInstance defaults to a more useful name" do callable = AED::Callable::EventListenerInstance(TestListener, AED::GenericEvent(String, String)).new( Proc(AED::GenericEvent(String, String), Nil).new { }, TestListener.new, 0, nil, ) callable.name.should eq "unknown TestListener method" end end end ================================================ FILE: src/components/event_dispatcher/spec/compiler_spec.cr ================================================ require "./spec_helper" # Changes here should also be reflected in `framework/spec/ext/event_dispatcher/register_listeners_spec.cr` describe Athena::EventDispatcher do describe "compiler errors", tags: "compiled" do it "when the listener method is static" do ASPEC::Methods.assert_compile_time_error "Event listener methods can only be defined as instance methods. Did you mean 'MyListener#listener'?", <<-CR require "./spec_helper.cr" class MyListener @[AEDA::AsEventListener] def self.listener(blah : AED::GenericEvent(String, String)) : Nil end end AED::EventDispatcher.new.listener MyListener.new CR end it "with no parameters" do ASPEC::Methods.assert_compile_time_error "Expected 'MyListener#listener' to have 1..2 parameters, got '0'.", <<-CR require "./spec_helper.cr" class MyListener @[AEDA::AsEventListener] def listener : Nil end end AED::EventDispatcher.new.listener MyListener.new CR end it "with too many parameters" do ASPEC::Methods.assert_compile_time_error "Expected 'MyListener#listener' to have 1..2 parameters, got '3'.", <<-CR require "./spec_helper.cr" class MyListener @[AEDA::AsEventListener] def listener(foo, bar, baz) : Nil end end AED::EventDispatcher.new.listener MyListener.new CR end it "first parameter unrestricted" do ASPEC::Methods.assert_compile_time_error "'MyListener#listener': event parameter must have a type restriction of an 'Athena::Contracts::EventDispatcher::Event' instance.", <<-CR require "./spec_helper.cr" class MyListener @[AEDA::AsEventListener] def listener(foo) : Nil end end AED::EventDispatcher.new.listener MyListener.new CR end it "first parameter non Athena::Contracts::EventDispatcher::Event restriction" do ASPEC::Methods.assert_compile_time_error "'MyListener#listener': event parameter must have a type restriction of an 'Athena::Contracts::EventDispatcher::Event' instance, not 'String'.", <<-CR require "./spec_helper.cr" class MyListener @[AEDA::AsEventListener] def listener(foo : String) : Nil end end AED::EventDispatcher.new.listener MyListener.new CR end it "second parameter unrestricted" do ASPEC::Methods.assert_compile_time_error "'MyListener#listener': dispatcher parameter must have a type restriction of 'AED::EventDispatcherInterface'.", <<-CR require "./spec_helper.cr" class MyListener @[AEDA::AsEventListener] def listener(foo : AED::GenericEvent(String, String), dispatcher) : Nil end end AED::EventDispatcher.new.listener MyListener.new CR end it "second parameter non AED::EventDispatcherInterface restriction" do ASPEC::Methods.assert_compile_time_error "'MyListener#listener': dispatcher parameter must have a type restriction of 'AED::EventDispatcherInterface', not 'String'.", <<-CR require "./spec_helper.cr" class MyListener @[AEDA::AsEventListener] def listener(foo : AED::GenericEvent(String, String), dispatcher : String) : Nil end end AED::EventDispatcher.new.listener MyListener.new CR end it "non integer priority field" do ASPEC::Methods.assert_compile_time_error "Event listener method 'MyListener#listener' expects a 'NumberLiteral' for its 'AEDA::AsEventListener#priority' field, but got a 'StringLiteral'.", <<-CR require "./spec_helper.cr" class MyListener @[AEDA::AsEventListener(priority: "foo")] def listener(foo : AED::GenericEvent(String, String)) : Nil end end AED::EventDispatcher.new.listener MyListener.new CR end end end ================================================ FILE: src/components/event_dispatcher/spec/event_dispatcher_spec.cr ================================================ require "./spec_helper" class PreFoo < AED::Event; end class PostFoo < AED::Event; end class PreBar < AED::Event; end class ContractEvent < ACTR::EventDispatcher::Event; end class Sum < AED::Event property value : Int32 = 0 end class TestListener getter values = [] of Int32 @[AEDA::AsEventListener] def on_pre1(event : PreFoo) : Nil @values << 1 end @[AEDA::AsEventListener(priority: 10)] def on_pre2(event : PreFoo, dispatcher : AED::EventDispatcherInterface) : Nil @values << 2 end @[AEDA::AsEventListener] def on_post1(event : PostFoo) : Nil @values << 3 end @[AEDA::AsEventListener] def on_contract(event : ContractEvent) : Nil @values << -1 end end module SomeInterface; end abstract class Animal; end class Dog < Animal; end class Cat < Animal include SomeInterface end abstract class ParentAnimal < Animal; end class Sloth < ParentAnimal include SomeInterface end class ThreeToedSloth < Sloth; end class GenericAnimalEvent(T) < AED::Event getter animal : T def initialize(@animal : T); end end class AnimalListener getter all_animal_calls : Array(Animal.class) = [] of Animal.class getter only_child_animal_calls : Array(Animal.class) = [] of Animal.class getter only_interface_animal_calls : Array(Animal.class) = [] of Animal.class getter non_abstract_animal_calls : Array(Animal.class) = [] of Animal.class @[AEDA::AsEventListener] def all_animals(event : GenericAnimalEvent(Animal)) : Nil @all_animal_calls << event.animal.class end @[AEDA::AsEventListener] def only_child_animals(event : GenericAnimalEvent(ParentAnimal), dispatcher : AED::EventDispatcherInterface) : Nil @only_child_animal_calls << event.animal.class end @[AEDA::AsEventListener] def only_interface_animals(event : GenericAnimalEvent(SomeInterface)) : Nil @only_interface_animal_calls << event.animal.class end @[AEDA::AsEventListener] def non_abstract_animals(event : GenericAnimalEvent(Sloth)) : Nil @non_abstract_animal_calls << event.animal.class end end struct EventDispatcherTest < ASPEC::TestCase @dispatcher : AED::EventDispatcher def initialize @dispatcher = AED::EventDispatcher.new end @[Tags("compiled")] def test_listener_not_passed_event_class : Nil ASPEC::Methods.assert_compile_time_error "expected argument #1 to 'listener' to be Athena::Contracts::EventDispatcher::Event.class, not String.", <<-CR require "./spec_helper.cr" AED::EventDispatcher.new.listener String do end CR end def test_initial_state : Nil @dispatcher.listeners.should be_empty @dispatcher.has_listeners?.should be_false @dispatcher.has_listeners?(PreFoo).should be_false @dispatcher.has_listeners?(PostFoo).should be_false end def test_listener_block : Nil @dispatcher.listener PreFoo do end @dispatcher.listener PreFoo, name: "#2" do end @dispatcher.listener PostFoo do end @dispatcher.has_listeners?.should be_true @dispatcher.has_listeners?(PreFoo).should be_true @dispatcher.has_listeners?(PostFoo).should be_true @dispatcher.has_listeners?(PreBar).should be_false @dispatcher.listeners(PreFoo).size.should eq 2 @dispatcher.listeners(PreFoo).map(&.name).should eq ["unknown callable", "#2"] @dispatcher.listeners(PostFoo).size.should eq 1 @dispatcher.listeners.size.should eq 2 end def test_listener_callable : Nil callback1 = PreFoo.callable do end callback2 = PostFoo.callable do end @dispatcher.listener callback1 @dispatcher.listener callback2 @dispatcher.has_listeners?.should be_true @dispatcher.has_listeners?(PreFoo).should be_true @dispatcher.has_listeners?(PostFoo).should be_true @dispatcher.has_listeners?(PreBar).should be_false @dispatcher.listeners(PreFoo).size.should eq 1 @dispatcher.listeners(PostFoo).size.should eq 1 @dispatcher.listeners.size.should eq 2 end def test_listeners_sorted_by_priority : Nil callback1 = PreFoo.callable(priority: -10) { } callback2 = PreFoo.callable(priority: 10) { } callback3 = PreFoo.callable { } callback4 = PreFoo.callable(priority: 20) { } callback5 = PreFoo.callable { } @dispatcher.listener callback1 @dispatcher.listener callback2 @dispatcher.listener callback3 @dispatcher.listener callback4 # Returns a new copy with thew new priority set callback5 = @dispatcher.listener callback5, priority: 5 @dispatcher.listeners(PreFoo).should eq([ callback4, callback2, callback5, callback3, callback1, ]) end def test_all_listeners_sorts_by_priority callback1 = PreFoo.callable(priority: -10) { } callback2 = PreFoo.callable { } callback3 = PreFoo.callable(priority: 10) { } callback4 = PostFoo.callable(priority: -10) { } callback5 = PostFoo.callable { } callback6 = PostFoo.callable(priority: 10) { } @dispatcher.listener callback1 @dispatcher.listener callback2 @dispatcher.listener callback3 @dispatcher.listener callback4 @dispatcher.listener callback5 @dispatcher.listener callback6 @dispatcher.listeners.should eq({ PreFoo => [callback3, callback2, callback1], PostFoo => [callback6, callback5, callback4], }) end def test_listeners_are_sorted_stably : Nil callback1 = PreFoo.callable(priority: -10) { } callback2 = PreFoo.callable { } callback3 = PreFoo.callable { } callback4 = PreFoo.callable(priority: 10) { } @dispatcher.listener callback1 @dispatcher.listener callback2 @dispatcher.listener callback3 @dispatcher.listener callback4 @dispatcher.listeners(PreFoo).should eq([ callback4, callback2, callback3, callback1, ]) end def test_callable_exposes_correct_priority : Nil callback1 = PreFoo.callable priority: -10 { } callback2 = PreFoo.callable { } callback3 = PreFoo.callable priority: 50 { } @dispatcher.listener callback1 @dispatcher.listener callback2 # Returns a new copy with thew new priority set callback3 = @dispatcher.listener callback3, priority: 10 callback1.priority.should eq -10 callback2.priority.should eq 0 callback3.priority.should eq 10 PreFoo.callable { }.priority.should eq 0 end def test_dispatch : Nil event = Sum.new @dispatcher.listener Sum do |e| e.value += 10 end @dispatcher.listener PostFoo do end @dispatcher.dispatch event @dispatcher.dispatch PostFoo.new event.value.should eq 10 end def test_dispatch_contract_event : Nil event = ContractEvent.new @dispatcher.listener ContractEvent do end returned_event = @dispatcher.dispatch event returned_event.should be event end def test_dispatch_sub_dispatch : Nil value = 0 @dispatcher.listener Sum do value += 123 end @dispatcher.listener PostFoo do |_, dispatcher| dispatcher.dispatch Sum.new end @dispatcher.dispatch PostFoo.new value.should eq 123 end def test_dispatch_stop_event_propagation : Nil pre_foo_invoked = false other_pre_foo_invoked = false @dispatcher.listener PreFoo do |event| pre_foo_invoked = true event.stop_propagation end @dispatcher.listener PreFoo do other_pre_foo_invoked = true end @dispatcher.dispatch PreFoo.new pre_foo_invoked.should be_true other_pre_foo_invoked.should be_false end def test_listener_generic_polymorphism : Nil animal_listener = AnimalListener.new @dispatcher.listener animal_listener @dispatcher.has_listeners?(GenericAnimalEvent(Cat)).should be_true @dispatcher.has_listeners?(GenericAnimalEvent(Sloth)).should be_true @dispatcher.has_listeners?(GenericAnimalEvent(ThreeToedSloth)).should be_true @dispatcher.has_listeners?(GenericAnimalEvent(Dog)).should be_true # Should not include module/abstract types that cannot actually exist. @dispatcher.has_listeners?(GenericAnimalEvent(Animal)).should be_false @dispatcher.has_listeners?(GenericAnimalEvent(SomeInterface)).should be_false @dispatcher.has_listeners?(GenericAnimalEvent(ParentAnimal)).should be_false @dispatcher.dispatch GenericAnimalEvent(Cat).new Cat.new @dispatcher.dispatch GenericAnimalEvent(Sloth).new Sloth.new @dispatcher.dispatch GenericAnimalEvent(Dog).new Dog.new @dispatcher.dispatch GenericAnimalEvent(ThreeToedSloth).new ThreeToedSloth.new animal_listener.all_animal_calls.should eq [Cat, Sloth, Dog, ThreeToedSloth] animal_listener.only_child_animal_calls.should eq [Sloth, ThreeToedSloth] animal_listener.only_interface_animal_calls.should eq [Cat, Sloth] animal_listener.non_abstract_animal_calls.should eq [Sloth, ThreeToedSloth] end def test_remove_listener : Nil callback1 = PreFoo.callable { } @dispatcher.listener callback1 @dispatcher.has_listeners?(PreFoo).should be_true @dispatcher.remove_listener callback1 @dispatcher.has_listeners?(PreFoo).should be_false @dispatcher.remove_listener callback1 end def test_remove_listener_via_get : Nil @dispatcher.listener(PreFoo) { } @dispatcher.has_listeners?(PreFoo).should be_true @dispatcher.remove_listener @dispatcher.listeners(PreFoo).first @dispatcher.has_listeners?(PreFoo).should be_false end def test_add_event_listener_instance listener = TestListener.new @dispatcher.listener listener @dispatcher.has_listeners?(PreFoo).should be_true @dispatcher.listeners(PreFoo).size.should eq 2 @dispatcher.listeners(PreFoo).map(&.name).should eq ["TestListener#on_pre2", "TestListener#on_pre1"] @dispatcher.dispatch PreFoo.new listener.values.should eq [2, 1] end def test_remove_event_listener_instance listener = TestListener.new listener2 = TestListener.new @dispatcher.listener listener @dispatcher.has_listeners?(PreFoo).should be_true @dispatcher.listeners(PreFoo).size.should eq 2 @dispatcher.has_listeners?(PostFoo).should be_true @dispatcher.listeners(PostFoo).size.should eq 1 @dispatcher.listener listener2 @dispatcher.has_listeners?(PreFoo).should be_true @dispatcher.listeners(PreFoo).size.should eq 4 @dispatcher.has_listeners?(PostFoo).should be_true @dispatcher.listeners(PostFoo).size.should eq 2 @dispatcher.remove_listener listener @dispatcher.has_listeners?(PreFoo).should be_true @dispatcher.listeners(PreFoo).size.should eq 2 @dispatcher.has_listeners?(PostFoo).should be_true @dispatcher.listeners(PostFoo).size.should eq 1 @dispatcher.remove_listener listener2 @dispatcher.has_listeners?(PreFoo).should be_false @dispatcher.has_listeners?(PostFoo).should be_false @dispatcher.listeners.should be_empty end def test_remove_event_listener_instance_diff_instance listener = TestListener.new listener2 = TestListener.new @dispatcher.listener listener @dispatcher.listener listener2 @dispatcher.listeners(PreFoo).size.should eq 4 @dispatcher.remove_listener TestListener.new @dispatcher.listeners(PreFoo).size.should eq 4 @dispatcher.remove_listener listener2 @dispatcher.listeners(PreFoo).size.should eq 2 end end ================================================ FILE: src/components/event_dispatcher/spec/generic_event_spec.cr ================================================ require "./spec_helper" struct GenericEventTest < ASPEC::TestCase def test_with_arguments : Nil event = AED::GenericEvent(String, Int32 | String).new( "foo", args = {"counter" => 0, "data" => "bar"} ) event.subject.should eq "foo" event.arguments.should eq args event.arguments = {"counter" => 2} of String => Int32 | String event.arguments.should eq({"counter" => 2}) event["counter"].should eq 2 event["foo"]?.should be_nil event["counter"] = 5 event["counter"].should eq 5 event.has_key?("counter").should be_true end def test_without_arguments : Nil event = AED::GenericEvent.new "foo" event.subject.should eq "foo" event.arguments.should be_empty end end ================================================ FILE: src/components/event_dispatcher/spec/spec_helper.cr ================================================ require "spec" require "athena-spec" require "../src/athena-event_dispatcher" ASPEC.run_all ================================================ FILE: src/components/event_dispatcher/src/annotations.cr ================================================ # Can be applied to method(s) within a type to denote that method is an event listener. # The annotation expects to be assigned to an instance method with between 1 and 2 parameters with a return type of `Nil`. # The first parameter should be the concrete `ACTR::EventDispatcher::Event` instance the method is listening on. # The optional second parameter should be typed as an `AED::EventDispatcherInterface`. # # The annotation accepts an optional `priority` field, defaulting to `0`, denoting the [listener's priority][Athena::EventDispatcher::EventDispatcherInterface--listener-priority] # # ``` # class MyListener # # Single parameter # @[AEDA::AsEventListener] # def single_param(event : MyEvent) : Nil # end # # # Double parameter # @[AEDA::AsEventListener] # def double_param(event : MyEvent, dispatcher : AED::EventDispatcherInterface) : Nil # end # # # With priority # @[AEDA::AsEventListener(priority: 10)] # def with_priority(event : MyEvent) : Nil # end # end # ``` annotation Athena::EventDispatcher::Annotations::AsEventListener; end ================================================ FILE: src/components/event_dispatcher/src/athena-event_dispatcher.cr ================================================ require "athena-contracts/event_dispatcher" require "./annotations" require "./event_dispatcher" require "./generic_event" # Convenience alias to make referencing `Athena::EventDispatcher` types easier. alias AED = Athena::EventDispatcher # Convenience alias to make referencing `AED::Annotations` types easier. alias AEDA = AED::Annotations module Athena::EventDispatcher VERSION = "0.4.1" # Contains all the `Athena::EventDispatcher` based annotations. module Annotations; end end ================================================ FILE: src/components/event_dispatcher/src/callable.cr ================================================ # Encapsulates everything required to represent an event listener. # Including what event is being listened on, the callback itself, and its priority. # # Each subclass represents a specific "type" of listener. # See each subclass for more information. # # TIP: These types can be manually instantiated and added via the related `AED::EventDispatcherInterface#listener(callable)` overload. # This can be useful as a point of integration to other libraries, such as lazily instantiating listener instances. # # ### Name # # Each callable also has an optional *name* that can be useful for debugging to allow identifying a specific callable # since there would be no way to tell apart two listeners on the same event, with the same priority. # # ``` # class MyEvent < AED::Event; end # # dispatcher = AED::EventDispatcher.new # # dispatcher.listener(MyEvent) { } # dispatcher.listener(MyEvent, name: "block-listener") { } # # class MyListener # @[AEDA::AsEventListener] # def on_my_event(event : MyEvent) : Nil # end # end # # dispatcher.listener MyListener.new # # dispatcher.listeners(MyEvent).map &.name # => ["unknown callable", "block-listener", "MyListener#on_my_event"] # ``` # # `AED::Callable::EventListenerInstance` instances registered via `AED::EventDispatcherInterface#listener(listener)` will automatically have a name including the # method and listener class names in the format of `ClassName#method_name`. abstract struct Athena::EventDispatcher::Callable include Comparable(self) # Returns what `ACTR::EventDispatcher::Event` class this callable represents. getter event_class : ACTR::EventDispatcher::Event.class # Returns the name of this callable. # Useful for debugging to identify a specific callable added from a block, or which method an `AED::Callable::EventListenerInstance` is associated with. getter name : String # Returns the [listener priority][Athena::EventDispatcher::EventDispatcherInterface--listener-priority] of this callable. getter priority : Int32 def initialize( @event_class : ACTR::EventDispatcher::Event.class, name : String?, @priority : Int32, ) @name = name || "unknown callable" end # :nodoc: def <=>(other : AED::Callable) : Int32? other.priority <=> @priority end # :nodoc: def call(event : ACTR::EventDispatcher::Event, dispatcher : AED::EventDispatcherInterface) : NoReturn raise "BUG: Invoked wrong `call` overload" end protected abstract def copy_with(priority _priority = @priority) # Represents a listener that only accepts the `ACTR::EventDispatcher::Event` instance. struct Event(E) < Athena::EventDispatcher::Callable @callback : E -> Nil def initialize( @callback : E -> Nil, priority : Int32 = 0, name : String? = nil, event_class : E.class = E, ) super event_class, name, priority end # :nodoc: def_equals @event_class, @priority, @callback # :nodoc: def call(event : E, dispatcher : AED::EventDispatcherInterface) : Nil @callback.call event end protected def copy_with(priority _priority = @priority) : self Event(E).new( callback: @callback, priority: _priority, ) end end # Represents a listener that accepts both the `ACTR::EventDispatcher::Event` instance and the `AED::EventDispatcherInterface` instance. # Such as when using [AED::EventDispatcherInterface#listener(event_class,*,priority,&)][Athena::EventDispatcher::EventDispatcherInterface#listener(callable,*,priority)], or the `AED::Event.callable` method. struct EventDispatcher(E) < Athena::EventDispatcher::Callable @callback : E, AED::EventDispatcherInterface -> Nil def initialize( @callback : E, AED::EventDispatcherInterface -> Nil, priority : Int32 = 0, name : String? = nil, event_class : E.class = E, ) super event_class, name, priority end # :nodoc: def_equals @event_class, @priority, @callback # :nodoc: def call(event : E, dispatcher : AED::EventDispatcherInterface) : Nil @callback.call event, dispatcher end protected def copy_with(priority _priority = @priority) : self EventDispatcher(E).new( callback: @callback, priority: _priority, ) end end # Represents a dedicated type based listener using `AEDA::AsEventListener` annotations. struct EventListenerInstance(I, E) < Athena::EventDispatcher::Callable # Returns the listener instance this callable is associated with. getter instance : I @callback : Proc(E, Nil) | Proc(E, AED::EventDispatcherInterface, Nil) def initialize( @callback : Proc(E, Nil) | Proc(E, AED::EventDispatcherInterface, Nil), @instance : I, priority : Int32 = 0, name : String? = nil, event_class : E.class = E, ) super event_class, name || "unknown #{@instance.class} method", priority end # :nodoc: def_equals @event_class, @priority, @callback, @instance # :nodoc: def call(event : ACTR::EventDispatcher::Event, dispatcher : AED::EventDispatcherInterface) : Nil return unless event.is_a?(E) case cb = @callback when Proc(E, Nil) then cb.call event when Proc(E, AED::EventDispatcherInterface, Nil) then cb.call event, dispatcher else raise "BUG: Tried to call unknown event type." end end protected def copy_with(priority _priority = @priority) : self EventListenerInstance(I, E).new( callback: @callback, instance: @instance, priority: _priority, ) end end end ================================================ FILE: src/components/event_dispatcher/src/event.cr ================================================ # Extension of `ACTR::EventDispatcher::Event` to add additional functionality. # # ## Generics # # Events with generic type variables are also supported, the `AED::GenericEvent` event is an example of this. # Listeners on events with generics are a bit unique in how they behave in that each unique instantiation is treated as its own event. # For example: # # ``` # class Foo; end # # subject = Foo.new # # dispatcher.listener AED::GenericEvent(Foo, Int32) do |e| # e["counter"] += 1 # end # # dispatcher.listener AED::GenericEvent(String, String) do |e| # e["class"] = e.subject.upcase # end # # dispatcher.dispatch AED::GenericEvent.new subject, data = {"counter" => 0} # # data["counter"] # => 1 # # dispatcher.dispatch AED::GenericEvent.new "foo", data = {"bar" => "baz"} # # data["class"] # => "FOO" # ``` # # Notice that the listeners are registered with the generic types included. # This allows the component to treat `AED::GenericEvent(String, Int32)` differently than `AED::GenericEvent(String, String)`. # The added benefit of this is that the listener is also aware of the type returned by the related methods, so no manual casting is required. # # TIP: Use type aliases to give better names to commonly used generic types. # # ``` # alias UserCreatedEvent = AED::GenericEvent(User, String) # ``` # # ### Polymorphism # # There is special handling for an event class has a single generic type variable. # When used within an event listener, if the generic type has child types or is included in other types (when it's a module), then that listener will be registered for each concrete descendant of that type. # # ``` # abstract struct Animal; end # # struct Dog < Animal; end # # struct Cat < Animal; end # # class GenericAnimalEvent(T) < AED::Event # getter animal : T # # def initialize(@animal : T); end # end # # class AnimalsListener # @[AEDA::AsEventListener] # def all_animals(event : GenericAnimalEvent(Animal)) : Nil # pp "All Animals: #{event.animal}" # end # # @[AEDA::AsEventListener] # def dog_only(event : GenericAnimalEvent(Dog)) : Nil # pp "Dog Only: #{event.animal}" # end # end # # dispatcher = AED::EventDispatcher.new # animal_listener = AnimalsListener.new # dispatcher.listener animal_listener # # dispatcher.dispatch GenericAnimalEvent(Cat).new Cat.new # dispatcher.dispatch GenericAnimalEvent(Dog).new Dog.new # # "All Animals: Cat()" # # "All Animals: Dog()" # # "Dog Only: Dog()" # ``` # # In this example, notice how the `all_animals` event listener's *event* parameter is typed as `GenericAnimalEvent(Animal)` and gets invoked for both children of the abstract `Animal` type. # Whereas the event for the `dog_only` event listener is typed as `GenericAnimalEvent(Dog)`, so it only gets invoked once when the animal is a `Dog`. abstract class Athena::EventDispatcher::Event < Athena::Contracts::EventDispatcher::Event # Returns an `AED::Callable` based on the event class the method was called on. # Optionally allows customizing the *priority* and *name* of the listener. # # ``` # class MyEvent < AED::Event; end # # callable = MyEvent.callable do |event, dispatcher| # # Do something with the event, and/or dispatcher # end # # dispatcher.listener callable # ``` # # Essentially the same as using [AED::EventDispatcherInterface#listener(event_class,*,priority,&)][Athena::EventDispatcher::EventDispatcherInterface#listener(callable,*,priority)], but removes the need to pass the *event_class*. def self.callable(*, priority : Int32 = 0, name : String? = nil, &block : self, AED::EventDispatcherInterface -> Nil) : AED::Callable AED::Callable::EventDispatcher(self).new block, priority, name end end ================================================ FILE: src/components/event_dispatcher/src/event_dispatcher.cr ================================================ require "./event_dispatcher_interface" require "./event" require "./callable" # Default implementation of `AED::EventDispatcherInterface`. class Athena::EventDispatcher::EventDispatcher include Athena::EventDispatcher::EventDispatcherInterface @listeners = Hash(ACTR::EventDispatcher::Event.class, Array(AED::Callable)).new # Keeps track of event types that have already been sorted. @sorted = Set(ACTR::EventDispatcher::Event.class).new # :inherit: def dispatch(event : ACTR::EventDispatcher::Event) : ACTR::EventDispatcher::Event self.call_listeners event, self.listeners event.class event end # :inherit: def has_listeners? : Bool @listeners.each_value.any? { |listeners| !listeners.empty? } end # :inherit: def has_listeners?(event_class : ACTR::EventDispatcher::Event.class) : Bool @listeners.has_key? event_class end # :inherit: def listener(callable : AED::Callable) : AED::Callable self.add_callable callable end # :inherit: def listener(callable : AED::Callable, *, priority : Int32) : AED::Callable self.add_callable callable.copy_with priority: priority end # :inherit: def listener(event_class : E.class, *, priority : Int32 = 0, name : String? = nil, &block : E, AED::EventDispatcherInterface -> Nil) : AED::Callable forall E {% unless E <= ACTR::EventDispatcher::Event @def.args[0].raise "expected argument #1 to '#{@def.name}' to be #{ACTR::EventDispatcher::Event.class}, not #{E}." end %} self.add_callable AED::Callable::EventDispatcher(E).new block, priority, name end # :inherit: def listener(listener : T) : Nil forall T {% begin %} {% listeners = [] of Nil %} {% class_listeners = T.class.methods.select &.annotation(AEDA::AsEventListener) # Raise compile time error if a listener is defined as a class method. unless class_listeners.empty? class_listeners.first.raise "Event listener methods can only be defined as instance methods. Did you mean '#{T.name}##{class_listeners.first.name}'?" end T.methods.select(&.annotation(AEDA::AsEventListener)).each do |m| # Validate the parameters of each method. if (m.args.size < 1) || (m.args.size > 2) m.raise "Expected '#{T.name}##{m.name}' to have 1..2 parameters, got '#{m.args.size}'." end event_arg = m.args[0] # Validate the type restriction of the first parameter, if present if event_arg.restriction.is_a?(Nop) event_arg.raise "'#{T.name}##{m.name}': event parameter must have a type restriction of an 'Athena::Contracts::EventDispatcher::Event' instance." end if !(event_arg.restriction.resolve <= ACTR::EventDispatcher::Event) event_arg.raise "'#{T.name}##{m.name}': event parameter must have a type restriction of an 'Athena::Contracts::EventDispatcher::Event' instance, not '#{event_arg.restriction}'." end if dispatcher_arg = m.args[1] if dispatcher_arg.restriction.is_a?(Nop) dispatcher_arg.raise "'#{T.name}##{m.name}': dispatcher parameter must have a type restriction of 'AED::EventDispatcherInterface'." end if !(dispatcher_arg.restriction.resolve <= AED::EventDispatcherInterface) dispatcher_arg.raise "'#{T.name}##{m.name}': dispatcher parameter must have a type restriction of 'AED::EventDispatcherInterface', not '#{dispatcher_arg.restriction}'." end end priority = m.annotation(AEDA::AsEventListener)[:priority] || 0 unless priority.is_a? NumberLiteral m.raise "Event listener method '#{T.name}##{m.name}' expects a 'NumberLiteral' for its 'AEDA::AsEventListener#priority' field, but got a '#{priority.class_name.id}'." end # A listener whose event arg is `Foo(Animal)` will not be invoked for a dispatched `Foo(Dog)`, because the dispatcher matches on exact `event.class`. # To work around this, register a listener for each non-abstract descendant of that type. restriction = event_arg.restriction if restriction.is_a?(Generic) && restriction.type_vars.size == 1 type_param = restriction.type_vars.first.resolve concrete_types = [type_param] + type_param.all_subclasses + type_param.includers concrete_types.each do |concrete_type| concrete_event = "#{restriction.name}(#{concrete_type})".id listeners << {concrete_event, m.args.size, m.name.id, priority} if !(concrete_type.abstract? || concrete_type.module?) end else listeners << {restriction.id, m.args.size, m.name.id, priority} end end %} {% for info in listeners %} {% event, count, method, priority = info %} {% if 1 == count %} self.add_callable( AED::Callable::EventListenerInstance(T, {{event}}).new( ->listener.{{method}}({{event}}), listener, {{priority}}, "{{T}}##{{{method.stringify}}}" ) ) {% else %} self.add_callable( AED::Callable::EventListenerInstance(T, {{event}}).new( ->listener.{{method}}({{event}}, AED::EventDispatcherInterface), listener, {{priority}}, "{{T}}##{{{method.stringify}}}" ) ) {% end %} {% end %} {% end %} end protected def add_callable(callable : AED::Callable) : AED::Callable (@listeners[callable.event_class] ||= Array(Callable).new) << callable @sorted.delete callable.event_class callable end # :inherit: def listeners : Hash(ACTR::EventDispatcher::Event.class, Array(AED::Callable)) @listeners.each_key do |ec| self.sort_listeners ec unless @sorted.includes? ec end @listeners end # :inherit: def listeners(for event_class : ACTR::EventDispatcher::Event.class) : Array(AED::Callable) return [] of AED::Callable unless @listeners.has_key? event_class unless @sorted.includes? event_class self.sort_listeners event_class end @listeners[event_class] end # :inherit: def remove_listener(callable : AED::Callable) : Nil return unless listeners = @listeners[callable.event_class]? listeners.reject! { |c| c == callable } @listeners.delete callable.event_class if listeners.empty? @sorted.delete callable.event_class end # :inherit: def remove_listener(listener : T) : Nil forall T @listeners.each do |event_class, listeners| listeners.reject! { |l| l.is_a?(AED::Callable::EventListenerInstance) && l.instance == listener } @listeners.delete event_class if listeners.empty? end end private def call_listeners(event : ACTR::EventDispatcher::Event, listeners : Array(AED::Callable)) : Nil listeners.each do |listener| break if event.is_a?(ACTR::EventDispatcher::StoppableEvent) && !event.propagate? listener.call event, self end end private def sort_listeners(event_class : ACTR::EventDispatcher::Event.class) : Nil # Use stable sort to ensure callables with priority of `0` are invoked in the order they were inserted @listeners[event_class].sort! @sorted << event_class end end ================================================ FILE: src/components/event_dispatcher/src/event_dispatcher_interface.cr ================================================ # An event dispatcher is the primary type of `Athena::EventDispatcher`. # Extends `ACTR::EventDispatcher::Interface` to add additional functionality. # It maintains a registry of listeners, with events also being dispatched via this type. # When dispatched, the dispatcher notifies all listeners registered with that event. # # ## Usage # # Listeners can be added in a few ways, with the simplest being registering a block directly on the dispatcher instance. # # ``` # class MyEvent < ACTR::EventDispatcher::Event; end # # dispatcher.listener MyEvent do |event, dispatcher| # # Do something with the event, and/or dispatcher # end # ``` # # Another way involves passing an `AED::Callable` instance, created manually or via the `AED::Event.callable` method. # Lastly, a type that has one or more `AEDA::AsEventListener` annotated methods may also be passed. # # Once all listeners are registered, you can begin to dispatch events. # Dispatching an event is simply calling the `#dispatch` method with an `ACTR::EventDispatcher::Event` subclass instance as an argument. # # ### Listener Priority # # As you may have noticed, each way of registering a listener has an optional *priority* parameter. # This value can be a positive or negative integer, with a default of `0` that controls the order in which each listener is executed. # The higher the value, the sooner that listener would be executed. # If two listeners have the same priority, they are executed in the order in which they were registered with the dispatcher. # # ``` # class MyEvent < ACTR::EventDispatcher::Event; end # # dispatcher = AED::EventDispatcher.new # dispatcher.listener(MyEvent, priority: -10) { pp "callback1" } # dispatcher.listener(MyEvent, priority: 10) { pp "callback2" } # dispatcher.listener(MyEvent) { pp "callback3" } # dispatcher.listener(MyEvent, priority: 20) { pp "callback4" } # dispatcher.listener(MyEvent) { pp "callback5" } # # dispatcher.dispatch MyEvent.new # # => # # "callback4" # # "callback2" # # "callback3" # # "callback5" # # "callback1" # ``` # # NOTE: While the priority can be any `Int32`, best practices suggest keeping it in the `-255..255` range. module Athena::EventDispatcher::EventDispatcherInterface include Athena::Contracts::EventDispatcher::Interface # Returns `true` if there are any listeners on any event. abstract def has_listeners? : Bool # Returns `true` if this dispatcher has any listeners on the provided *event_class*. abstract def has_listeners?(event_class : ACTR::EventDispatcher::Event.class) : Bool # Registers the provided *callable* listener to this dispatcher. abstract def listener(callable : AED::Callable) : AED::Callable # Registers the provided *callable* listener to this dispatcher, overriding its priority with that of the provided *priority*. abstract def listener(callable : AED::Callable, *, priority : Int32) : AED::Callable # Registers the block as an `AED::Callable` on the provided *event_class*, optionally with the provided *priority* and/or *name*. abstract def listener(event_class : E.class, *, priority : Int32 = 0, name : String? = nil, &block : E, AED::EventDispatcherInterface -> Nil) : AED::Callable forall E # Registers the provided *listener* instance to this dispatcher. # # `T` is any type that has methods annotated with `AEDA::AsEventListener`. def listener(listener : T) : Nil forall T # TODO: Make this actually abstract once https://github.com/crystal-lang/crystal/issues/14451 is resolved. {% @type.raise "abstract `def Athena::EventDispatcher::EventDispatcherInterface#listener(listener : T) : Nil forall T` must be implemented by #{@type}" %} end # Returns a hash of all registered listeners as a `Hash(ACTR::EventDispatcher::Event.class, Array(AED::Callable))`. abstract def listeners : Hash(ACTR::EventDispatcher::Event.class, Array(AED::Callable)) # Returns an `Array(AED::Callable)` for all listeners on the provided *event_class*. abstract def listeners(for event_class : ACTR::EventDispatcher::Event.class) : Array(AED::Callable) # Deregisters the provided *callable* from this dispatcher. # # TIP: The callable may be one retrieved via either `#listeners` method. abstract def remove_listener(callable : AED::Callable) : Nil # Deregisters listeners based on the provided *listener* from this dispatcher. # # `T` is any type that has methods annotated with `AEDA::AsEventListener`. def remove_listener(listener : T) : Nil forall T # TODO: Make this actually abstract once https://github.com/crystal-lang/crystal/issues/14451 is resolved. {% @type.raise "abstract `def Athena::EventDispatcher::EventDispatcherInterface#remove_listener(listener : T) : Nil forall T` must be implemented by #{@type}" %} end end ================================================ FILE: src/components/event_dispatcher/src/generic_event.cr ================================================ # An extension of `AED::Event` that provides a generic event type that can be used in place of dedicated event types. # Allows using various instantiations of this one event type to handle multiple events. # # INFO: This type is provided for convenience for use within simple use cases. # Dedicated event types are still considered a best practice. # # ## Usage # # A generic event consists of a `#subject` of type `S`, which is some object/value representing an event that has occurred. # `#arguments` of type `V` may also be provided to augment the event with additional context, which is modeled as a `Hash(String, V)`. # # ``` # dispatcher.dispatch( # AED::GenericEvent(MyClass, Int32 | String).new( # my_class_instance, # {"counter" => 0, "data" => "bar"} # ) # ) # ``` # # Refer to [AED::Event][Athena::EventDispatcher::Event--generics] for examples of how listeners on events with generics behave. # # TODO: Make this include `Mappable` when/if https://github.com/crystal-lang/crystal/issues/10886 is implemented. class Athena::EventDispatcher::GenericEvent(S, V) < Athena::EventDispatcher::Event # Returns the subject of this event. getter subject : S # Returns the extra information stored with this event. getter arguments : Hash(String, V) # Sets the extra information that should be stored with this event. setter arguments : Hash(String, V) def self.new(subject : S) AED::GenericEvent(S, NoReturn).new subject, Hash(String, NoReturn).new end def initialize( @subject : S, @arguments : Hash(String, V), ); end # Returns the argument with the provided *key*, raising if it does not exist. def [](key : String) : V @arguments[key] end # Returns the argument with the provided *key*, or `nil` if it does not exist. def []?(key : String) : V? @arguments[key]? end # Sets the argument with the provided *key* to the provided *value*. def []=(key : String, value : V) : Nil @arguments[key] = value end # Returns `true` if there is an argument with the provided *key*, otherwise `false`. def has_key?(key : String) : Bool @arguments.has_key? key end end ================================================ FILE: src/components/event_dispatcher/src/spec.cr ================================================ require "spec" # A set of testing utilities/types to aid in testing `Athena::EventDispatcher` related types. # # ### Getting Started # # Require this module in your `spec_helper.cr` file. # # ``` # # This also requires "spec". # require "athena-event_dispatcher/spec" # ``` module Athena::EventDispatcher::Spec # Test implementation of `AED::EventDispatcherInterface` that keeps track of the events that were dispatched. # # ``` # class MyEvent < AED::Event; end # # class OtherEvent < AED::Event; end # # dispatcher = AED::Spec::TracableEventDispatcher.new # # dispatcher.dispatch MyEvent.new # dispatcher.dispatch OtherEvent.new # # dispatcher.emitted_events # => [MyEvent, OtherEvent] # ``` class TracableEventDispatcher < AED::EventDispatcher # Returns an array of each `Athena::Contracts::EventDispatcher::Event.class` that was dispatched via this dispatcher. getter emitted_events : Array(ACTR::EventDispatcher::Event.class) = [] of ACTR::EventDispatcher::Event.class # :inherit: def dispatch(event : ACTR::EventDispatcher::Event) : Nil @emitted_events << event.class super end end end ================================================ FILE: src/components/framework/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/framework/.gitignore ================================================ *.dwarf /.shards/ /bin/ /lib/ /logs/ # Libraries don't need dependency lock # Dependencies will be locked in application that uses them /shard.lock ================================================ FILE: src/components/framework/CHANGELOG.md ================================================ # Changelog ## [0.22.0] - 2026-04-19 ### Changed - **Breaking:** Store `ATH::Action` within `ATH::Request#attributes` instead of within an ivar ([#636]) (George Dietrich) - **Breaking:** Extract out HTTP related `framework` types into the new `http` component ([#640]) (George Dietrich) - **Breaking:** Extract out Request/Response handling related `framework` types into the new `http_kernel` component ([#657]) (George Dietrich) - **Breaking:** Refactor how annotations are fetched off an action/parameter ([#655]) (George Dietrich) ### Fixed - Fix CORS error when using HTTP/2 but providing uppercase header names ([#670]) (George Dietrich) - Fix compile time error when inadvertently using a type name that conflicts with an internal component type ([#678]) (George Dietrich) [0.22.0]: https://github.com/athena-framework/framework/releases/tag/v0.22.0 [#636]: https://github.com/athena-framework/athena/pull/636 [#640]: https://github.com/athena-framework/athena/pull/640 [#657]: https://github.com/athena-framework/athena/pull/657 [#655]: https://github.com/athena-framework/athena/pull/655 [#670]: https://github.com/athena-framework/athena/pull/670 [#678]: https://github.com/athena-framework/athena/pull/678 ## [0.21.1] - 2025-10-04 ### Fixed - Fix improper handling of optional file uploads ([#595]) (George Dietrich) [0.21.1]: https://github.com/athena-framework/framework/releases/tag/v0.21.1 [#595]: https://github.com/athena-framework/athena/pull/595 ## [0.21.0] - 2025-09-04 ### Changed - **Breaking:** Leverage `ATH::AbstractFile` within `ATH::BinaryFileResponse` ([#563]) (George Dietrich) - Leverage `mime` component within `ATH::BinaryFileResponse` ([#545]) (George Dietrich) - Setter methods on `ATH::Response` and subclasses now return `self` to better support method chaining ([#563]) (George Dietrich) ### Added - Add support for Athena Contract component types ([#544]) (George Dietrich) - Add native file upload support ([#559]) (George Dietrich) ### Fixed - Correctly apply `emit_nil` value from `ATHA::View` ([#526]) (George Dietrich) [0.21.0]: https://github.com/athena-framework/framework/releases/tag/v0.21.0 [#545]: https://github.com/athena-framework/athena/pull/545 [#563]: https://github.com/athena-framework/athena/pull/563 [#544]: https://github.com/athena-framework/athena/pull/544 [#559]: https://github.com/athena-framework/athena/pull/559 [#526]: https://github.com/athena-framework/athena/pull/526 ## [0.20.1] - 2025-02-08 ### Fixed - Fix `ATH::ViewHandler` bundle configuration values not being correctly set ([#520]) (George Dietrich) [0.20.1]: https://github.com/athena-framework/framework/releases/tag/v0.20.1 [#520]: https://github.com/athena-framework/athena/pull/520 ## [0.20.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) - **Breaking:** The `ATHR::Interface.configuration` macro is no longer scoped to the resolver namespace ([#425]) (George Dietrich) - **Breaking:** Rename `ATHR::RequestBody::Extract` to `ATHA::MapRequestBody` ([#425]) (George Dietrich) - **Breaking:** Rename `ATHR::Time::Format` to `ATHA::MapTime` ([#425]) (George Dietrich) - Update minimum `crystal` version to `~> 1.14.0` ([#433]) (George Dietrich) - Refactor auto redirection logic to be more robust ([#436], [#480]) (George Dietrich) - Refactor `ATHR::RequestBody` to raise more accurate deserialization errors ([#490]) (George Dietrich) ### Added - Add support for [Proxies & Load Balancers](https://athenaframework.org/guides/proxies/) ([#440], [#444]) (George Dietrich) - Add new `trusted_host` bundle scheme property to allow setting trusted hostnames ([#474]) (George Dietrich) - Add support for deserializing `application/x-www-form-urlencoded` bodies via `ATHA::MapRequestBody` ([#477]) (George Dietrich) - Add `ATHA::MapQueryString` to map a request's query string into a DTO type ([#477]) (George Dietrich) - Add `ATH::Exception.from_status` helper method ([#426]) (George Dietrich) - Add `ATHA::MapQueryParameter` for handling query parameters ([#426]) (George Dietrich) - Add `#validation_groups` and `#accept_formats` annotation properties to `ATHA::MapRequestBody` ([#486]) (George Dietrich) - Add `#validation_groups` annotation property to `ATHA::MapQueryString` ([#486]) (George Dietrich) - Add `ATH::Request#port` and `ATH::Response#redirect?` methods ([#436]) (George Dietrich) - Add `#host`, `#scheme`, `#secure?`, and `#from_trusted_proxy?` methods to `ATH::Request` ([#440]) (George Dietrich) - Add `ATH::Request#content_type_format` to return the request format's name from its `content-type` header ([#477]) (George Dietrich) - Add `ATH::IPUtils` module ([#440]) (George Dietrich) - Add `.unquote`, `.split`, and `.combine` methods `ATH::HeaderUtils` ([#440]) (George Dietrich) - Add request matchers for headers and query parameters ([#491]) (George Dietrich) ### Removed - **Breaking:** Remove `ATHA::QueryParam` ([#426]) (George Dietrich) - **Breaking:** Remove `ATHA::RequestParam` ([#426]) (George Dietrich) - **Breaking:** Remove `ATH::Exception::InvalidParameter` ([#426]) (George Dietrich) - **Breaking:** Remove everything within `ATH::Params` namespace ([#426]) (George Dietrich) - **Breaking:** Remove `ATH::Action#params` ([#426]) (George Dietrich) - **Breaking:** Remove `ATH::Listeners::ParamFetcher` ([#426]) (George Dietrich) ### Fixed - Fix query parameters being dropped when redirecting to a trailing/non-trailing slash endpoint ([#436]) (George Dietrich) - Fix auto redirection with non-standard ports ([#480]) (George Dietrich) - Fix `multipart/form-data` not being mapped to the `form` format ([#441]) (George Dietrich) - Fix being unable to provide the path of an `ARTA::Route` annotation on a class as a positional argument ([#482]) (George Dietrich) - Fix error when attempting to use `ATH::Controller#redirect_view` and `ATH::Controller#route_redirect_view` ([#498]) (George Dietrich) - Fix error when attempting to use `ATH::Spec::APITestCase#unlink` ([#498]) (George Dietrich) [0.20.0]: https://github.com/athena-framework/framework/releases/tag/v0.20.0 [#425]: https://github.com/athena-framework/athena/pull/425 [#426]: https://github.com/athena-framework/athena/pull/426 [#428]: https://github.com/athena-framework/athena/pull/428 [#433]: https://github.com/athena-framework/athena/pull/433 [#436]: https://github.com/athena-framework/athena/pull/436 [#440]: https://github.com/athena-framework/athena/pull/440 [#441]: https://github.com/athena-framework/athena/pull/441 [#444]: https://github.com/athena-framework/athena/pull/444 [#474]: https://github.com/athena-framework/athena/pull/474 [#477]: https://github.com/athena-framework/athena/pull/477 [#480]: https://github.com/athena-framework/athena/pull/480 [#482]: https://github.com/athena-framework/athena/pull/482 [#486]: https://github.com/athena-framework/athena/pull/486 [#490]: https://github.com/athena-framework/athena/pull/490 [#491]: https://github.com/athena-framework/athena/pull/491 [#498]: https://github.com/athena-framework/athena/pull/498 ## [0.19.2] - 2024-07-31 ### Added - Add `ATH.run_console` as an easier entrypoint into the console application ([#413]) (George Dietrich) - Add support for additional boolean conversion values from request attributes ([#422]) (George Dietrich) ### Changed - **Breaking:** `ATH::RequestMatcher::Method` now requires an `Array(String)` as opposed to any `Enumerable(String)` ([#431]) (George Dietrich) - Update minimum `crystal` version to `~> 1.13.0` ([#433]) (George Dietrich) - Updates usages of `UTF-8` in response headers to `utf-8` as preferred by the RFC ([#417]) (George Dietrich) ### Fixed - Fix the content negotiation implementation not working ([#431]) (George Dietrich) [0.19.2]: https://github.com/athena-framework/framework/releases/tag/v0.19.2 [#413]: https://github.com/athena-framework/athena/pull/413 [#417]: https://github.com/athena-framework/athena/pull/417 [#422]: https://github.com/athena-framework/athena/pull/422 [#431]: https://github.com/athena-framework/athena/pull/431 [#433]: https://github.com/athena-framework/athena/pull/433 ## [0.19.1] - 2024-04-27 ### Fixed - Fix `framework` component docs landing on an empty page ([#399]) (George Dietrich) - Fix `Athena::Clock` not being aliased to the interface correctly ([#400]) (George Dietrich) - Fix `ATHA::View` annotation being defined in incorrect namespace ([#403]) (George Dietrich) - Fix `ATH::ErrorRenderer` not being aliased to the interface correctly ([#404]) (George Dietrich) [0.19.1]: https://github.com/athena-framework/framework/releases/tag/v0.19.1 [#399]: https://github.com/athena-framework/athena/pull/399 [#400]: https://github.com/athena-framework/athena/pull/400 [#403]: https://github.com/athena-framework/athena/pull/403 [#404]: https://github.com/athena-framework/athena/pull/404 ## [0.19.0] - 2024-04-09 ### Changed - **Breaking:** change how framework features are configured ([#337], [#374], [#383]) (George Dietrich) - Update minimum `crystal` version to `~> 1.11.0` ([#270]) (George Dietrich) - Integrate website into monorepo ([#365]) (George Dietrich) ### Added - Support for Windows OS ([#270]) (George Dietrich) - Add `ATH::RequestMatcher` as a generic way of matching an `ATH::Request` given a set of rules ([#338]) (George Dietrich) - Raise an exception if a controller's return value fails to serialize instead of just returning `nil` ([#357]) (George Dietrich) - Add support for new Crystal 1.12 `Process.on_terminate` method ([#394]) (George Dietrich) ### Fixed - Fix macro splat deprecation ([#330]) (George Dietrich) - Normalize `ATH::Request#method` to always be uppercase ([#338]) (George Dietrich) - Fixed not being able to use top level configuration annotations on controller action parameters ([#356]) (George Dietrich) [0.19.0]: https://github.com/athena-framework/framework/releases/tag/v0.19.0 [#270]: https://github.com/athena-framework/athena/pull/270 [#330]: https://github.com/athena-framework/athena/pull/330 [#337]: https://github.com/athena-framework/athena/pull/337 [#338]: https://github.com/athena-framework/athena/pull/338 [#356]: https://github.com/athena-framework/athena/pull/356 [#357]: https://github.com/athena-framework/athena/pull/357 [#365]: https://github.com/athena-framework/athena/pull/365 [#374]: https://github.com/athena-framework/athena/pull/374 [#383]: https://github.com/athena-framework/athena/pull/383 [#394]: https://github.com/athena-framework/athena/pull/394 ## [0.18.2] - 2023-10-09 ### Changed - Change routing logic to redirect `GET` and `HEAD` requests with a trailing slash to the route without one if it exists, and vice versa ([#307]) (George Dietrich) ### Added - Add native tab completion support to the built-in `ATH::Commands` ([#296]) (George Dietrich) - Add support for defining multiple route annotations on a single controller action method ([#315]) (George Dietrich) - Require the new `Athena::Clock` component ([#318]) (George Dietrich) - Add additional `ATH::Spec::APITestCase` request helper methods ([#312], [#313]) (George Dietrich) ### Fixed - Fix incorrectly generated route paths with a controller level prefix and no action level `/` prefix ([#308]) (George Dietrich) [0.18.2]: https://github.com/athena-framework/framework/releases/tag/v0.18.2 [#296]: https://github.com/athena-framework/athena/pull/296 [#307]: https://github.com/athena-framework/athena/pull/307 [#308]: https://github.com/athena-framework/athena/pull/308 [#312]: https://github.com/athena-framework/athena/pull/312 [#313]: https://github.com/athena-framework/athena/pull/313 [#315]: https://github.com/athena-framework/athena/pull/315 [#318]: https://github.com/athena-framework/athena/pull/318 ## [0.18.1] - 2023-05-29 ### Added - Add support for serializing arbitrarily nested controller action return types ([#273]) (George Dietrich) - Allow using constants for controller action's `path` ([#279]) (George Dietrich) ### Fixed - Fix incorrect `content-length` header value when returning multi-byte strings ([#288]) (George Dietrich) [0.18.1]: https://github.com/athena-framework/framework/releases/tag/v0.18.1 [#273]: https://github.com/athena-framework/athena/pull/273 [#279]: https://github.com/athena-framework/athena/pull/279 [#288]: https://github.com/athena-framework/athena/pull/288 ## [0.18.0] - 2023-02-20 ### Changed - **Breaking:** upgrade [Athena::EventDispatcher](https://athenaframework.org/EventDispatcher/) to [0.2.x](https://github.com/athena-framework/event-dispatcher/blob/master/CHANGELOG.md#020---2023-01-07) ([#205]) (George Dietrich) - **Breaking:** deprecate the `ATH::ParamConverter` concept in favor of [Value Resolvers](https://athenaframework.org/Framework/Controller/ValueResolvers/Interface) ([#243]) (George Dietrich) - **Breaking:** rename various types/methods to better adhere to https://github.com/crystal-lang/crystal/issues/10374 ([#243]) (George Dietrich) - **Breaking:** Change `ATH::Spec::AbstractBrowser` to be a `class` ([#249]) (George Dietrich) - **Breaking:** upgrade [Athena::Validator](https://athenaframework.org/Validator/) to [0.3.x](https://github.com/athena-framework/validator/blob/master/CHANGELOG.md#030---2023-01-07) ([#250]) (George Dietrich) - Improve service `ATH::Controller`s to not need the `public: true` `ADI::Register` field ([#213]) (George Dietrich) - Update minimum `crystal` version to `~> 1.6.0` ([#205]) (George Dietrich) ### Added - Add trace logging to `ATH::Listeners::CORS` to aid in debugging ([#265]) (George Dietrich) - Introduce new `framework.debug` parameter that is `true` if the binary was _not_ built with the `--release` flag ([#249]) (George Dietrich) - Add built-in [HTTP Expectation](https://athenaframework.org/Framework/Spec/Expectations/HTTP) methods to `ATH::Spec::WebTestCase` ([#249]) (George Dietrich) - Add `#response` and `#request` methods to `ATH::Spec::AbstractBrowser` types ([#249]) (George Dietrich) - Add [ATHR](https://athenaframework.org/Framework/aliases/#ATHR) alias to make using value resolver annotations easier ([#243]) (George Dietrich) - Add [ATH::Commands::Commands::DebugEventDispatcher](https://athenaframework.org/Framework/Commands/DebugEventDispatcher) framework CLI command to aid in debugging the event dispatcher ([#241]) (George Dietrich) - Add [ATH::Commands::Commands::DebugRouter](https://athenaframework.org/Framework/Commands/DebugRouter) and [ATH::Commands::Commands::DebugRouterMatch](https://athenaframework.org/Framework/Commands/DebugRouterMatch) framework CLI commands to aid in debugging the router ([#224]) (George Dietrich) - Add integration for the [Athena::Console](https://athenaframework.org/Console/) component ([#218]) (George Dietrich) ### Fixed - Correctly populate `content-length` based on the response content's size ([#267]) (George Dietrich) - Prevent wildcard CORS `expose_headers` value when `allow_credentials` is `true` ([#264]) (George Dietrich) - Correctly handle `JSON::Serializable` values within `Hash`/`NamedTuple` controller action return types ([#253]) (George Dietrich) - Fix [ATH::ParameterBag#get?](https://athenaframework.org/Framework/ParameterBag/#Athena::Framework::ParameterBag#get?(name,_type)) not returning `nil` if it could not convert the value to the desired type ([#243]) (George Dietrich) [0.18.0]: https://github.com/athena-framework/framework/releases/tag/v0.18.0 [#205]: https://github.com/athena-framework/athena/pull/205 [#213]: https://github.com/athena-framework/athena/pull/213 [#218]: https://github.com/athena-framework/athena/pull/218 [#224]: https://github.com/athena-framework/athena/pull/224 [#241]: https://github.com/athena-framework/athena/pull/241 [#243]: https://github.com/athena-framework/athena/pull/243 [#249]: https://github.com/athena-framework/athena/pull/249 [#250]: https://github.com/athena-framework/athena/pull/250 [#253]: https://github.com/athena-framework/athena/pull/253 [#264]: https://github.com/athena-framework/athena/pull/264 [#265]: https://github.com/athena-framework/athena/pull/265 [#267]: https://github.com/athena-framework/athena/pull/267 ## [0.17.1] - 2022-09-05 ### Changed - **Breaking:** ensure parameter names defined on interfaces match the implementation ([#188]) (George Dietrich) [0.17.1]: https://github.com/athena-framework/framework/releases/tag/v0.17.1 [#188]: https://github.com/athena-framework/athena/pull/188 ## [0.17.0] - 2022-05-14 _Checkout [this](https://forum.crystal-lang.org/t/athena-0-17-0/4624) forum thread for an overview of changes within the ecosystem._ ### Added - Add `pcre2` library dependency to `shard.yml` ([#159]) (George Dietrich) - Add [ATH::Arguments::Resolvers::Enum](https://athenaframework.org/Framework/Arguments/Resolvers/Enum/) to allow resolving `Enum` members directly to controller actions ([#173]) (George Dietrich) - Add [ATH::Arguments::Resolvers::UUID](https://athenaframework.org/Framework/Arguments/Resolvers/UUID/) to allow resolving `UUID`s directly to controller actions by ([#176]) (George Dietrich) - Add [ATH::ParameterBag#has(name, type)](https://athenaframework.org/Framework/ParameterBag/#Athena::Framework::ParameterBag#has?(name,type)) that checks if a parameter with the provided name exists, and that is of the provided type ([#176]) (George Dietrich) - Add [ATH::Arguments::Resolvers::DefaultValue](https://athenaframework.org/Framework/Arguments/Resolvers/DefaultValue/) to allow resolving an action parameter's default value if no other value was provided ([#177]) (George Dietrich) ### Changed - **Breaking:** rename `ATH::Arguments::Resolvers::ArgumentValueResolverInterface` to `ATH::Arguments::Resolvers::Interface` ([#176]) (George Dietrich) - **Breaking:** bump `athena-framework/serializer` to `~> 0.3.0` ([#181]) (George Dietrich) - **Breaking:** bump `athena-framework/validator` to `~> 0.2.0` ([#181]) (George Dietrich) - Expose the default value of an [ATH::Arguments::ArgumentMetadata](https://athenaframework.org/Framework/Arguments/ArgumentMetadata/) ([#176]) (George Dietrich) - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Fixed - Fix error when two controller share a common action name ([#146]) (George Dietrich) - Fix release badge to use correct repo ([#161]) (George Dietrich) - Fix query/request param docs to use new error responses ([#167]) (George Dietrich) - Fix incorrect `Athena::Framework` `Log` name ([#175]) (George Dietrich) [0.17.0]: https://github.com/athena-framework/framework/releases/tag/v0.17.0 [#146]: https://github.com/athena-framework/athena/pull/146 [#159]: https://github.com/athena-framework/athena/pull/159 [#161]: https://github.com/athena-framework/athena/pull/161 [#167]: https://github.com/athena-framework/athena/pull/167 [#169]: https://github.com/athena-framework/athena/pull/169 [#173]: https://github.com/athena-framework/athena/pull/173 [#175]: https://github.com/athena-framework/athena/pull/175 [#176]: https://github.com/athena-framework/athena/pull/176 [#177]: https://github.com/athena-framework/athena/pull/177 [#181]: https://github.com/athena-framework/athena/pull/181 ## [0.16.0] - 2022-01-22 _First release in the [athena-framework/framework](https://github.com/athena-framework/framework) repo, post monorepo._ ### Added - Add dependency on `athena-framework/routing` ([#141]) (George Dietrich) - Allow prepending [HTTP::Handlers](https://crystal-lang.org/api/HTTP/Handler.html) to the Athena server ([#133]) (George Dietrich) - Add common HTTP methods (get, post, put, delete) to [ATH::Spec::APITestCase](https://athenaframework.org//Framework/Spec/APITestCase/#Athena::Framework::Spec::APITestCase-methods) ([#134]) (George Dietrich) - Add overload of [ATH::Spec::APITestCase#request](https://athenaframework.org/Framework/Spec/APITestCase/#Athena::Framework::Spec::APITestCase#request(method,path,body,headers)) that accepts an [ATH::Request](https://athenaframework.org/Framework/Request/) or [HTTP::Request](https://crystal-lang.org/api/HTTP/Request.html) ([#134]) (George Dietrich) - Allow running an HTTPS server via passing an [OpenSSL::SSL::Context::Server](https://crystal-lang.org/api/OpenSSL/SSL/Context/Server.html) to `ATH.run` ([#135], [#136]) (George Dietrich) - Add [ATH::ParameterBag#set(hash)](https://athenaframework.org/Framework/ParameterBag/#Athena::Framework::ParameterBag#set(name,value,type)) that allows setting a hash of key/value pairs ([#141]) (George Dietrich) ### Changed - **Breaking:** integrate the [Athena::Routing](https://athenaframework.org/Routing/) component ([#141]) (George Dietrich) ### Removed - **Breaking:** remove dependency on [amberframework/amber-router](https://github.com/amberframework/amber-router) ([#141]) (George Dietrich) [0.16.0]: https://github.com/athena-framework/framework/releases/tag/v0.16.0 [#133]: https://github.com/athena-framework/athena/pull/133 [#134]: https://github.com/athena-framework/athena/pull/134 [#135]: https://github.com/athena-framework/athena/pull/135 [#136]: https://github.com/athena-framework/athena/pull/136 [#141]: https://github.com/athena-framework/athena/pull/141 ## [0.15.1] - 2021-12-13 ### Changed - Include error list in `ATH::Exception::InvalidParameter` ([#124]) (George Dietrich) - Set the base path of parameter errors to the name of the parameter ([#124]) (George Dietrich) [0.15.1]: https://github.com/athena-framework/athena/releases/tag/v0.15.1 [#124]: https://github.com/athena-framework/athena/pull/124 ## [0.15.0] - 2021-10-30 _Last release in the [athena-framework/athena](https://github.com/athena-framework/athena) repo, pre monorepo._ ### Added - Expose the raw [HTTP::Request](https://crystal-lang.org/api/HTTP/Request.html) method from an `ATH::Request` ([#115]) (George Dietrich) - Add built in [ATH::RequestBodyConverter](https://athenaframework.org/Framework/RequestBodyConverter) param converter ([#116]) (George Dietrich) - Add `VERSION` constant to `Athena::Framework` namespace ([#120]) (George Dietrich) ### Changed - **Breaking:** rename base param converter type to `ATH::ParamConverter` and make it a class ([#116]) (George Dietrich) - **Breaking:** rename the component from `Athena::Routing` to `Athena::Framework` ([#120]) (George Dietrich) ### Fixed - Fix incorrect parameter type restriction on `ATH::ParameterBag#set` ([#116]) (George Dietrich) - Fix incorrect ivar type on `AVD::Exception::Exceptions::ValidationFailed#violations` ([#116]) (George Dietrich) - Correctly reject requests with whitespace when converting numeric inputs ([#117]) (George Dietrich) [0.15.0]: https://github.com/athena-framework/athena/releases/tag/v0.15.0 [#115]: https://github.com/athena-framework/athena/pull/115 [#116]: https://github.com/athena-framework/athena/pull/116 [#117]: https://github.com/athena-framework/athena/pull/117 [#120]: https://github.com/athena-framework/athena/pull/120 ================================================ FILE: src/components/framework/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/framework/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2020 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/framework/README.md ================================================ # Athena Framework [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/framework.svg)](https://github.com/athena-framework/framework/releases) A web framework comprised of reusable, independent components. ## Getting Started Checkout the [Documentation](https://athenaframework.org/getting_started). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/framework/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.22.0 ### Dedicated service for accessing annotations defined on a controller action and/or parameter The `#annotation_configurations` getter on `ATH::Action` and `ATH::Controller::ParameterMetadata` has been removed. Inject and use the new [ATH::AnnotationResolver](https://athenaframework.org/Framework/AnnotationResolver) service to access the annotations: Before: ```crystal # Action request.action.annotation_configurations # Parameter parameter.annotation_configurations ``` After: ```crystal # Action @annotation_resolver.action_annotations(request) # Parameter @annotation_resolver.action_parameter_annotations(request, parameter.name) ``` ### HTTP types extracted to dedicated component The following types have been extracted from the `framework` component into the dedicated `http` component. Any references will need their namespace updated from `Athena::Framework` (`ATH`) to `Athena::HTTP` (`AHTTP`): - `Request` - `Response` - `ResponseHeaders` - `RedirectResponse` - `StreamedResponse` - `BinaryFileResponse` - `ParameterBag` - `HeaderUtils` - `IPUtils` - `RequestStore` - `AbstractFile` - `UploadedFile` - `RequestMatcher` - `RequestMatcher::Attributes` - `RequestMatcher::Header` - `RequestMatcher::Hostname` - `RequestMatcher::Method` - `RequestMatcher::Path` - `RequestMatcher::QueryParameter` - `Exception::ConflictingHeaders` - `Exception::SuspiciousOperation` - `Exception::RequestExceptionInterface` - `Exception::File` - `Exception::FileNotFound` - `Exception::FileSizeLimitExceeded` - `Exception::Logic` ### Request/Response handling types extracted to dedicated component The following types have been extracted from the `framework` component into the dedicated `http_kernel` component. Any references will need their namespace updated from `Athena::Framework` (`ATH`) to `Athena::HTTPKernel` (`AHK`): - `Action` - `ActionBase` - `Controller::ArgumentResolver` - `Controller::ArgumentResolverInterface` - `Controller::ParameterMetadata` - `Controller::ValueResolvers::DefaultValue` - `Controller::ValueResolvers::Request` - `Controller::ValueResolvers::RequestAttribute` - `ErrorRenderer` - `ErrorRendererInterface` - `Events::Action` - `Events::Exception` - `Events::Request` - `Events::RequestAware` - `Events::Response` - `Events::SettableResponse` - `Events::Terminate` - `Events::View` - `Exception::BadGateway` - `Exception::BadRequest` - `Exception::Conflict` - `Exception::Forbidden` - `Exception::Gone` - `Exception::HTTPException` - `Exception::LengthRequired` - `Exception::Logic` - `Exception::MethodNotAllowed` - `Exception::NotAcceptable` - `Exception::NotFound` - `Exception::NotImplemented` - `Exception::PreconditionFailed` - `Exception::ServiceUnavailable` - `Exception::TooManyRequests` - `Exception::Unauthorized` - `Exception::UnprocessableEntity` - `Exception::UnsupportedMediaType` - `Listeners::Error` - `Listeners::Routing` ### Change how the `ATH::Action` is accessed The `AHTTP::Request#action` getter used to access the matched `ATH::Action` instance has been removed. The action is now stored within the request's attributes as `"_action"` and must be accessed via: ```crystal request.attributes.get("_action", AHK::ActionBase) ```` `#get?` may be used in place of `#action?` if it's not guaranteed the action exists. ## Upgrade to 0.20.0 ### Change how query parameters are represented The `ATHA::QueryParam` annotation applied to the controller action is replaced with the `ATHA::MapQueryParameter` annotation applied directly to the parameter. Before: ```crystal class ExampleController < ATH::Controller @[ARTA::Get("/")] @[ATHA::QueryParam("page")] def index(page : Int32) : Int32 page end end ``` After: ```crystal class ExampleController < ATH::Controller @[ARTA::Get("/")] def index(@[ATHA::MapQueryParameter] page : Int32) : Int32 page end end ``` See the [API Docs](https://athenaframework.org/Framework/Controller/ValueResolvers/QueryParameter/#Athena::Framework::Controller::ValueResolvers::QueryParameter) for more information. ### Change how request parameters are handled The `ATHA::RequestParam` annotation that allowed mapping `x-www-form-urlencoded` form data within the request body to particular controller action parameters has been removed in favor of `ATHR::RequestBody`, which now supports deserializing form data request bodies into a DTO type. Before: ```crystal class ExampleController < ATH::Controller @[ARTA::Post("/login")] @[ATHA::RequestParam("username")] @[ATHA::RequestParam("password")] def login(username : String, password : String) : Nil # ... end end ``` After: ```crystal record LoginDTO, username : String, password : String do include URI::Params::Serializable end class ExampleController < ATH::Controller @[ARTA::Post("/login")] def login(@[ATHA::MapRequestBody] login : LoginDTO) : Nil # ... end end ``` This provides better consistency and additional features such as adding validation constraints to the request parameters. ### Normalization of Exception types The namespace exception types live in has changed from `ATH::Exceptions` to `ATH::Exception`. Any usages of `framework` exception types will need to be updated. If using a `rescue` statement with a parent exception type, either from the `framework` component or Crystal stdlib, double check it to ensure it'll still rescue what you are expecting it will. ### `ATHR::Interface.configuration` scoping Previously if you had a value resolver using the `configuration` macro: ```cr struct Multiply include ATHR::Interface configuration This # ... end ``` The `This` configuration would be scoped to the `Multiply` namespace, i.e. `@[Multiply::This]`. Scoping is now handled separately, meaning the same resolver could define multiple configurations to an entirely different namespace. If you wish to retain the same behavior, provide the FQN to the `configuration` macro: `configuration Multiply::This`. If you wish to move the configuration to another namespace, prefix the FQN with `::`: `configuration ::MyApp::Annotations::Multiply`. ## Upgrade to 0.19.0 ### Change how framework features are configured This change is a pretty fundamental change and cannot really be easily captured in this upgrading guide. Instead, take a moment to review the updated [Configuration](https://athenaframework.org/getting_started/configuration/) section in the getting started guide. At a high level, the `.configure` calls have been replaced with `ATH.configure` that handles both configuration and parameters. ================================================ FILE: src/components/framework/docs/.gitkeep ================================================ ================================================ FILE: src/components/framework/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Framework site_url: https://athenaframework.org/Framework/ repo_url: https://github.com/athena-framework/framework nav: - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: index.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-clock/src/athena-clock.cr - ./lib/athena-console/src/athena-console.cr - ./lib/athena-contracts/src/athena-contracts.cr - ./lib/athena-dependency_injection/src/athena-dependency_injection.cr - ./lib/athena-event_dispatcher/src/athena-event_dispatcher.cr - ./lib/athena-http/src/athena-http.cr - ./lib/athena-http_kernel/src/athena-http_kernel.cr - ./lib/athena-image_size/src/athena-image_size.cr - ./lib/athena-mime/src/athena-mime.cr - ./lib/athena-negotiation/src/athena-negotiation.cr - ./lib/athena-routing/src/athena-routing.cr - ./lib/athena-serializer/src/athena-serializer.cr - ./lib/athena-validator/src/athena-validator.cr - ./lib/athena/src/athena.cr - ./lib/athena/src/spec.cr source_locations: lib/athena: https://github.com/athena-framework/framework/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/framework/shard.yml ================================================ name: athena version: 0.22.0 crystal: ~> 1.19 license: MIT repository: https://github.com/athena-framework/athena documentation: https://athenaframework.org/Framework description: | A web framework comprised of reusable, independent components. authors: - George Dietrich dependencies: athena-clock: github: athena-framework/clock version: ~> 0.3.0 athena-console: github: athena-framework/console version: ~> 0.4.0 athena-contracts: github: athena-framework/contracts version: ~> 0.1.0 athena-dependency_injection: github: athena-framework/dependency-injection version: ~> 0.4.0 athena-event_dispatcher: github: athena-framework/event-dispatcher version: ~> 0.4.0 athena-http: github: athena-framework/http version: ~> 0.1.0 athena-http_kernel: github: athena-framework/http-kernel version: ~> 0.1.0 athena-mime: github: athena-framework/mime version: ~> 0.2.0 athena-negotiation: github: athena-framework/negotiation version: ~> 0.2.0 athena-routing: github: athena-framework/routing version: ~> 0.2.0 athena-serializer: github: athena-framework/serializer version: ~> 0.4.0 athena-validator: github: athena-framework/validator version: ~> 0.5.0 ================================================ FILE: src/components/framework/spec/argument_resolver_controller_spec.cr ================================================ require "./spec_helper" struct ArgumentResolverControllerTest < ATH::Spec::APITestCase def test_happy_path1 : Nil self.post("/argument-resolvers/float").body.should eq "3.14" end def test_happy_path2 : Nil self.post("/argument-resolvers/string").body.should eq %("fooo") end end ================================================ FILE: src/components/framework/spec/assets/file-big.txt ================================================ I'm not big, but I'm big enough to carry more than 50 bytes inside me. ================================================ FILE: src/components/framework/spec/assets/file-small.txt ================================================ I'm a file with less than 50 bytes. ================================================ FILE: src/components/framework/spec/assets/foo.txt ================================================ foo ================================================ FILE: src/components/framework/spec/assets/greeting.ecr ================================================ Greetings, <%= name %>! ================================================ FILE: src/components/framework/spec/assets/layout.ecr ================================================

Content:

<%= content -%> ================================================ FILE: src/components/framework/spec/assets/openssl/openssl.crt ================================================ -----BEGIN CERTIFICATE----- MIIDXTCCAkWgAwIBAgIJAKtJGQyJHN83MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV BAYTAkFSMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX aWRnaXRzIFB0eSBMdGQwHhcNMTYwNTI5MTUwMzI1WhcNNDMxMDE0MTUwMzI1WjBF MQswCQYDVQQGEwJBUjETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB CgKCAQEAqz+M6CnLr8wJ5ooDiNU7D2hxfZqxculFP1y2wTDuxJfP8LqPO4o1NLpU E9H2idIn/iMLZrRpeK38lw8RmorEh0ykOQ2jXbw9Lw+xgQXjmsf0ZcXqSB82VD6q 7JsGOF+Qq3I/YGegINfiOYMw60r8YEMTBJlz7tyeuJrCx2VUwBOa2Rtx7n0fzSom 5jYAHEMQA6bAmShNOtCRn45NeVStQS1XTZ6XavmLiCUrgvEfWj+FlrpQQiTqoxGd dOTz1G0/0+FdJ7By/G/GbDBc2xuix7Fai7qhuLB5KAVd73Vy6T09U5TfDcUi+CNx cvJu0YPn9vVkRIuAoH3lMpprtzLaGwIDAQABo1AwTjAdBgNVHQ4EFgQU7DjYFtaT vnHQCfVWLOilVdco8mwwHwYDVR0jBBgwFoAU7DjYFtaTvnHQCfVWLOilVdco8mww DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAgApc4DjKd3x3lDeENS/s CZck6IkK+iErXeevIQFvi2poorTdoCdmnl4Hn+VgElTx8OL48WulDtqppEVY5I5t qT4AU7UXMBvmySw9cOB4nSMSYJVmtAYnVa61WICpQ8tIOunanRxwB32I/BUUc6rr 0i+iAiW8x4aPsG5SGafIwtfNhY1pJa4nyo/VAJKxEKIl5jgeITBGHCO8ZHHqTcBu bj/DuWC3vGN5pVR3mb+O7Q1X+nhOZaSJYkB0nfLBKpWdMx0jrp1ZvbwDH5RrzzdD ZRtrL2CVb3uWpbiCS8UaFcd1PaD92yT0IkxEhdIWH6WyDqs1/DZzbKDzxAlDiaCS ZA== -----END CERTIFICATE----- ================================================ FILE: src/components/framework/spec/assets/openssl/openssl.key ================================================ -----BEGIN PRIVATE KEY----- MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCrP4zoKcuvzAnm igOI1TsPaHF9mrFy6UU/XLbBMO7El8/wuo87ijU0ulQT0faJ0if+IwtmtGl4rfyX DxGaisSHTKQ5DaNdvD0vD7GBBeOax/RlxepIHzZUPqrsmwY4X5Crcj9gZ6Ag1+I5 gzDrSvxgQxMEmXPu3J64msLHZVTAE5rZG3HufR/NKibmNgAcQxADpsCZKE060JGf jk15VK1BLVdNnpdq+YuIJSuC8R9aP4WWulBCJOqjEZ105PPUbT/T4V0nsHL8b8Zs MFzbG6LHsVqLuqG4sHkoBV3vdXLpPT1TlN8NxSL4I3Fy8m7Rg+f29WREi4CgfeUy mmu3MtobAgMBAAECggEAHmOir7hrCwFcaGrpgajFWFCigzWmc8vtm/bp/5KdbIm8 Pu38aQZ3tqmyLeo+o+qFalXxugIeDWpivrPP3eruQUxagD1pVkMHYIiaaVkQMPF2 73CVyMKxM3YDgwVnry1WUPZvRL5e7jUhUi9zyO1/p91/THum1SaVjBD6q8PRrFwD 2hjbi1ZYuzTJE9/7EWnrIeJUUx/TbhTM1aseufCpCbTQA5MA7ZVJKiW9+ZWPUw4x hfaDJx/kzFWE3DHU0L3eU83AFPZR2rmLOQONB2BhsSr5V7BGLtcY/4HTcRlzTt2g ZSPZiD7IhpFcOyWiqATmGtmuFOl9dOORHtgF6VxJsQKBgQDdlcRbEbBv+78GQosK Vh8ByhiDE1GQ4G4MoxxdtainBfbg4Uy+A2GDO9SgrD2VX3CSn+ZtJ8EBOO3wh9io /+X4Eoitkfpo0ZyAQQbSwXjVCZNANw8T27lYRAjIGVlNf/A3c7k3XdkdvSqkifhx e8sUFKZVR+82g9s3nAJgGxI5VwKBgQDF2GPdOw64rT3GxYsFl4qp5fc7r5IAgR73 ZPWPVApYrimzpu/5AEPA/dgAcRqflP3HKCJE+gJxtUgOnyd6GXSApD6rkBx3PGd1 1ZtAsQw3wzWwQxzrQfjv4OMHy1ky1OtORvT/g1zWUKJdE+cm0CmonSbYE2Fgjkh4 +G8spDk23QKBgDMBvLdx9PlyK+DXBIaWmICi8s2Jbuc4olyKV4dCv9Xiy5eshSvg P1wkM6fgvjRaSeGWqUZLNmR/pFYQD1Gnxlo6effqeIgUaEAlt9pf6t6vW5QWmIPr uliVIKhfHW13m+ZH30Tdd5Me7mf90pDc/DxdHITZEDmuVJISeYGB+cn1AoGAHdHt y2yZXXCPPSSNPbyHo/ALga2G3hiYKEXJVV8faBpoIrHovakyjSY1pmtlzePRFHGS KL9eGvFt+PY4JwkrLDCVWZqRD8/E8FfP3MJSyxzbPMQA2dzJvq4wyf32Zdj91oCP cOvF1G+26TyUvJ7niIiXUD4rkTgg6ErZxurBzOkCgYAOLlXQC+GkDjOzksSwJ60e KtcI4/7Z0CA43Tp6rLxcheNAaTcmMtmoqGyp7kChOlcJ5IOl1uXCaWP9Xho7gJqu bIK9i6hum8b1uTBI2U7FN3pUD+mKgaqXXfHjvVtoB9OS9Rug9loRkyXFtD6EZhdJ uGKDTMXbzMWWKPpoYd55Vw== -----END PRIVATE KEY----- ================================================ FILE: src/components/framework/spec/athena_spec.cr ================================================ require "./spec_helper" private class MockHandler include ::HTTP::Handler def call(context) end end describe Athena::Framework do describe ATH::Server do describe ".new" do it "creates a server with the provided args" do ATH::Server.new 1234, "google.com", false end it "creates a server with a prepended ::HTTP::Handler" do ATH::Server.new prepend_handlers: [MockHandler.new] end it "creates a server with SSL context" do context = OpenSSL::SSL::Context::Server.new context.certificate_chain = "#{__DIR__}/assets/openssl/openssl.crt" context.private_key = "#{__DIR__}/assets/openssl/openssl.key" ATH::Server.new ssl_context: context end end end end ================================================ FILE: src/components/framework/spec/bundle_spec.cr ================================================ require "./spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "./spec_helper.cr") end private def assert_compiles(code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compiles code, line: line, preamble: %(require "./spec_helper.cr") end describe ATH::Bundle, tags: "compiled" do describe ATH::Listeners::CORS do it "wildcard allow_headers with allow_credentials" do assert_compile_time_error "'expose_headers' cannot contain a wildcard ('*') when 'allow_credentials' is 'true'.", <<-'CR' ATH.configure({ framework: { cors: { enabled: true, defaults: { allow_credentials: true, expose_headers: ["*"], }, }, }, }) CR end it "does not exist if not enabled" do assert_compile_time_error "undefined method 'athena_framework_listeners_cors'", <<-CR ADI.container.athena_framework_listeners_cors CR end it "correctly wires up the listener based on its configuration" do assert_compiles <<-'CR' ATH.configure({ framework: { cors: { enabled: true, defaults: { allow_credentials: true, allow_origin: ["allow_origin", /foo/], allow_headers: ["allow_headers", "X-My-Header"], allow_methods: ["allow_methods"], expose_headers: ["expose_headers", "X-My-Header"], max_age: 123 }, }, }, }) macro finished macro finished \{% service = ADI::ServiceContainer::SERVICE_HASH["athena_framework_listeners_cors"] arg = service["parameters"]["config"]["value"] %} ASPEC.compile_time_assert(\{{ arg =~ /allow_credentials: true/ }}, "Expected allow_credentials: true") ASPEC.compile_time_assert(\{{ arg =~ /allow_origin: \["allow_origin", \/foo\/\]/ }}, "Expected allow_origin") ASPEC.compile_time_assert(\{{ arg =~ /allow_headers: \["allow_headers", "x-my-header"]/ }}, "Expected allow_headers") ASPEC.compile_time_assert(\{{ arg =~ /allow_methods: \["allow_methods"]/ }}, "Expected allow_methods") ASPEC.compile_time_assert(\{{ arg =~ /expose_headers: \["expose_headers", "x-my-header"]/ }}, "Expected expose_headers") ASPEC.compile_time_assert(\{{ arg =~ /max_age: 123/ }}, "Expected max_age: 123") end end CR end end describe ATH::Listeners::Format do it "correctly wires up the listener based on its configuration" do assert_compiles <<-'CR' ATH.configure({ framework: { format_listener: { enabled: true, rules: [ {priorities: ["json", "xml"], host: /api\.example\.com/, fallback_format: "json"}, {path: /^\/image/, priorities: ["jpeg", "gif"], fallback_format: false, stop: true}, {methods: ["HEAD"], priorities: ["xml", "html"], prefer_extension: false}, {path: /^\/image/, priorities: ["foo"]}, ], }, }, }) macro finished macro finished \{% service = ADI::ServiceContainer::SERVICE_HASH["athena_framework_listeners_format"] %} ASPEC.compile_time_assert(\{{ service["parameters"]["format_negotiator"]["value"].stringify == "athena_framework_view_format_negotiator" }}, "Expected format_negotiator to be athena_framework_view_format_negotiator") \{% service = ADI::ServiceContainer::SERVICE_HASH["athena_framework_view_format_negotiator"] map = service["calls"] %} ASPEC.compile_time_assert(\{{ map.size == 4 }}, "Expected 4 format negotiator rules") # Hostname rule \{% m0, rule = map[0][1] matcher = ADI::ServiceContainer::SERVICE_HASH[m0.stringify]["parameters"]["matchers"]["value"] %} ASPEC.compile_time_assert(\{{ matcher.includes? %(AHTTP::RequestMatcher::Hostname.new(/api\\.example\\.com/)) }}, "Expected hostname matcher for api.example.com") ASPEC.compile_time_assert(\{{ rule.includes? "ATH::View::FormatNegotiator::Rule.new" }}, "Expected hostname rule to be a FormatNegotiator::Rule") ASPEC.compile_time_assert(\{{ rule =~ /fallback_format: "json"/ }}, "Expected hostname rule fallback_format: json") ASPEC.compile_time_assert(\{{ rule =~ /prefer_extension: true/ }}, "Expected hostname rule prefer_extension: true") ASPEC.compile_time_assert(\{{ rule =~ /priorities: \["json", "xml"\]/ }}, "Expected hostname rule priorities: json, xml") ASPEC.compile_time_assert(\{{ rule =~ /stop: false/ }}, "Expected hostname rule stop: false") # Path rule \{% m1, rule = map[1][1] matcher = ADI::ServiceContainer::SERVICE_HASH[m1.stringify]["parameters"]["matchers"]["value"] %} ASPEC.compile_time_assert(\{{ matcher.includes? %(AHTTP::RequestMatcher::Path.new(/^\\/image/)) }}, "Expected path matcher for /image") ASPEC.compile_time_assert(\{{ rule.includes? "ATH::View::FormatNegotiator::Rule.new" }}, "Expected path rule to be a FormatNegotiator::Rule") ASPEC.compile_time_assert(\{{ rule =~ /fallback_format: false/ }}, "Expected path rule fallback_format: false") ASPEC.compile_time_assert(\{{ rule =~ /prefer_extension: true/ }}, "Expected path rule prefer_extension: true") ASPEC.compile_time_assert(\{{ rule =~ /priorities: \["jpeg", "gif"\]/ }}, "Expected path rule priorities: jpeg, gif") ASPEC.compile_time_assert(\{{ rule =~ /stop: true/ }}, "Expected path rule stop: true") # Methods rule \{% m2, rule = map[2][1] matcher = ADI::ServiceContainer::SERVICE_HASH[m2.stringify]["parameters"]["matchers"]["value"] %} ASPEC.compile_time_assert(\{{ matcher.includes? %(AHTTP::RequestMatcher::Method.new(["HEAD"])) }}, "Expected method matcher for HEAD") ASPEC.compile_time_assert(\{{ rule.includes? "ATH::View::FormatNegotiator::Rule.new" }}, "Expected methods rule to be a FormatNegotiator::Rule") ASPEC.compile_time_assert(\{{ rule =~ /fallback_format: "json"/ }}, "Expected methods rule fallback_format: json") ASPEC.compile_time_assert(\{{ rule =~ /prefer_extension: false/ }}, "Expected methods rule prefer_extension: false") ASPEC.compile_time_assert(\{{ rule =~ /priorities: \["xml", "html"\]/ }}, "Expected methods rule priorities: xml, html") ASPEC.compile_time_assert(\{{ rule =~ /stop: false/ }}, "Expected methods rule stop: false") # Tests matcher reuse logic \{% m3, rule = map[3][1] %} ASPEC.compile_time_assert(\{{ m3 == m1 }}, "Expected matcher reuse for path rules") end end CR end end describe ATH::Listeners::File do it "correctly wires up the services based on its configuration" do assert_compiles <<-'CR' ATH.configure({ framework: { file_uploads: { enabled: true, temp_dir: "/tmp/dir", max_uploads: 12, max_file_size: 1_000_i64, }, }, }) macro finished macro finished \{% service = ADI::ServiceContainer::SERVICE_HASH["athena_framework_listeners_file"] %} ASPEC.compile_time_assert(\{{ !service.nil? }}, "Expected athena_framework_listeners_file service to exist") \{% service = ADI::ServiceContainer::SERVICE_HASH["athena_framework_file_parser"] %} ASPEC.compile_time_assert(\{{ !service.nil? }}, "Expected athena_framework_file_parser service to exist") \{% parameters = service["parameters"] %} ASPEC.compile_time_assert(\{{ parameters["temp_dir"]["value"] == "/tmp/dir" }}, "Expected temp_dir to be /tmp/dir") ASPEC.compile_time_assert(\{{ parameters["max_uploads"]["value"] == 12 }}, "Expected max_uploads to be 12") ASPEC.compile_time_assert(\{{ parameters["max_file_size"]["value"] == 1000_i64 }}, "Expected max_file_size to be 1000") end end CR end end end ================================================ FILE: src/components/framework/spec/commands/debug_event_dispatcher_spec.cr ================================================ require "../spec_helper" private class MyEvent < AED::Event; end private class MyOtherEvent < AED::Event; end struct DebugEventDispatcherCommandTest < ASPEC::TestCase def test_specific_event : Nil tester = self.command_tester ret = tester.execute event: "MyEvent", decorated: false ret.should eq ACON::Command::Status::SUCCESS tester.display.should contain "Registered Listeners for the MyEvent Event" tester.display.should contain "#1 unknown callable 0" tester.display.should contain "#2 some_service#some_method -1" tester.display.should_not contain "GenericEvent" end def test_specific_event_no_match : Nil tester = self.command_tester ret = tester.execute event: "blah", decorated: false, capture_stderr_separately: true ret.should eq ACON::Command::Status::SUCCESS tester.display.should be_empty tester.error_output(true).should contain "[WARNING] The event 'blah' does not have any registered listeners." end def test_specific_event_partial_match_single : Nil tester = self.command_tester ret = tester.execute event: "other", decorated: false ret.should eq ACON::Command::Status::SUCCESS tester.display.should contain "Registered Listeners for the MyOtherEvent Event" tester.display.should contain "#1 unknown callable 0" tester.display.should_not contain "MyEvent" tester.display.should_not contain "GenericEvent" end def test_specific_event_partial_match_multiple : Nil tester = self.command_tester ret = tester.execute event: "my", decorated: false ret.should eq ACON::Command::Status::SUCCESS tester.display.should contain "Registered Listeners Grouped by Event" tester.display.should contain "MyEvent event" tester.display.should contain "#1 unknown callable 0" tester.display.should contain "#2 some_service#some_method -1" tester.display.should contain "MyOtherEvent event" tester.display.should contain "#1 unknown callable 0" tester.display.should_not contain "GenericEvent" end def test_all_events : Nil tester = self.command_tester ret = tester.execute decorated: false ret.should eq ACON::Command::Status::SUCCESS tester.display.should contain "Registered Listeners Grouped by Event" tester.display.should contain "MyEvent event" tester.display.should contain "#1 unknown callable 0" tester.display.should contain "#2 some_service#some_method -1" tester.display.should contain "MyOtherEvent event" tester.display.should contain "#1 unknown callable 0" tester.display.should contain "Athena::EventDispatcher::GenericEvent(String, String) event" tester.display.should contain "#1 generic-event 0" end @[DataProvider("complete_provider")] def test_complete(input : Array(String), expected_suggestions : Array(String)) : Nil tester = ACON::Spec::CommandCompletionTester.new self.command suggestions = tester.complete input suggestions.should eq expected_suggestions end def complete_provider : Hash { "nothing" => {[] of String, ["Athena::EventDispatcher::GenericEvent(String, String)", "MyEvent", "MyOtherEvent"]}, "format" => {["--format"], ["txt"]}, } end private def command : ATH::Commands::DebugEventDispatcher ATH::Commands::DebugEventDispatcher.new self.dispatcher end private def command_tester : ACON::Spec::CommandTester ACON::Spec::CommandTester.new self.command end private def dispatcher : AED::EventDispatcherInterface dispatcher = AED::EventDispatcher.new dispatcher.listener(AED::GenericEvent(String, String), name: "generic-event") { } dispatcher.listener(MyEvent) { } dispatcher.listener(MyEvent, priority: -1, name: "some_service#some_method") { } dispatcher.listener(MyOtherEvent) { } dispatcher end end ================================================ FILE: src/components/framework/spec/commands/debug_router_match_spec.cr ================================================ require "../spec_helper" struct DebugRouterMatchCommandTest < ASPEC::TestCase def test_matches : Nil tester = self.command_tester ret = tester.execute path_info: "/foo", decorated: false ret.should eq ACON::Command::Status::SUCCESS tester.display.should contain "Route Name | foo" end def test_no_match : Nil tester = self.command_tester ret = tester.execute path_info: "/test", decorated: false ret.should eq ACON::Command::Status::FAILURE tester.display(true).should contain "None of the routes match the path '/test'" end def test_partial : Nil tester = self.command_tester ret = tester.execute path_info: "/bar/11", decorated: false ret.should eq ACON::Command::Status::FAILURE tester.display.should contain "Route 'bar' almost matches but requirement for 'id' does not match (10)" tester.display.should contain "None of the routes match the path '/bar/11'" end private def command_tester : ACON::Spec::CommandTester application = ACON::Application.new "Athena Specs" application.add ATH::Commands::DebugRouterMatch.new self.router application.add ATH::Commands::DebugRouter.new self.router ACON::Spec::CommandTester.new application.find "debug:router:match" end private def router : ART::RouterInterface route_collection = ART::RouteCollection.new route_collection.add "foo", ART::Route.new "foo" route_collection.add "bar", ART::Route.new "/bar/{id<10>}" context = ART::RequestContext.new ART::Router.new route_collection, context: context end end ================================================ FILE: src/components/framework/spec/commands/debug_router_spec.cr ================================================ require "../spec_helper" struct DebugRouterCommandTest < ASPEC::TestCase @router : ART::Router def initialize routes = ART::RouteCollection.new routes.add "routerdebug_session_welcome", ART::Route.new "/session" routes.add "routerdebug_session_welcome_name", ART::Route.new "/session/{name}" routes.add "routerdebug_session_logout", ART::Route.new "/session_logout" routes.add "routerdebug_test", ART::Route.new "/test" @router = ART::Router.new routes end def test_all_routes : Nil tester = self.command_tester ret = tester.execute ret.should eq ACON::Command::Status::SUCCESS tester.display.should contain "routerdebug_session_welcome" tester.display.should contain "/session" tester.display.should contain "routerdebug_session_welcome_name" tester.display.should contain "/session/{name}" tester.display.should contain "routerdebug_session_logout" tester.display.should contain "/session_logout" tester.display.should contain "routerdebug_test" tester.display.should contain "/test" end def test_single_route : Nil tester = self.command_tester ret = tester.execute name: "routerdebug_session_welcome_name" ret.should eq ACON::Command::Status::SUCCESS tester.display.should contain "routerdebug_session_welcome_name" tester.display.should contain "/session/{name}" end def test_multiple_matching_routes : Nil tester = self.command_tester tester.inputs "3" ret = tester.execute name: "routerdebug", interactive: true ret.should eq ACON::Command::Status::SUCCESS tester.display.should contain "Select one of the matching routes:" tester.display.should contain "routerdebug_test" tester.display.should contain "/test" end def test_multiple_matching_routes_no_interaction : Nil tester = self.command_tester ret = tester.execute name: "routerdebug", interactive: false ret.should eq ACON::Command::Status::SUCCESS tester.display.should_not contain "Select one of the matching routes:" tester.display.should contain "routerdebug_session_welcome" tester.display.should contain "/session" tester.display.should contain "routerdebug_session_welcome_name" tester.display.should contain "/session/{name}" tester.display.should contain "routerdebug_session_logout" tester.display.should contain "/session_logout" tester.display.should contain "routerdebug_test" tester.display.should contain "/test" end def test_missing_route : Nil tester = self.command_tester expect_raises ACON::Exception::InvalidArgument, "The route 'blah' does not exist." do tester.execute name: "blah", interactive: true end end @[DataProvider("complete_provider")] def test_complete(input : Array(String), expected_suggestions : Array(String)) : Nil tester = ACON::Spec::CommandCompletionTester.new self.command suggestions = tester.complete input suggestions.should eq expected_suggestions end def complete_provider : Hash { "nothing" => {[] of String, ["routerdebug_session_welcome", "routerdebug_session_welcome_name", "routerdebug_session_logout", "routerdebug_test"]}, "format" => {["--format"], ["txt"]}, } end private def command : ATH::Commands::DebugRouter ATH::Commands::DebugRouter.new(@router) end private def command_tester ACON::Spec::CommandTester.new self.command end end ================================================ FILE: src/components/framework/spec/compiler_spec.cr ================================================ require "./spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "./spec_helper.cr"), postamble: "ATH.run" end private def assert_compiles(code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compiles code, line: line, preamble: %(require "./spec_helper.cr"), postamble: "ATH.run" end describe Athena::Framework do describe "compiler errors", tags: "compiled" do it "action parameter missing type restriction" do assert_compile_time_error "Route action parameter 'CompileController#action:id' must have a type restriction.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(path: "/:id")] def action(id) : Int32 123 end end CR end it "action missing return type" do assert_compile_time_error "Route action return type must be set for 'CompileController#action'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action 123 end end CR end it "class method action" do assert_compile_time_error "Routes can only be defined as instance methods. Did you mean 'CompileController#class_method'?", <<-CR class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def self.class_method : Int32 123 end end CR end it "when action does not have a path" do assert_compile_time_error "Route action 'CompileController#action' is missing its path.", <<-CR class CompileController < ATH::Controller @[ARTA::Get] def action : Int32 123 end end CR end describe "when the controller type conflicts with internal ATH types" do it "complies when the controller is a service" do assert_compiles <<-CR @[ADI::Register] class Controller::ExampleController < ATH::Controller @[ARTA::Get("/name")] def name : Nil end end CR end it "compiles when the controller is not a service" do assert_compiles <<-CR class Controller::ExampleController < ATH::Controller @[ARTA::Get("/name")] def name : Nil end end CR end end describe "when a controller action is mistakenly overridden" do it "within the same controller" do assert_compile_time_error "A controller action named '#action' already exists within 'CompileController'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(path: "/foo")] def action : String "foo" end @[ARTA::Get(path: "/bar")] def action : String "bar" end end CR end it "within a different controller" do assert_compiles <<-CR class ExampleController < ATH::Controller @[ARTA::Get(path: "/foo")] def action : String "foo" end end class CompileController < ATH::Controller @[ARTA::Get(path: "/bar")] def action : String "bar" end end CR end end describe ARTA::Route do it "when there is a prefix for a controller action with a locale that does not have a route" do assert_compile_time_error "Route action 'CompileController#action' is missing paths for locale(s) 'de'.", <<-CR @[ARTA::Route(path: {"de" => "/german", "fr" => "/france"})] class CompileController < ATH::Controller @[ARTA::Get(path: {"fr" => ""})] def action : Nil end end CR end it "when a controller action has a locale that is missing a prefix" do assert_compile_time_error "Route action 'CompileController#action' is missing a corresponding route prefix for the 'de' locale.", <<-CR @[ARTA::Route(path: {"fr" => "/france"})] class CompileController < ATH::Controller @[ARTA::Get(path: {"de" => "/foo", "fr" => "/bar"})] def action : Nil end end CR end it "has an unexpected type as the #methods" do assert_compile_time_error "Route action 'CompileController#action' expects a 'StringLiteral | ArrayLiteral | TupleLiteral' for its 'ARTA::Route#methods' field, but got a 'NumberLiteral'.", <<-CR class CompileController < ATH::Controller @[ARTA::Route("/", methods: 123)] def action : Nil end end CR end it "requires ARTA::Route to use 'methods'" do assert_compile_time_error "Route action 'CompileController#action' cannot change the required methods when _NOT_ using the 'ARTA::Route' annotation.", <<-CR class CompileController < ATH::Controller @[ARTA::Get("/", methods: "SEARCH")] def action : Nil; end end CR end describe "invalid field types" do describe "path" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects a 'StringLiteral | HashLiteral(StringLiteral, StringLiteral)' for its 'ARTA::Route#path' field, but got a 'NumberLiteral'.", <<-CR @[ARTA::Route(path: 10)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end it "route ann" do assert_compile_time_error "Route action 'CompileController#action' expects a 'StringLiteral | HashLiteral(StringLiteral, StringLiteral)' for its 'ARTA::Get#path' field, but got a 'NumberLiteral'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(path: 10)] def action : Nil; end end CR end end describe "defaults" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects a 'HashLiteral(StringLiteral, _)' for its 'ARTA::Route#defaults' field, but got a 'NumberLiteral'.", <<-CR @[ARTA::Route(defaults: 10)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end it "route ann" do assert_compile_time_error "Route action 'CompileController#action' expects a 'HashLiteral(StringLiteral, _)' for its 'ARTA::Get#defaults' field, but got a 'NumberLiteral'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(defaults: 10)] def action : Nil; end end CR end end describe "locale" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects a 'StringLiteral' for its 'ARTA::Route#locale' field, but got a 'NumberLiteral'.", <<-CR @[ARTA::Route(locale: 10)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end it "route ann" do assert_compile_time_error "Route action 'CompileController#action' expects a 'StringLiteral' for its 'ARTA::Get#locale' field, but got a 'NumberLiteral'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(locale: 10)] def action : Nil; end end CR end end describe "format" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects a 'StringLiteral' for its 'ARTA::Route#format' field, but got a 'NumberLiteral'.", <<-CR @[ARTA::Route(format: 10)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end it "route ann" do assert_compile_time_error "Route action 'CompileController#action' expects a 'StringLiteral' for its 'ARTA::Get#format' field, but got a 'NumberLiteral'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(format: 10)] def action : Nil; end end CR end end describe "stateless" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects a 'BoolLiteral' for its 'ARTA::Route#stateless' field, but got a 'NumberLiteral'.", <<-CR @[ARTA::Route(stateless: 10)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end it "route ann" do assert_compile_time_error "Route action 'CompileController#action' expects a 'BoolLiteral' for its 'ARTA::Get#stateless' field, but got a 'NumberLiteral'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(stateless: 10)] def action : Nil; end end CR end end describe "name" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects a 'StringLiteral' for its 'ARTA::Route#name' field, but got a 'NumberLiteral'.", <<-CR @[ARTA::Route(name: 10)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end it "route ann" do assert_compile_time_error "Route action 'CompileController#action' expects a 'StringLiteral' for its 'ARTA::Get#name' field, but got a 'NumberLiteral'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(path: "/", name: 10)] def action : Nil; end end CR end end describe "requirements" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects a 'HashLiteral(StringLiteral, StringLiteral | RegexLiteral)' for its 'ARTA::Route#requirements' field, but got a 'NumberLiteral'.", <<-CR @[ARTA::Route(requirements: 10)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end it "route ann" do assert_compile_time_error "Route action 'CompileController#action' expects a 'HashLiteral(StringLiteral, StringLiteral | RegexLiteral)' for its 'ARTA::Get#requirements' field, but got a 'NumberLiteral'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(path: "/", requirements: 10)] def action : Nil; end end CR end end describe "schemes" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects a 'StringLiteral | Enumerable(StringLiteral)' for its 'ARTA::Route#schemes' field, but got a 'NumberLiteral'.", <<-CR @[ARTA::Route(schemes: 10)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end end describe "methods" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects a 'StringLiteral | Enumerable(StringLiteral)' for its 'ARTA::Route#methods' field, but got a 'NumberLiteral'.", <<-CR @[ARTA::Route(methods: 10)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end end describe "host" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects a 'StringLiteral | RegexLiteral' for its 'ARTA::Route#host' field, but got a 'NumberLiteral'.", <<-CR @[ARTA::Route(host: 10)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end it "route ann" do assert_compile_time_error "Route action 'CompileController#action' expects a 'StringLiteral | RegexLiteral' for its 'ARTA::Get#host' field, but got a 'NumberLiteral'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(path: "/", host: 10)] def action : Nil; end end CR end end describe "condition" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects an 'ART::Route::Condition' for its 'ARTA::Route#condition' field, but got a 'NumberLiteral'.", <<-CR @[ARTA::Route(condition: 10)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end it "route ann" do assert_compile_time_error "Route action 'CompileController#action' expects an 'ART::Route::Condition' for its 'ARTA::Get#condition' field, but got a 'NumberLiteral'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(path: "/", condition: 10)] def action : Nil; end end CR end end describe "priority" do it "controller ann" do assert_compile_time_error "Route action 'CompileController' expects a 'NumberLiteral' for its 'ARTA::Route#priority' field, but got a 'BoolLiteral'.", <<-CR @[ARTA::Route(priority: true)] class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end CR end it "route ann" do assert_compile_time_error "Route action 'CompileController#action' expects a 'NumberLiteral' for its 'ARTA::Get#priority' field, but got a 'BoolLiteral'.", <<-CR class CompileController < ATH::Controller @[ARTA::Get(priority: false)] def action : Nil; end end CR end end end end describe ATHR::RequestBody do it "when the action parameter is not serializable" do assert_compile_time_error " The annotation '@[ATHA::MapRequestBody]' cannot be applied to 'CompileController#action:foo : Foo' since the 'Athena::Framework::Controller::ValueResolvers::RequestBody' resolver only supports parameters of type 'Athena::Serializer::Serializable | JSON::Serializable | URI::Params::Serializable | (Athena::HTTP::UploadedFile | Nil) | Array(Athena::HTTP::UploadedFile)'.", <<-CR record Foo, text : String class CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action(@[ATHA::MapRequestBody] foo : Foo) : Foo foo end end CR end end end end ================================================ FILE: src/components/framework/spec/controller/redirect_spec.cr ================================================ require "../spec_helper" struct RedirectControllerTest < ASPEC::TestCase def test_empty_route_permanent : Nil request = AHTTP::Request.new "GET", "/" controller = ATH::Controller::Redirect.new ex = expect_raises AHK::Exception::HTTPException do controller.redirect_url request, "", true end ex.status_code.should eq 410 end def test_empty_route_non_permanent : Nil request = AHTTP::Request.new "GET", "/" controller = ATH::Controller::Redirect.new ex = expect_raises AHK::Exception::HTTPException do controller.redirect_url request, "" end ex.status_code.should eq 404 end def test_full_url : Nil request = AHTTP::Request.new "GET", "/" controller = ATH::Controller::Redirect.new response = controller.redirect_url request, "http://foo.com/" self.assert_redirect_url response, "http://foo.com/" response.status.found?.should be_true end def test_full_url_with_method_keep : Nil request = AHTTP::Request.new "GET", "/" controller = ATH::Controller::Redirect.new response = controller.redirect_url request, "http://foo.com/", keep_request_method: true self.assert_redirect_url response, "http://foo.com/" response.status.temporary_redirect?.should be_true end def test_protocol_relative : Nil request = AHTTP::Request.new "GET", "/" controller = ATH::Controller::Redirect.new response = controller.redirect_url request, "//foo.bar/" self.assert_redirect_url response, "http://foo.bar/" response.status.found?.should be_true end def test_url_redirect_default_ports : Nil host = "www.example.com" path = "/redirect-path" http_port = 1080 https_port = 1443 expected_url = "https://#{host}:#{https_port}#{path}" request = AHTTP::Request.new "GET", "/", headers: ::HTTP::Headers{"host" => "#{host}:#{http_port}"} controller = ATH::Controller::Redirect.new https_port: https_port response = controller.redirect_url request, path, scheme: "https" self.assert_redirect_url response, expected_url expected_url = "http://#{host}:#{http_port}#{path}" request = AHTTP::Request.new "GET", "/", headers: ::HTTP::Headers{"host" => "#{host}:#{http_port}"} controller = ATH::Controller::Redirect.new http_port response = controller.redirect_url request, path, scheme: "http" self.assert_redirect_url response, expected_url end @[DataProvider("url_redirect_provider")] def test_url_redirect( scheme : String, http_port : Int32?, https_port : Int32?, request_scheme : String, request_port : Int32, expected_port : String, ) : Nil host = "www.example.com" path = "/redirect-path" expected_url = "#{scheme}://#{host}#{expected_port}#{path}" request = AHTTP::Request.new "GET", "/", headers: ::HTTP::Headers{"host" => "#{host}:#{request_port}"} request.scheme = request_scheme controller = ATH::Controller::Redirect.new response = controller.redirect_url request, path, scheme: scheme, http_port: http_port, https_port: https_port self.assert_redirect_url response, expected_url end def url_redirect_provider : Tuple { # Standard ports {"http", nil, nil, "http", 80, ""}, {"http", 80, nil, "http", 80, ""}, {"https", nil, nil, "http", 80, ""}, {"https", 80, nil, "http", 80, ""}, {"http", nil, nil, "https", 443, ""}, {"http", nil, 443, "https", 443, ""}, {"https", nil, nil, "https", 443, ""}, {"https", nil, 443, "https", 443, ""}, # Non-standard ports {"http", nil, nil, "http", 8080, ":8080"}, {"http", 4080, nil, "http", 8080, ":4080"}, {"http", 80, nil, "http", 8080, ""}, {"https", nil, nil, "http", 8080, ""}, {"https", nil, 8443, "http", 8080, ":8443"}, {"https", nil, 443, "http", 8080, ""}, {"https", nil, nil, "https", 8443, ":8443"}, {"https", nil, 4443, "https", 8443, ":4443"}, {"https", nil, 443, "https", 8443, ""}, {"http", nil, nil, "https", 8443, ""}, {"http", 8080, 4443, "https", 8443, ":8080"}, {"http", 80, 4443, "https", 8443, ""}, } end @[TestWith( {"http://www.example.com/redirect-path", "/redirect-path", ""}, {"http://www.example.com/redirect-path?foo=bar", "/redirect-path?foo=bar", ""}, {"http://www.example.com/redirect-path?f.o=bar", "/redirect-path", "f.o=bar"}, {"http://www.example.com/redirect-path?f.o=bar&a.c=example", "/redirect-path?f.o=bar", "a.c=example"}, {"http://www.example.com/redirect-path?f.o=bar&a.c=example&b.z=def", "/redirect-path?f.o=bar", "a.c=example&b.z=def"}, {"http://www.example.com/redirect-path?val=one&val=two", "/redirect-path?val=one", "val=two"}, )] def test_path_query_params(expected : String, path : String, query_string : String) : Nil scheme = "http" host = "www.example.com" port = 80 request = AHTTP::Request.new "GET", "/", headers: ::HTTP::Headers{"host" => "#{host}:#{port}"} request.query = query_string if query_string != "" controller = ATH::Controller::Redirect.new self.assert_redirect_url controller.redirect_url(request, path, scheme: scheme, http_port: port), expected end # TODO: For when we have a way to redirect to a route vs just a path # def test_redirect_with_query : Nil # end # def test_redirect_with_query_with_route_params_overriding : Nil # end private def assert_redirect_url(response : AHTTP::Response, expected : String) : Nil response.redirect?(expected).should be_true, failure_message: "Expected: '#{expected}'\n Got: '#{response.headers["location"]}'." end end ================================================ FILE: src/components/framework/spec/controller/value_resolvers/enum_spec.cr ================================================ require "../../spec_helper" enum TestEnum A B C end describe ATHR::Enum do describe "#resolve" do it "some other type" do ATHR::Enum.new.resolve(new_request, AHK::Controller::ParameterMetadata(Int32).new "enum").should be_nil ATHR::Enum.new.resolve(new_request, AHK::Controller::ParameterMetadata(Int32?).new "enum").should be_nil ATHR::Enum.new.resolve(new_request, AHK::Controller::ParameterMetadata(Bool | String).new "enum").should be_nil end it "is not a string" do parameter = AHK::Controller::ParameterMetadata(TestEnum).new "enum" request = new_request request.attributes.set "enum", 1 ATHR::Enum.new.resolve(request, parameter).should be_nil end it "that does not exist in request attributes" do parameter = AHK::Controller::ParameterMetadata(TestEnum).new "enum" ATHR::Enum.new.resolve(new_request, parameter).should be_nil end it "that is nilable and not exist in request attributes" do parameter = AHK::Controller::ParameterMetadata(TestEnum?).new "enum" ATHR::Enum.new.resolve(new_request, parameter).should be_nil end it "that is a union of another type" do parameter = AHK::Controller::ParameterMetadata(TestEnum | String).new "enum" request = new_request request.attributes.set "enum", "1" ATHR::Enum.new.resolve(request, parameter).should eq TestEnum::B end it "the enum member is nilable" do parameter = AHK::Controller::ParameterMetadata(TestEnum?).new "enum" request = new_request request.attributes.set "enum", "1" ATHR::Enum.new.resolve(request, parameter).should eq TestEnum::B end it "with a numeric based value" do parameter = AHK::Controller::ParameterMetadata(TestEnum).new "enum" request = new_request request.attributes.set "enum", "2" ATHR::Enum.new.resolve(request, parameter).should eq TestEnum::C end it "with a numeric based value with whitespace" do parameter = AHK::Controller::ParameterMetadata(TestEnum).new "enum" request = new_request request.attributes.set "enum", "2" ATHR::Enum.new.resolve(request, parameter).should eq TestEnum::C end it "with a string based value" do parameter = AHK::Controller::ParameterMetadata(TestEnum).new "enum" request = new_request request.attributes.set "enum", "B" ATHR::Enum.new.resolve(request, parameter).should eq TestEnum::B end it "with a string based nilable value" do parameter = AHK::Controller::ParameterMetadata(TestEnum?).new "enum" request = new_request request.attributes.set "enum", "B" ATHR::Enum.new.resolve(request, parameter).should eq TestEnum::B end it "with an unknown member value" do parameter = AHK::Controller::ParameterMetadata(TestEnum).new "enum" request = new_request request.attributes.set "enum", " 4 " expect_raises AHK::Exception::BadRequest, "Parameter 'enum' of enum type 'TestEnum' has no valid member for ' 4 '." do ATHR::Enum.new.resolve request, parameter end end end end ================================================ FILE: src/components/framework/spec/controller/value_resolvers/query_parameter_spec.cr ================================================ require "../../spec_helper" private def parameter( klass : T.class = String, *, default : T? = nil, ) forall T AHK::Controller::ParameterMetadata(T).new( "foo", default_value: default, has_default: !default.nil?, ) end private def resolver( name : String? = nil, validation_failed_status : ::HTTP::Status = :not_found, ) forall T ATHR::QueryParameter.new( MockAnnotationResolver.new( action_parameter_annotations: ADI::AnnotationConfigurations.new({ ATHA::MapQueryParameter => [ ATHA::MapQueryParameterConfiguration.new(name, validation_failed_status), ] of ADI::AnnotationConfigurations::ConfigurationBase, } of ADI::AnnotationConfigurations::Classes => Array(ADI::AnnotationConfigurations::ConfigurationBase)), expected_parameter_name: "foo" ) ) end describe ATHR::QueryParameter do describe "#resolve" do it "does not have the annotation" do parameter = AHK::Controller::ParameterMetadata(String).new "foo" ATHR::QueryParameter.new(MockAnnotationResolver.new).resolve(new_request, parameter).should be_nil end it "valid scalar parameter" do resolver.resolve(new_request(query: "foo=bar"), parameter).should eq "bar" end it "custom param name" do resolver(name: "blah").resolve(new_request(query: "blah=bar"), parameter).should eq "bar" end it "valid array parameter" do resolver.resolve(new_request(query: "foo=1&foo=2"), parameter(Array(Int32))).should eq [1, 2] end it "missing nilable" do resolver.resolve(new_request, parameter(Float64?)).should be_nil end it "non-nilable with default" do resolver.resolve(new_request, parameter(Bool, default: false)).should be_nil end it "missing non-nilable no default" do expect_raises AHK::Exception::NotFound, "Missing query parameter: 'foo'." do resolver.resolve new_request, parameter end end it "missing non-nilable no default custom status" do expect_raises AHK::Exception::UnprocessableEntity, "Missing query parameter: 'foo'." do resolver(validation_failed_status: :unprocessable_entity).resolve new_request, parameter end end it "invalid" do expect_raises AHK::Exception::NotFound, "Invalid query parameter: 'foo'." do resolver.resolve new_request(query: "foo=bar"), parameter(Int32) end end it "missing non-nilable no default custom status" do expect_raises AHK::Exception::UnprocessableEntity, "Invalid query parameter: 'foo'." do resolver(validation_failed_status: :unprocessable_entity).resolve new_request(query: "foo=bar"), parameter(Int32) end end end end ================================================ FILE: src/components/framework/spec/controller/value_resolvers/request_body_spec.cr ================================================ require "../../spec_helper" private record MockJSONSerializableEntity, id : Int32, name : String do include JSON::Serializable end private record MockASRSerializableEntity, id : Int32, name : String do include ASR::Serializable end private record MockValidatableASRSerializableEntity, id : Int32, name : String do include ASR::Serializable include AVD::Validatable end private record MockURISerializableEntity, id : Int32, name : String do include URI::Params::Serializable end private record MockJSONAndURISerializableEntity, id : Int32, name : String do include JSON::Serializable include URI::Params::Serializable end struct RequestBodyResolverTest < ASPEC::TestCase @target : ATHR::RequestBody @serializer : ASR::SerializerInterface @validator : AVD::Validator::ValidatorInterface @annotation_resolver : MockAnnotationResolver def initialize @validator = AVD::Spec::MockValidator.new @serializer = DeserializableMockSerializer(Nil).new @annotation_resolver = MockAnnotationResolver.new @target = ATHR::RequestBody.new @serializer, @validator, @annotation_resolver end def test_no_annotation : Nil ATHR::RequestBody.new(@serializer, @validator, @annotation_resolver).resolve(new_request, new_parameter).should be_nil end def test_raises_on_no_body : Nil expect_raises AHK::Exception::BadRequest, "Request does not have a body." do @target.resolve new_request, self.get_config MockJSONSerializableEntity end end def test_raises_on_empty_body : Nil expect_raises AHK::Exception::BadRequest, "Request does not have a body." do @target.resolve new_request(body: ""), self.get_config(MockJSONSerializableEntity) end end def test_raises_on_invalid_json : Nil expect_raises AHK::Exception::BadRequest, "Malformed JSON payload." do @target.resolve new_request(body: %()), self.get_config(MockJSONSerializableEntity) end end def test_raises_on_invalid_nested_json : Nil expect_raises AHK::Exception::BadRequest, "Malformed JSON payload." do @target.resolve new_request(body: %({"id": "foo"})), self.get_config(MockJSONSerializableEntity) end end def test_raises_on_missing_json_data : Nil expect_raises AHK::Exception::UnprocessableEntity, "Missing JSON attribute: name" do @target.resolve new_request(body: %({"id":10})), self.get_config(MockJSONSerializableEntity) end end def test_raises_on_missing_www_form_data : Nil expect_raises AHK::Exception::UnprocessableEntity, "Missing required property: 'name'." do @target.resolve new_request(body: "id=10", format: "form"), self.get_config(MockURISerializableEntity) end end def test_raises_on_missing_query_string_data : Nil expect_raises AHK::Exception::UnprocessableEntity, "Missing required property: 'name'." do @target.resolve new_request(query: "id=10"), self.get_config(MockURISerializableEntity, ATHA::MapQueryString, ATHA::MapQueryStringConfiguration.new) end end def test_it_raises_on_constraint_violations : Nil serializer = DeserializableMockSerializer(MockValidatableASRSerializableEntity).new serializer.deserialized_response = MockValidatableASRSerializableEntity.new 10, "" validator = AVD::Spec::MockValidator.new( AVD::Violation::ConstraintViolationList.new([ AVD::Violation::ConstraintViolation.new("error", "error", Hash(String, String).new, "", ".name", AVD::ValueContainer.new("")), ]) ) expect_raises AVD::Exception::ValidationFailed, "Validation failed" do ATHR::RequestBody.new(serializer, validator, @annotation_resolver).resolve new_request(body: %({"id":10,"name":""})), self.get_config(MockValidatableASRSerializableEntity) end end def test_it_supports_json_serializable : Nil request = new_request body: %({"id":10,"name":"Fred"}) object = @target.resolve request, self.get_config(MockJSONSerializableEntity) object = object.should be_a MockJSONSerializableEntity object.id.should eq 10 object.name.should eq "Fred" end def test_it_supports_asr_serializable : Nil serializer = DeserializableMockSerializer(MockASRSerializableEntity).new serializer.deserialized_response = MockASRSerializableEntity.new 10, "Fred" request = new_request body: %({"id":10,"name":"Fred"}) object = ATHR::RequestBody.new(serializer, @validator, @annotation_resolver).resolve request, self.get_config(MockASRSerializableEntity) object = object.should be_a MockASRSerializableEntity object.id.should eq 10 object.name.should eq "Fred" end def test_it_supports_uri_params_serializable : Nil serializer = DeserializableMockSerializer(MockURISerializableEntity).new serializer.deserialized_response = MockURISerializableEntity.new 10, "Fred" request = new_request body: "id=10&name=Fred", format: "form" object = ATHR::RequestBody.new(serializer, @validator, @annotation_resolver).resolve request, self.get_config(MockURISerializableEntity) object = object.should be_a MockURISerializableEntity object.id.should eq 10 object.name.should eq "Fred" end def test_it_supports_specifying_accepted_formats : Nil expect_raises AHK::Exception::UnsupportedMediaType, %(Unsupported format, expects one of: 'json, xml', but got 'form'.) do @target.resolve( new_request(body: "id=10&name=Fred", format: "form"), self.get_config(MockURISerializableEntity, configuration: ATHA::MapRequestBodyConfiguration.new(["json", "xml"])) ) end end def test_it_supports_query_string_serializable : Nil serializer = DeserializableMockSerializer(MockURISerializableEntity).new serializer.deserialized_response = MockURISerializableEntity.new 10, "Fred" request = new_request query: "id=10&name=Fred" object = ATHR::RequestBody.new(serializer, @validator, @annotation_resolver).resolve request, self.get_config(MockURISerializableEntity, ATHA::MapQueryString, ATHA::MapQueryStringConfiguration.new) object = object.should be_a MockURISerializableEntity object.id.should eq 10 object.name.should eq "Fred" end def test_it_supports_query_string_serializable_no_query_string : Nil serializer = DeserializableMockSerializer(MockURISerializableEntity).new serializer.deserialized_response = MockURISerializableEntity.new 10, "Fred" ATHR::RequestBody .new(serializer, @validator, @annotation_resolver) .resolve(new_request, self.get_config(MockURISerializableEntity, ATHA::MapQueryString, ATHA::MapQueryStringConfiguration.new)) .should be_nil end def test_it_supports_multiple_serializable : Nil serializer = DeserializableMockSerializer(MockJSONAndURISerializableEntity).new serializer.deserialized_response = MockJSONAndURISerializableEntity.new 10, "Fred" form_request = new_request body: "id=10&name=Fred", format: "form" json_request = new_request body: %({"id":10,"name":"Fred"}) resolver = ATHR::RequestBody.new serializer, @validator, @annotation_resolver form_object = resolver.resolve form_request, self.get_config(MockJSONAndURISerializableEntity) form_object = form_object.should be_a MockJSONAndURISerializableEntity json_object = resolver.resolve json_request, self.get_config(MockJSONAndURISerializableEntity) json_object = json_object.should be_a MockJSONAndURISerializableEntity form_object.id.should eq 10 form_object.name.should eq "Fred" json_object.id.should eq 10 json_object.name.should eq "Fred" end def test_it_supports_avd_validatable : Nil serializer = DeserializableMockSerializer(MockValidatableASRSerializableEntity).new serializer.deserialized_response = MockValidatableASRSerializableEntity.new 10, "Fred" request = new_request body: %({"id":10,"name":"Fred"}) object = ATHR::RequestBody.new(serializer, @validator, @annotation_resolver).resolve request, self.get_config(MockValidatableASRSerializableEntity) object = object.should be_a MockValidatableASRSerializableEntity object.id.should eq 10 object.name.should eq "Fred" end # File Uploads @[DataProvider("uploaded_file_context")] def test_uploaded_file_single_defaults(request : AHTTP::Request) : Nil object = @target.resolve request, self.get_config(AHTTP::UploadedFile, ATHA::MapUploadedFile, ATHA::MapUploadedFileConfiguration.new) object = object.should be_a AHTTP::UploadedFile object.basename.should eq "file-small.txt" object.size.should eq 35 end @[DataProvider("uploaded_file_context")] def test_uploaded_file_single_missing(request : AHTTP::Request) : Nil object = @target.resolve request, self.get_config(AHTTP::UploadedFile, ATHA::MapUploadedFile, ATHA::MapUploadedFileConfiguration.new, property_name: "empty") object.should be_nil end @[DataProvider("uploaded_file_context")] def test_uploaded_file_single_custom_name(request : AHTTP::Request) : Nil object = @target.resolve request, self.get_config(AHTTP::UploadedFile, ATHA::MapUploadedFile, ATHA::MapUploadedFileConfiguration.new(name: "bar")) object = object.should be_a AHTTP::UploadedFile object.basename.should eq "file-big.txt" object.size.should eq 70 end @[DataProvider("uploaded_file_context")] def test_uploaded_file_single_constraints_no_violation(request : AHTTP::Request) : Nil @target = ATHR::RequestBody.new @serializer, AVD.validator, @annotation_resolver object = @target.resolve request, self.get_config( AHTTP::UploadedFile, ATHA::MapUploadedFile, ATHA::MapUploadedFileConfiguration.new( name: "bar", constraints: AVD::Constraints::File.new(max_size: 100), ) ) object = object.should be_a AHTTP::UploadedFile object.basename.should eq "file-big.txt" object.size.should eq 70 end @[DataProvider("uploaded_file_context")] def test_uploaded_file_single_constraints_with_violation(request : AHTTP::Request) : Nil @target = ATHR::RequestBody.new @serializer, AVD.validator, @annotation_resolver ex = expect_raises AVD::Exception::ValidationFailed do @target.resolve request, self.get_config( AHTTP::UploadedFile, ATHA::MapUploadedFile, ATHA::MapUploadedFileConfiguration.new( name: "bar", constraints: AVD::Constraints::File.new(max_size: 50), ) ) end ex.violations.size.should eq 1 ex.violations[0].message.should eq "The file is too large (70.0 bytes). Allowed maximum size is 50.0 bytes." end @[DataProvider("uploaded_file_context")] def test_uploaded_file_array_of_files_empty(request : AHTTP::Request) : Nil object = @target.resolve request, self.get_config(Array(AHTTP::UploadedFile), ATHA::MapUploadedFile, ATHA::MapUploadedFileConfiguration.new, property_name: "qux") object = object.should be_a Array(AHTTP::UploadedFile) object.should be_empty end @[DataProvider("uploaded_file_context")] def test_uploaded_file_array_of_files_empty_nullable(request : AHTTP::Request) : Nil object = @target.resolve request, self.get_config(Array(AHTTP::UploadedFile)?, ATHA::MapUploadedFile, ATHA::MapUploadedFileConfiguration.new, property_name: "qux") object.should be_nil end @[DataProvider("uploaded_file_context")] def test_uploaded_file_array_of_files(request : AHTTP::Request) : Nil object = @target.resolve request, self.get_config(Array(AHTTP::UploadedFile), ATHA::MapUploadedFile, ATHA::MapUploadedFileConfiguration.new, property_name: "baz") object = object.should be_a Array(AHTTP::UploadedFile) object.size.should eq 2 object[0].basename.should eq "file-small.txt" object[0].size.should eq 35 object[1].basename.should eq "file-big.txt" object[1].size.should eq 70 end @[DataProvider("uploaded_file_context")] def test_uploaded_file_array_of_files_with_constraint(request : AHTTP::Request) : Nil @target = ATHR::RequestBody.new @serializer, AVD.validator, @annotation_resolver ex = expect_raises AVD::Exception::ValidationFailed do @target.resolve request, self.get_config( Array(AHTTP::UploadedFile), ATHA::MapUploadedFile, ATHA::MapUploadedFileConfiguration.new( name: "baz", constraints: AVD::Constraints::File.new(max_size: 50), ) ) end ex.violations.size.should eq 1 ex.violations[0].message.should eq "The file is too large (70.0 bytes). Allowed maximum size is 50.0 bytes." end def uploaded_file_context : Hash small = AHTTP::UploadedFile.new("#{__DIR__}/../../assets/file-small.txt", "fie-small.txt", "text/plain", test: true) big = AHTTP::UploadedFile.new("#{__DIR__}/../../assets/file-big.txt", "fie-big.txt", "text/plain", test: true) request = new_request( path: "/", method: "POST", files: { "foo" => [small], "bar" => [big], "baz" => [small, big], "empty" => [] of AHTTP::UploadedFile, } ) { "standard" => {request}, } end private def get_config(type : T.class, ann = ATHA::MapRequestBody, configuration = ATHA::MapRequestBodyConfiguration.new, property_name : String = "foo") forall T metadata = AHK::Controller::ParameterMetadata(T).new( property_name, ) @annotation_resolver.action_parameter_annotations = ADI::AnnotationConfigurations.new({ ann => [ configuration, ] of ADI::AnnotationConfigurations::ConfigurationBase, } of ADI::AnnotationConfigurations::Classes => Array(ADI::AnnotationConfigurations::ConfigurationBase)) metadata end end ================================================ FILE: src/components/framework/spec/controller/value_resolvers/time_spec.cr ================================================ require "../../spec_helper" private def resolver( annotations = ADI::AnnotationConfigurations.new, ) forall T ATHR::Time.new( MockAnnotationResolver.new( action_parameter_annotations: annotations, expected_parameter_name: "foo" ) ) end describe ATHR::Time do describe "#resolve" do it "some other type parameter" do resolver.resolve(new_request, AHK::Controller::ParameterMetadata(Int32).new "foo").should be_nil resolver.resolve(new_request, AHK::Controller::ParameterMetadata(Int32?).new "foo").should be_nil resolver.resolve(new_request, AHK::Controller::ParameterMetadata(Bool | Float64).new "foo").should be_nil end it "type is nilable and the value is nil" do parameter = AHK::Controller::ParameterMetadata(String?).new "foo" request = new_request request.attributes.set "foo", nil resolver.resolve(request, parameter).should be_nil end it "is not a Time parameter" do parameter = AHK::Controller::ParameterMetadata(String).new "foo" request = new_request resolver.resolve(request, parameter).should be_nil end it "type is nilable" do parameter = AHK::Controller::ParameterMetadata(::Time?).new "foo" request = new_request request.attributes.set "foo", "2020-04-07T12:34:56Z" resolver.resolve(request, parameter).should eq Time.utc 2020, 4, 7, 12, 34, 56 end it "type a union of another type" do parameter = AHK::Controller::ParameterMetadata(Int32 | ::Time).new "foo" request = new_request request.attributes.set "foo", "2020-04-07T12:34:56Z" resolver.resolve(request, parameter).should eq Time.utc 2020, 4, 7, 12, 34, 56 end it "is missing from request attributes" do parameter = AHK::Controller::ParameterMetadata(::Time).new "foo" request = new_request resolver.resolve(request, parameter).should be_nil end it "is is a ::Time instance already" do parameter = AHK::Controller::ParameterMetadata(::Time).new "foo" request = new_request request.attributes.set "foo", now = Time.utc resolver.resolve(request, parameter).should eq now end it "is not a string" do parameter = AHK::Controller::ParameterMetadata(::Time).new "foo" request = new_request request.attributes.set "foo", 100 resolver.resolve(request, parameter).should be_nil end it "parses RFC 3339 by default" do parameter = AHK::Controller::ParameterMetadata(::Time).new "foo" request = new_request request.attributes.set "foo", "2020-04-07T12:34:56Z" resolver.resolve(request, parameter).should eq Time.utc 2020, 4, 7, 12, 34, 56 end it "allows specifying a format" do parameter = AHK::Controller::ParameterMetadata(::Time).new("foo") request = new_request request.attributes.set "foo", "2020--04//07 12:34:56" resolver( annotations: ADI::AnnotationConfigurations.new({ ATHA::MapTime => [ ATHA::MapTimeConfiguration.new(format: "%Y--%m//%d %T"), ] of ADI::AnnotationConfigurations::ConfigurationBase, } of ADI::AnnotationConfigurations::Classes => Array(ADI::AnnotationConfigurations::ConfigurationBase)), ) .resolve(request, parameter).should eq Time.utc 2020, 4, 7, 12, 34, 56 end it "allows specifying a location to parse the format in" do parameter = AHK::Controller::ParameterMetadata(::Time).new("foo") request = new_request request.attributes.set "foo", "2020--04//07 12:34:56" resolver( annotations: ADI::AnnotationConfigurations.new({ ATHA::MapTime => [ ATHA::MapTimeConfiguration.new(format: "%Y--%m//%d %T", location: Time::Location.fixed(9001)), ] of ADI::AnnotationConfigurations::ConfigurationBase, } of ADI::AnnotationConfigurations::Classes => Array(ADI::AnnotationConfigurations::ConfigurationBase)), ) .resolve(request, parameter).should eq Time.local 2020, 4, 7, 12, 34, 56, location: Time::Location.fixed(9001) end it "raises an AHK::Exception::BadRequest if a time could not be parsed from the string" do parameter = AHK::Controller::ParameterMetadata(::Time).new "foo" request = new_request request.attributes.set "foo", "foo" expect_raises AHK::Exception::BadRequest, "Invalid date(time) for parameter 'foo'." do resolver.resolve request, parameter end end end end ================================================ FILE: src/components/framework/spec/controller/value_resolvers/uuid_spec.cr ================================================ require "../../spec_helper" describe ATHR::UUID do describe "#resolve" do it "does not exist in request attributes" do parameter = AHK::Controller::ParameterMetadata(UUID).new "foo" ATHR::UUID.new.resolve(new_request, parameter).should be_nil end it "some other type" do ATHR::UUID.new.resolve(new_request, AHK::Controller::ParameterMetadata(Int32).new "foo").should be_nil ATHR::UUID.new.resolve(new_request, AHK::Controller::ParameterMetadata(Int32?).new "foo").should be_nil ATHR::UUID.new.resolve(new_request, AHK::Controller::ParameterMetadata(Bool | String).new "foo").should be_nil end it "attribute exists but is not a string" do parameter = AHK::Controller::ParameterMetadata(UUID).new "foo" request = new_request request.attributes.set "foo", 100 ATHR::UUID.new.resolve(request, parameter).should be_nil end it "attribute exists but is nil with a nullable parameter" do parameter = AHK::Controller::ParameterMetadata(UUID?).new "foo" request = new_request request.attributes.set "foo", nil ATHR::UUID.new.resolve(request, parameter).should be_nil end it "with a valid value" do parameter = AHK::Controller::ParameterMetadata(UUID).new "foo" uuid = UUID.random request = new_request request.attributes.set "foo", uuid.to_s ATHR::UUID.new.resolve(request, parameter).should eq uuid end it "type a union of another type" do parameter = AHK::Controller::ParameterMetadata(UUID | Int32).new "foo" request = new_request uuid = UUID.random request.attributes.set "foo", uuid.to_s ATHR::UUID.new.resolve(request, parameter).should eq uuid end it "with a valid nilable value" do parameter = AHK::Controller::ParameterMetadata(UUID?).new "foo" uuid = UUID.random request = new_request request.attributes.set "foo", uuid.to_s ATHR::UUID.new.resolve(request, parameter).should eq uuid end it "with an invalid value" do parameter = AHK::Controller::ParameterMetadata(UUID).new "foo" request = new_request request.attributes.set "foo", "foo" expect_raises AHK::Exception::BadRequest, "Parameter 'foo' with value 'foo' is not a valid 'UUID'." do ATHR::UUID.new.resolve request, parameter end end end end ================================================ FILE: src/components/framework/spec/controller_spec.cr ================================================ require "./spec_helper" describe ATH::Controller do describe ".render" do it "creates a proper response for the template" do # ameba:disable Lint/UselessAssign name = "TEST" response = ATH::Controller.render "#{__DIR__}/assets/greeting.ecr" response.status.should eq ::HTTP::Status::OK response.headers["content-type"].should eq "text/html" response.content.chomp.should eq "Greetings, TEST!" end it "creates a proper response for the template with a layout" do # ameba:disable Lint/UselessAssign name = "TEST" response = ATH::Controller.render "#{__DIR__}/assets/greeting.ecr", "#{__DIR__}/assets/layout.ecr" response.status.should eq ::HTTP::Status::OK response.headers["content-type"].should eq "text/html" response.content.chomp.should eq "

Content:

Greetings, TEST!" end end describe "#redirect" do it "creates an AHTTP::RedirectResponse" do response = TestController.new.redirect "URL" response.status.should eq ::HTTP::Status::FOUND response.headers["location"].should eq "URL" response.content.should be_empty end it "allows passing a `Path` instance" do response = TestController.new.redirect Path["/app/assets/foo.txt"] response.status.should eq ::HTTP::Status::FOUND response.headers["location"].should eq "/app/assets/foo.txt" response.content.should be_empty end end it "#redirect_view" do response = TestController.new.redirect_view "URL", :im_a_teapot view = response.should be_a ATH::View(Nil) view.location.should eq "URL" view.status.should eq ::HTTP::Status::IM_A_TEAPOT end it "#route_redirect_view" do response = TestController.new.route_redirect_view "get_user_me" view = response.should be_a ATH::View(Nil) view.route.should eq "get_user_me" view.route_params.should be_empty end end ================================================ FILE: src/components/framework/spec/controllers/argument_resolver_controller.cr ================================================ @[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: 101}])] class GenericAnnotationEnabledCustomResolver include ATHR::Interface configuration ::MyResolverAnnotation def initialize( @annotation_resolver : ATH::AnnotationResolver, ); end def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata(Float64)) : Float64? return unless @annotation_resolver.action_parameter_annotations(request, parameter.name).has? MyResolverAnnotation 3.14 end def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata(String)) : String? return unless @annotation_resolver.action_parameter_annotations(request, parameter.name).has? MyResolverAnnotation "fooo" end def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) : Nil end end @[ARTA::Route(path: "/argument-resolvers")] class ArgumentResolverController < ATH::Controller @[ARTA::Post("/float")] def happy_path1( @[MyResolverAnnotation] value : Float64, ) : Float64 value end @[ARTA::Post("/string")] def happy_path2( @[MyResolverAnnotation] value : String, ) : String value end end ================================================ FILE: src/components/framework/spec/controllers/custom_annotation_controller.cr ================================================ require "../spec_helper" ADI.configuration_annotation SpecAnnotation ADI.configuration_annotation CustomAnn, id : Int32 ADI.configuration_annotation TopParameterAnn ADI.configuration_annotation MyApp::NestedParameterAnn @[ADI::Register] struct CustomAnnotationListener def initialize( @annotation_resolver : ATH::AnnotationResolver, ); end @[AEDA::AsEventListener] def on_response(event : AHK::Events::Response) : Nil action_annotations = @annotation_resolver.action_annotations event.request if action_annotations.has?(SpecAnnotation) event.response.headers["ANNOTATION"] = "true" end if custom_ann = action_annotations[CustomAnn]? event.response.headers["ANNOTATION_VALUE"] = custom_ann.id.to_s end end end @[CustomAnn(1)] class AnnotationController < ATH::Controller @[SpecAnnotation] get("/with-ann", return_type: Nil) { } get("/without-ann", return_type: Nil) { } @[CustomAnn(2)] get("/with-ann-override", return_type: Nil) { } @[ARTA::Get("/top-parameter-ann/{id}")] def top_parameter_ann(@[TopParameterAnn] id : Int32) : Nil end @[ARTA::Get("/nested-parameter-ann/{id}")] def nested_parameter_ann(@[MyApp::NestedParameterAnn] id : Int32) : Nil end end ================================================ FILE: src/components/framework/spec/controllers/file_upload_controller.cr ================================================ class FileUploadController < ATH::Controller @[ARTA::Post("/required_single_file_present")] def required_single_file_present(@[ATHA::MapUploadedFile] file : AHTTP::UploadedFile) : String? file.client_original_name end @[ARTA::Post("/required_single_file_missing")] def required_single_file_missing(@[ATHA::MapUploadedFile] file : AHTTP::UploadedFile) : String? file.client_original_name end @[ARTA::Post("/required_single_file_missing_with_constraint")] def required_single_file_missing_with_constraint( @[ATHA::MapUploadedFile(constraints: AVD::Constraints::File.new(mime_types: ["text/plain"]))] file : AHTTP::UploadedFile, ) : String? file.client_original_name end @[ARTA::Post("/required_array_present")] def required_array_present(@[ATHA::MapUploadedFile] file : Array(AHTTP::UploadedFile)) : String? file.first.client_original_name end @[ARTA::Post("/required_array_empty")] def required_array_empty(@[ATHA::MapUploadedFile] file : Array(AHTTP::UploadedFile)) : String? file.first.client_original_name end @[ARTA::Post("/optional_single_file_present")] def optional_single_file_present(@[ATHA::MapUploadedFile] file : AHTTP::UploadedFile?) : String? file.try &.client_original_name end @[ARTA::Post("/optional_single_file_missing")] def optional_single_file_missing(@[ATHA::MapUploadedFile] file : AHTTP::UploadedFile?) : String? file.try &.client_original_name end @[ARTA::Post("/optional_single_file_missing_with_constraint")] def optional_single_file_missing_with_constraint(@[ATHA::MapUploadedFile] file : AHTTP::UploadedFile?) : String? file.try &.client_original_name end @[ARTA::Post("/optional_array_present")] def optional_array_present(@[ATHA::MapUploadedFile] file : AHTTP::UploadedFile?) : String? file.try &.client_original_name end @[ARTA::Post("/optional_array_empty")] def optional_array_empty(@[ATHA::MapUploadedFile] file : AHTTP::UploadedFile?) : String? file.try &.client_original_name end end ================================================ FILE: src/components/framework/spec/controllers/prefix_controller.cr ================================================ CLASS_PREFIX = "/prefix" METHOD_PREFIX = "/index" @[ARTA::Route(path: CLASS_PREFIX)] class PrefixController < ATH::Controller @[ARTA::Get(path: METHOD_PREFIX)] def index : String "foo" end end ================================================ FILE: src/components/framework/spec/controllers/routing_controller.cr ================================================ require "../spec_helper" @[ADI::Register] class RoutingController < ATH::Controller def initialize(@request_store : AHTTP::RequestStore); end @[ARTA::Get("get/safe")] def safe_request_check : String initial_query = @request_store.request.try &.query sleep 250.milliseconds if initial_query == "foo" check_query = @request_store.request.try &.query initial_query == check_query ? "safe" : "unsafe" end get "/container/id", return_type: UInt64 do ADI.container.object_id end @[ARTA::Route(path: "/head-get/", methods: {"HEAD", "GET"})] def head_get_trailing_slash : String "HEAD-GET/" end @[ARTA::Head("/head")] def head : String "HEAD" end @[ARTA::Head("/get-head")] def get_head : ATH::View(String) self.view "GET-HEAD", headers: ::HTTP::Headers{"FOO" => "BAR"} end get "/cookies", return_type: AHTTP::Response do response = AHTTP::Response.new "FOO" response.headers << ::HTTP::Cookie.new "key", "value" response end @[ARTA::Post("unprocessable")] def unprocessable : AHTTP::Response AHTTP::Response.new "", :unprocessable_entity end @[ARTA::Get("art/response")] def response : AHTTP::Response AHTTP::Response.new "FOO", 418, ::HTTP::Headers{"content-type" => "BAR"} end @[ARTA::Get("art/streamed-response")] def streamed_response : AHTTP::Response AHTTP::StreamedResponse.new 418, ::HTTP::Headers{"content-type" => "BAR"} do |io| "FOO".to_json io end end @[ARTA::Get("art/redirect")] def redirect : AHTTP::RedirectResponse AHTTP::RedirectResponse.new "https://crystal-lang.org" end @[ARTA::Get("url")] def generate_url : String self.generate_url "routing_controller_response" end @[ARTA::Get("url-hash")] def generate_url_hash : String self.generate_url "routing_controller_response", {"id" => 10} end @[ARTA::Get("url-nt")] def generate_url_nt : String self.generate_url "routing_controller_response", id: 10 end @[ARTA::Get("url-nt-abso")] def generate_url_nt_absolute : String self.generate_url "routing_controller_response", id: 10, reference_type: :absolute_url end @[ARTA::Get("redirect-url")] def redirect_url : AHTTP::RedirectResponse self.redirect_to_route "routing_controller_response" end @[ARTA::Get("redirect-url-status")] def redirect_url_status : AHTTP::RedirectResponse self.redirect_to_route "routing_controller_response", :permanent_redirect end @[ARTA::Get("redirect-url-hash")] def redirect_url_hash : AHTTP::RedirectResponse self.redirect_to_route "routing_controller_response", {"id" => 10} end @[ARTA::Get("redirect-url-nt")] def redirect_url_nt : AHTTP::RedirectResponse self.redirect_to_route "routing_controller_response", id: 10 end @[ARTA::Get("default")] def default(id : Int32 = 10) : Int32 id end @[ARTA::Get("nilable")] def nilable(id : Int32?) : Int32? id end @[ARTA::Route("/custom-method", methods: "FOO")] def custom_http_method : String "FOO" end @[ATHA::View(status: :accepted)] @[ARTA::Get("custom-status")] def custom_status : String "foo" end @[ARTA::Post("/echo")] def post_echo(request : AHTTP::Request) : String (request.body.should_not be_nil).gets_to_end end @[ARTA::Put("/echo")] def put_echo(request : AHTTP::Request) : String (request.body.should_not be_nil).gets_to_end end get "/macro/get-nil", return_type: Nil do end get "/macro/add/{num1}/{num2}", num1 : Int32, num2 : Int32, return_type: Int32 do num1 + num2 end get "/macro" { "GET" } get "/macro/{foo}", foo : String do foo end post "/macro" do "POST" end put "/macro" do "PUT" end patch "/macro" do "PATCH" end delete "/macro" do "DELETE" end link "/macro" do "LINK" end unlink "/macro" do "UNLINK" end head "/macro" do "HEAD" end end ================================================ FILE: src/components/framework/spec/controllers/view_controller.cr ================================================ require "../spec_helper" private record Unserializable record JSONSerializableModel, id : Int32, name : String do include JSON::Serializable end private record BothSerializableModel, id : Int32, name : String do include JSON::Serializable include ASR::Serializable @[ASRA::Groups("foo")] @name : String end @[ARTA::Route(path: "view")] class ViewController < ATH::Controller @[ARTA::Get("/unserializable")] def unserializable : Unserializable Unserializable.new end @[ARTA::Get("/nil")] def nil_return : Nil end @[ARTA::Get("/json")] def json_serializable : JSONSerializableModel JSONSerializableModel.new 10, "Bob" end @[ARTA::Get("/json-array")] def json_array_serializable : Array(JSONSerializableModel) [ JSONSerializableModel.new(10, "Bob"), JSONSerializableModel.new(20, "Sally"), ] of JSONSerializableModel end @[ARTA::Get("/json-array-nested")] def json_nested_array_serializable : Array(Array(JSONSerializableModel)) [[ JSONSerializableModel.new(10, "Bob"), ]] end @[ARTA::Get("/json-array-empty")] def json_empty_array_serializable : Array(JSONSerializableModel) [] of JSONSerializableModel end @[ARTA::Get("/asr")] @[ATHA::View(serialization_groups: ["default"])] def both_serializable : BothSerializableModel BothSerializableModel.new 20, "Jim" end @[ARTA::Get("/asr-array")] @[ATHA::View(serialization_groups: ["default"])] def both_serializable_array : Array(BothSerializableModel) [ BothSerializableModel.new(10, "Bob"), BothSerializableModel.new(20, "Sally"), ] end @[ARTA::Get("/json-nested-hash-collection")] def nested_json_hash_collection : Hash(String, Int32 | JSONSerializableModel) {"foo" => 10, "obj" => JSONSerializableModel.new(10, "Bob")} end @[ARTA::Get("/json-nested-nt-collection")] def nested_json_nt_collection : {foo: Int32, obj: JSONSerializableModel} {foo: 10, obj: JSONSerializableModel.new(10, "Bob")} end @[ARTA::Get("/json-nested-hash-array-collection")] def nested_json_hash_array_collection : Hash(String, Int32 | Array(JSONSerializableModel)) {"foo" => 10, "objs" => [JSONSerializableModel.new(10, "Bob")]} end @[ARTA::Get("/json-nested-nt-array-collection")] def nested_json_nt_array_collection : {foo: Int32, objs: Array(JSONSerializableModel)} {foo: 10, objs: [JSONSerializableModel.new(10, "Bob")]} end @[ARTA::Post("/status")] @[ATHA::View(status: :accepted)] def custom_status_code : String "foo" end @[ARTA::Get("")] def view : ATH::View(String) self.view "DATA", :im_a_teapot end @[ARTA::Get("/array")] def view_array : ATH::View(Array(JSONSerializableModel)) self.view( [ JSONSerializableModel.new(10, "Bob"), JSONSerializableModel.new(20, "Sally"), ], :im_a_teapot ) end end ================================================ FILE: src/components/framework/spec/custom_annotation_spec.cr ================================================ require "./spec_helper" struct CustomAnnotationControllerTest < ATH::Spec::APITestCase def test_with_annotation : Nil self.get "/with-ann" self.assert_response_header_equals "ANNOTATION", "true" self.assert_response_header_equals "ANNOTATION_VALUE", "1" end def test_without_annotation : Nil self.get "/without-ann" self.assert_response_not_has_header "ANNOTATION" self.assert_response_header_equals "ANNOTATION_VALUE", "1" end def test_overriding_class_annotation : Nil self.get "/with-ann-override" self.assert_response_not_has_header "ANNOTATION" self.assert_response_header_equals "ANNOTATION_VALUE", "2" end def test_top_level_parameter_ann : Nil self.get "/top-parameter-ann/10" self.assert_response_is_successful end def test_nested_level_parameter_ann : Nil self.get "/nested-parameter-ann/20" self.assert_response_is_successful end end ================================================ FILE: src/components/framework/spec/ext/console/register_commands_spec.cr ================================================ require "../../spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__, file : String = __FILE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, file: file, preamble: %(require "../../spec_helper.cr") end @[ADI::Register] class EagerlyInitializedCommand < ACON::Command class_getter initialized = false def initialize @@initialized = true super end protected def configure : Nil self .name("eagerly-initialized") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end @[ADI::Register] @[ACONA::AsCommand("lazy-initialized")] class LazyInitializedCommand < ACON::Command class_getter initialized = false def initialize @@initialized = true super end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end @[ADI::Register] @[ACONA::AsCommand("annn|tset", hidden: true, description: "Test desc")] class AnnConfiguredCommand < ACON::Command protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end @[ADI::Register] @[ACONA::AsCommand("|empty-name")] class EmptyCommandName < ACON::Command protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status ACON::Command::Status::SUCCESS end end describe ATH do describe "Console", tags: "compiled" do it "errors if no name is provided" do assert_compile_time_error "Console command 'TestCommand' has an 'ACONA::AsCommand' annotation but is missing the commands's name. It was not provided as the first positional argument nor via the 'name' field.", <<-CR require "../../spec_helper.cr" @[ADI::Register] @[ACONA::AsCommand] class TestCommand < ACON::Command end CR end end # Fetching the console application initializes everything. # Kinda hacky, but do this in a `before_all` to assert they both start off un-initialized. before_all do EagerlyInitializedCommand.initialized.should be_false LazyInitializedCommand.initialized.should be_false end it "is initialized eagerly if not configured via annotation" do application = ADI.container.athena_console_application EagerlyInitializedCommand.initialized.should be_true application.has?("eagerly-initialized").should be_true EagerlyInitializedCommand.initialized.should be_true end it "is initialized lazily if configured via annotation" do application = ADI.container.athena_console_application LazyInitializedCommand.initialized.should be_false application.has?("lazy-initialized").should be_true LazyInitializedCommand.initialized.should be_false # Lazy command wrapper application.get("lazy-initialized").help.should be_empty LazyInitializedCommand.initialized.should be_true end it "applies data from annotation" do application = ADI.container.athena_console_application application.has?("tset").should be_true command = application.get "annn" command.hidden?.should be_true command.description.should eq "Test desc" command.aliases.should eq ["tset"] end it "applies hidden status via empty command name" do application = ADI.container.athena_console_application command = application.get "empty-name" command.name.should eq "empty-name" command.hidden?.should be_true command.aliases.should be_empty end end ================================================ FILE: src/components/framework/spec/ext/routing/annotation_route_loader_spec.cr ================================================ require "../../spec_helper" private def assert_route( route_collection : ART::RouteCollection, file : String = __FILE__, line : Int32 = __LINE__, **args, ) route_collection.size.should eq 1 self.assert_route route_collection.first, **args, file: file, line: line end private def assert_route( route : Tuple(String, ART::Route), *, path : String = "/", methods : Set(String) = Set{"GET"}, defaults : Hash(String, String?) = Hash(String, String?).new, requirements : Hash(String, String | Regex) = Hash(String, String | Regex).new, host : String? = nil, schemes : Set(String)? = nil, condition : ART::Route::Condition? = nil, name : String? = nil, file : String = __FILE__, line : Int32 = __LINE__, ) : Nil route_name, route = route route_name.should eq(name), line: line, file: file if name route.path.should eq(path), line: line, file: file route.methods.should eq(methods), line: line, file: file route.schemes.should eq(schemes), line: line, file: file route.host.should eq(host), line: line, file: file route.requirements.should eq(requirements), line: line, file: file route_defaults = route.defaults.dup route_defaults.delete "_controller" unless defaults.has_key?("_controller") route_defaults.raw?("_action").should be_a AHK::ActionBase route_defaults.delete "_action" route_defaults.should eq(defaults), line: line, file: file if condition route.condition.should_not be_nil, line: line, file: file else route.condition.should be_nil, line: line, file: file end end class App::CompileController < ATH::Controller @[ARTA::Get(path: "/")] def action : Nil; end end class CompileController < ATH::Controller @[ARTA::Get("/", name: "action")] def action : Nil; end end @[ARTA::Route( path: "parent", locale: "de", format: "json", stateless: true, name: "parent", requirements: {"foo" => "bar"}, defaults: {"foo" => "bar"}, schemes: ["https", "ftp"], methods: ["foo"], condition: ART::Route::Condition.new { false }, priority: 16, )] class GlobalsController < ATH::Controller @[ARTA::Route("/child")] def action : Nil; end end @[ARTA::Route( path: "prefix", )] class PrefixedController < ATH::Controller @[ARTA::Post("")] def empty : Nil; end @[ARTA::Post("/")] def slash : Nil; end @[ARTA::Get("{id}")] def empty_param(id : String) : Nil; end @[ARTA::Get("/{id}")] def slash_param(id : String) : Nil; end end @[ARTA::Route("pos-prefix")] class PositionalPrefixedController < ATH::Controller @[ARTA::Post("")] def empty : Nil; end @[ARTA::Post("/")] def slash : Nil; end @[ARTA::Get("{id}")] def empty_param(id : String) : Nil; end @[ARTA::Get("/{id}")] def slash_param(id : String) : Nil; end end @[ARTA::Route( schemes: ["BAR", "foo", "baz"], methods: ["foo", "baz", "bar"], requirements: {"foo" => "bar"}, defaults: {"foo" => "bar"}, stateless: false )] class GlobalsMerges < ATH::Controller @[ARTA::Route( path: "/", methods: ["bar", "biz"], schemes: ["foo", "biz"], requirements: {"biz" => "baz"}, defaults: {"biz" => "baz"}, )] def action : Nil; end end class CustomMethodsString < ATH::Controller @[ARTA::Route("/", methods: "FOO")] def action : Nil; end end class CustomMethodsArray < ATH::Controller @[ARTA::Route("/", methods: {"BAR"})] def action : Nil; end end class LocalizedAction < ATH::Controller @[ARTA::Get({"en" => "/USA", "de" => "/Germany"})] def action : Nil; end end @[ARTA::Route(path: "prefix")] class LocalizedPrefixedAction < ATH::Controller @[ARTA::Get({"en" => "/USA", "de" => "/Germany"})] def action : Nil; end @[ARTA::Get(path: {"en" => "{id}/USA", "de" => "/{id}/Germany"})] def index(id : String) : Nil; end end @[ARTA::Route(path: {"en" => "/USA", "de" => "/Germany"})] class LocalizedClass < ATH::Controller @[ARTA::Get("")] def action : Nil; end @[ARTA::Get("{id}")] def index(id : String) : Nil; end end @[ARTA::Route(path: {"en" => "/parent", "de" => "/parent"})] class LocalizedClassAction < ATH::Controller @[ARTA::Get(path: {"en" => "/USA", "de" => "/Germany"})] def action : Nil; end @[ARTA::Get(path: {"en" => "{id}/USA", "de" => "/{id}/Germany"})] def index(id : String) : Nil; end end class DefaultArgs < ATH::Controller @[ARTA::Get("/{slug}")] def action(id : Int32, slug : String = "foo", blah : Bool = false) : Nil; end end class RouteDefaultHelpers < ATH::Controller @[ARTA::Get("/", stateless: false, locale: "de", format: "json")] def action : Nil; end end enum StringificationColor Red Green Blue end class StringificationController < ATH::Controller @[ARTA::Get("/color/{color}", requirements: {"color" => ART::Requirement::Enum(StringificationColor).new, "foo" => /foo/, "bar" => "bar"})] def get_color(color : StringificationColor) : StringificationColor color end end class MultipleRoutesSingleMethod < ATH::Controller @[ARTA::Get("/multiple-routes")] @[ARTA::Post("/multiple-routes")] @[ARTA::Route("/multiple-routes", methods: "PATCH")] def action : Nil; end end describe ATH::Routing::AnnotationRouteLoader do describe ".route_collection" do it "simple route" do assert_route( ATH::Routing::AnnotationRouteLoader.populate_collection(App::CompileController), name: "app_compile_controller_action", defaults: {"_controller" => "App::CompileController#action"} ) end it "custom route name" do assert_route( ATH::Routing::AnnotationRouteLoader.populate_collection(CompileController), name: "action", defaults: {"_controller" => "CompileController#action"} ) end it "applies defaults from method arguments to defaults" do assert_route( ATH::Routing::AnnotationRouteLoader.populate_collection(DefaultArgs), path: "/{slug}", defaults: {"slug" => "foo"} ) end it "with helper default values" do assert_route( ATH::Routing::AnnotationRouteLoader.populate_collection(RouteDefaultHelpers), defaults: {"_stateless" => "false", "_locale" => "de", "_format" => "json"} ) end it "with a stringable route requirement" do assert_route( ATH::Routing::AnnotationRouteLoader.populate_collection(StringificationController), path: "/color/{color}", requirements: {"color" => /red|green|blue/, "foo" => /foo/, "bar" => /bar/} ) end it "allows multiple route annotations on a single method" do routes = ATH::Routing::AnnotationRouteLoader.populate_collection(MultipleRoutesSingleMethod).routes.to_a routes.size.should eq 3 route = routes[0] assert_route( route, name: "multiple_routes_single_method_action", path: "/multiple-routes", methods: Set{"PATCH"} ) route = routes[1] assert_route( route, name: "multiple_routes_single_method_action_1", path: "/multiple-routes", methods: Set{"GET"} ) route = routes[2] assert_route( route, name: "multiple_routes_single_method_action_2", path: "/multiple-routes", methods: Set{"POST"} ) end describe "custom route methods" do it String do assert_route( ATH::Routing::AnnotationRouteLoader.populate_collection(CustomMethodsString), methods: Set{"FOO"} ) end it Enumerable do assert_route( ATH::Routing::AnnotationRouteLoader.populate_collection(CustomMethodsArray), methods: Set{"BAR"} ) end end describe "localized routes" do describe "only on a method" do it "without a prefix" do routes = ATH::Routing::AnnotationRouteLoader.populate_collection(LocalizedAction).routes.to_a routes.size.should eq 2 route = routes[0] assert_route( route, name: "localized_action_action.en", path: "/USA", defaults: {"_locale" => "en", "_canonical_route" => "localized_action_action"}, requirements: {"_locale" => /en/} ) route = routes[1] assert_route( route, name: "localized_action_action.de", path: "/Germany", defaults: {"_locale" => "de", "_canonical_route" => "localized_action_action"}, requirements: {"_locale" => /de/} ) end it "with prefix" do routes = ATH::Routing::AnnotationRouteLoader.populate_collection(LocalizedPrefixedAction).routes.to_a routes.size.should eq 4 route = routes[0] assert_route( route, name: "localized_prefixed_action_action.en", path: "/prefix/USA", defaults: {"_locale" => "en", "_canonical_route" => "localized_prefixed_action_action"}, requirements: {"_locale" => /en/} ) route = routes[1] assert_route( route, name: "localized_prefixed_action_action.de", path: "/prefix/Germany", defaults: {"_locale" => "de", "_canonical_route" => "localized_prefixed_action_action"}, requirements: {"_locale" => /de/} ) route = routes[2] assert_route( route, name: "localized_prefixed_action_index.en", path: "/prefix/{id}/USA", defaults: {"_locale" => "en", "_canonical_route" => "localized_prefixed_action_index"}, requirements: {"_locale" => /en/} ) route = routes[3] assert_route( route, name: "localized_prefixed_action_index.de", path: "/prefix/{id}/Germany", defaults: {"_locale" => "de", "_canonical_route" => "localized_prefixed_action_index"}, requirements: {"_locale" => /de/} ) end end it "only on the class" do routes = ATH::Routing::AnnotationRouteLoader.populate_collection(LocalizedClass).routes.to_a routes.size.should eq 4 route = routes[0] assert_route( route, name: "localized_class_action.en", path: "/USA", defaults: {"_locale" => "en", "_canonical_route" => "localized_class_action"}, requirements: {"_locale" => /en/} ) route = routes[1] assert_route( route, name: "localized_class_action.de", path: "/Germany", defaults: {"_locale" => "de", "_canonical_route" => "localized_class_action"}, requirements: {"_locale" => /de/} ) route = routes[2] assert_route( route, name: "localized_class_index.en", path: "/USA/{id}", defaults: {"_locale" => "en", "_canonical_route" => "localized_class_index"}, requirements: {"_locale" => /en/} ) route = routes[3] assert_route( route, name: "localized_class_index.de", path: "/Germany/{id}", defaults: {"_locale" => "de", "_canonical_route" => "localized_class_index"}, requirements: {"_locale" => /de/} ) end it "on both class and action" do routes = ATH::Routing::AnnotationRouteLoader.populate_collection(LocalizedClassAction).routes.to_a routes.size.should eq 4 route = routes[0] assert_route( route, name: "localized_class_action_action.en", path: "/parent/USA", defaults: {"_locale" => "en", "_canonical_route" => "localized_class_action_action"}, requirements: {"_locale" => /en/} ) route = routes[1] assert_route( route, name: "localized_class_action_action.de", path: "/parent/Germany", defaults: {"_locale" => "de", "_canonical_route" => "localized_class_action_action"}, requirements: {"_locale" => /de/} ) route = routes[2] assert_route( route, name: "localized_class_action_index.en", path: "/parent/{id}/USA", defaults: {"_locale" => "en", "_canonical_route" => "localized_class_action_index"}, requirements: {"_locale" => /en/} ) route = routes[3] assert_route( route, name: "localized_class_action_index.de", path: "/parent/{id}/Germany", defaults: {"_locale" => "de", "_canonical_route" => "localized_class_action_index"}, requirements: {"_locale" => /de/} ) end end describe "globals" do it "applies to child routes" do assert_route( ATH::Routing::AnnotationRouteLoader.populate_collection(GlobalsController), name: "parent_globals_controller_action", path: "/parent/child", methods: Set{"FOO"}, condition: ART::Route::Condition.new { false }, schemes: Set{"https", "ftp"}, requirements: {"foo" => /bar/}, defaults: {"foo" => "bar", "_locale" => "de", "_format" => "json", "_stateless" => "true"} ) end it "merges methods and schemes with the child route" do assert_route( ATH::Routing::AnnotationRouteLoader.populate_collection(GlobalsMerges), path: "/", schemes: Set{"bar", "foo", "baz", "biz"}, methods: Set{"FOO", "BAZ", "BAR", "BIZ"}, requirements: {"foo" => /bar/, "biz" => /baz/}, defaults: {"foo" => "bar", "biz" => "baz", "_stateless" => "false"} ) end it "normalizes prefixed route paths" do routes = ATH::Routing::AnnotationRouteLoader.populate_collection(PrefixedController).routes.to_a routes.size.should eq 4 route = routes[0] assert_route( route, name: "prefixed_controller_empty", path: "/prefix", methods: Set{"POST"} ) route = routes[1] assert_route( route, name: "prefixed_controller_slash", path: "/prefix/", methods: Set{"POST"} ) route = routes[2] assert_route( route, name: "prefixed_controller_empty_param", path: "/prefix/{id}", ) route = routes[3] assert_route( route, name: "prefixed_controller_slash_param", path: "/prefix/{id}", ) end it "normalizes positional prefixed route paths" do routes = ATH::Routing::AnnotationRouteLoader.populate_collection(PositionalPrefixedController).routes.to_a routes.size.should eq 4 route = routes[0] assert_route( route, name: "positional_prefixed_controller_empty", path: "/pos-prefix", methods: Set{"POST"} ) route = routes[1] assert_route( route, name: "positional_prefixed_controller_slash", path: "/pos-prefix/", methods: Set{"POST"} ) route = routes[2] assert_route( route, name: "positional_prefixed_controller_empty_param", path: "/pos-prefix/{id}", ) route = routes[3] assert_route( route, name: "positional_prefixed_controller_slash_param", path: "/pos-prefix/{id}", ) end end end end ================================================ FILE: src/components/framework/spec/file_parser_spec.cr ================================================ require "./spec_helper" struct FileParserTest < ASPEC::TestCase def test_parse_happy_path : Nil file1 : AHTTP::UploadedFile? = nil file2 : AHTTP::UploadedFile? = nil request = new_request( body: String.build do |io| ::HTTP::FormData.build io, "boundary" do |form| # Non HTML file input types have a `nil` filename. form.field("age", 12) form.file( "success", File.open("#{__DIR__}/assets/foo.txt"), ::HTTP::FormData::FileMetadata.new( "foo.txt" ), headers: ::HTTP::Headers{ "content-type" => "text/plain", } ) # Skipped because optional HTML file input types have a `""` filename if no file was selected. form.file( "optional", IO::Memory.new, ::HTTP::FormData::FileMetadata.new( "" ), headers: ::HTTP::Headers{ "content-type" => "text/plain", } ) form.file( "too_big", File.open("#{__DIR__}/assets/file-big.txt"), ::HTTP::FormData::FileMetadata.new( "file-big.txt" ), headers: ::HTTP::Headers{ "content-type" => "text/plain", } ) # Skipped due to max_uploads == 2 form.file( "skipped", File.open("#{__DIR__}/assets/foo.txt"), ::HTTP::FormData::FileMetadata.new( "foo.txt" ), headers: ::HTTP::Headers{ "content-type" => "text/plain", } ) end end, headers: ::HTTP::Headers{ "content-type" => "multipart/form-data; boundary=\"boundary\"", }, ) file_parser = self.target file_parser.parse request request.files.keys.should eq ["success", "too_big"] files = request.files["success"] files.size.should eq 1 file1 = files[0] file1.status.ok?.should be_true file1.client_original_name.should eq "foo.txt" file1.client_original_path.should eq "foo.txt" file1.client_mime_type.should eq "text/plain" file1.path.should match /file_upload\.\w+/ file_parser.uploaded_file?(file1.path).should be_true files = request.files["too_big"] files.size.should eq 1 file2 = files[0] file2.status.size_limit_exceeded?.should be_true file2.client_original_name.should eq "file-big.txt" file2.client_original_path.should eq "file-big.txt" file2.client_mime_type.should eq "text/plain" file2.path.should be_empty file_parser.uploaded_file?(file2.path).should be_false request.attributes.get("age", String).should eq "12" request.attributes.has?("optional").should be_false file_parser.clear ::File.exists?(file1.path).should be_false ensure file1.try { |f| ::File.delete? f.path } end private def target(max_uploads : Int32 = 2, max_file_size : Int64 = 50) : ATH::FileParser ATH::FileParser.new( nil, max_uploads, max_file_size ) end end ================================================ FILE: src/components/framework/spec/file_upload_controller_spec.cr ================================================ require "./spec_helper" struct FileUploadControllerTest < ATH::Spec::APITestCase def test_required_single_file_present : Nil self.upload_file "/required_single_file_present" self.assert_response_is_successful end def test_optional_single_file_present : Nil self.upload_file "/optional_single_file_present" self.assert_response_is_successful end def test_required_single_file_missing : Nil self.upload_file "/required_single_file_missing", "missing" self.assert_response_has_status :internal_server_error URI.decode(self.response.headers["x-debug-exception-message"]).should contain "requires that you provide a value for the 'file' parameter." end def test_optional_single_file_missing : Nil self.upload_file "/optional_single_file_missing" self.assert_response_is_successful end def test_required_single_file_missing_with_constraint : Nil self.upload_file "/required_single_file_missing_with_constraint", "missing" self.assert_response_has_status :internal_server_error URI.decode(self.response.headers["x-debug-exception-message"]).should contain "requires that you provide a value for the 'file' parameter." end def test_optional_single_file_missing_with_constraint : Nil self.upload_file "/optional_single_file_missing_with_constraint", "missing" self.assert_response_is_successful end def test_required_array_present : Nil self.upload_file "/required_array_present" self.assert_response_is_successful end def test_optional_array_present : Nil self.upload_file "/optional_array_present" self.assert_response_is_successful end def test_required_array_empty : Nil self.upload_file "/required_array_empty" self.assert_response_is_successful end def test_optional_array_empty : Nil self.upload_file "/optional_array_empty" self.assert_response_is_successful end private def upload_file(route : String, name : String = "file") : Nil self.post( route, headers: ::HTTP::Headers{ "content-type" => "multipart/form-data; boundary=\"boundary\"", }, body: self.build_payload name ) end private def build_payload(name : String = "file") : String String.build do |io| ::HTTP::FormData.build io, "boundary" do |form| form.file( name, File.open("#{__DIR__}/assets/foo.txt"), ::HTTP::FormData::FileMetadata.new( "foo.txt" ), headers: ::HTTP::Headers{ "content-type" => "text/plain", } ) end end end end ================================================ FILE: src/components/framework/spec/listeners/cors_spec.cr ================================================ require "../spec_helper" private def new_response_event new_response_event() { } end private def new_response_event(& : AHTTP::Request -> _) request = new_request yield request AHK::Events::Response.new request, AHTTP::Response.new end private def assert_headers(response : AHTTP::Response, origin : String = "https://example.com") : Nil response.headers["access-control-allow-credentials"].should eq "true" response.headers["access-control-allow-headers"].should eq "X-FOO" response.headers["access-control-allow-methods"].should eq "POST, GET" response.headers["access-control-allow-origin"].should eq origin response.headers["access-control-max-age"].should eq "123" end private def assert_headers_with_wildcard_config_without_request_headers(response : AHTTP::Response) : Nil response.headers["access-control-allow-credentials"]?.should be_nil response.headers["access-control-allow-headers"]?.should be_nil response.headers["access-control-allow-methods"].should eq "GET, POST, HEAD" response.headers["access-control-allow-origin"].should eq "https://example.com" response.headers["access-control-max-age"].should eq "123" end private EMPTY_CONFIG = ATH::Listeners::CORS::Config.new private WILDCARD_CONFIG = ATH::Listeners::CORS::Config.new( allow_credentials: false, allow_headers: %w(*), allow_origin: %w(*), expose_headers: %w(*), max_age: 123, ) private CONFIG = ATH::Listeners::CORS::Config.new( allow_credentials: true, allow_headers: %w(X-FOO), allow_methods: %w(POST GET), allow_origin: ["https://example.com", /https:\/\/(?:api|app)\.example\.com/], expose_headers: %w(HEADER1 HEADER2), max_age: 123 ) describe ATH::Listeners::CORS do describe "#on_request - request" do it "without a configuration defined" do listener = ATH::Listeners::CORS.new event = new_request_event listener.on_request event event.response.should be_nil event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_false end it "without the origin header" do listener = ATH::Listeners::CORS.new EMPTY_CONFIG event = new_request_event listener.on_request event event.response.should be_nil event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_false end describe "preflight" do describe :defaults do it "should only set the default headers" do listener = ATH::Listeners::CORS.new EMPTY_CONFIG event = new_request_event do |request| request.method = "OPTIONS" request.headers.add "origin", "https://example.com" request.headers.add "access-control-request-method", "GET" end listener.on_request event response = event.response.should_not be_nil response.headers["vary"].should eq "origin" response.headers["access-control-allow-methods"].should eq "GET, POST, HEAD" event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_false end end it "with an unsupported request method" do listener = ATH::Listeners::CORS.new CONFIG event = new_request_event do |request| request.method = "OPTIONS" request.headers.add "origin", "https://example.com" request.headers.add "access-control-request-method", "LINK" end listener.on_request event response = event.response.should_not be_nil response.status.should eq ::HTTP::Status::METHOD_NOT_ALLOWED event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_false assert_headers response end it "with an unsupported request header" do listener = ATH::Listeners::CORS.new CONFIG event = new_request_event do |request| request.method = "OPTIONS" request.headers.add "origin", "https://example.com" request.headers.add "access-control-request-method", "GET" request.headers.add "access-control-request-headers", "X-BAD" end expect_raises AHK::Exception::Forbidden, "Unauthorized header: 'X-BAD'" do listener.on_request event end event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_false event.response.should be_nil end it "with an invalid origin" do listener = ATH::Listeners::CORS.new CONFIG event = new_request_event do |request| request.method = "OPTIONS" request.headers.add "origin", "https://admin.example.com" request.headers.add "access-control-request-method", "GET" end listener.on_request event response = event.response.should_not be_nil response.headers["vary"].should eq "origin" response.headers["access-control-allow-methods"].should eq "POST, GET" event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_false end describe "proper request" do it "static origin" do listener = ATH::Listeners::CORS.new CONFIG event = new_request_event do |request| request.method = "OPTIONS" request.headers.add "origin", "https://example.com" request.headers.add "access-control-request-method", "GET" request.headers.add "access-control-request-headers", "X-FOO" end listener.on_request event event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_false assert_headers event.response.should_not be_nil end it "regex origin" do listener = ATH::Listeners::CORS.new CONFIG event = new_request_event do |request| request.method = "OPTIONS" request.headers.add "origin", "https://api.example.com" request.headers.add "access-control-request-method", "GET" request.headers.add "access-control-request-headers", "X-FOO" end listener.on_request event event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_false assert_headers event.response.should_not(be_nil), "https://api.example.com" end end it "without the access-control-request-headers header" do listener = ATH::Listeners::CORS.new CONFIG event = new_request_event do |request| request.method = "OPTIONS" request.headers.add "origin", "https://example.com" request.headers.add "access-control-request-method", "GET" end listener.on_request event event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_false assert_headers event.response.should_not be_nil end it "without the access-control-request-headers header and wildcard in allow_headers config" do listener = ATH::Listeners::CORS.new WILDCARD_CONFIG event = new_request_event do |request| request.method = "OPTIONS" request.headers.add "origin", "https://example.com" request.headers.add "access-control-request-method", "GET" end listener.on_request event event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_false assert_headers_with_wildcard_config_without_request_headers event.response.should_not be_nil end end describe "non-preflight" do it "with an invalid domain" do listener = ATH::Listeners::CORS.new CONFIG event = new_request_event do |request| request.method = "GET" request.headers.add "origin", "https://example.net" request.headers.add "access-control-request-method", "GET" request.headers.add "access-control-request-headers", "X-FOO" end listener.on_request event event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_false event.response.should be_nil end it "with a proper request" do listener = ATH::Listeners::CORS.new CONFIG event = new_request_event do |request| request.method = "GET" request.headers.add "origin", "https://example.com" request.headers.add "access-control-request-method", "GET" request.headers.add "access-control-request-headers", "X-FOO" end listener.on_request event event.request.attributes.has?(ATH::Listeners::CORS::ALLOW_SET_ORIGIN).should be_true event.response.should be_nil end end end describe "#on_response - response" do describe "with a proper request" do it "static origin" do listener = ATH::Listeners::CORS.new CONFIG event = new_response_event do |request| request.method = "GET" request.headers.add "origin", "https://example.com" request.headers.add "access-control-request-method", "GET" request.headers.add "access-control-request-headers", "X-FOO" request.attributes.set ATH::Listeners::CORS::ALLOW_SET_ORIGIN, true end listener.on_response event event.response.headers["access-control-allow-origin"].should eq "https://example.com" event.response.headers["access-control-allow-credentials"].should eq "true" event.response.headers["access-control-expose-headers"].should eq "HEADER1, HEADER2" end it "valid regex origin" do listener = ATH::Listeners::CORS.new CONFIG event = new_response_event do |request| request.method = "GET" request.headers.add "origin", "https://app.example.com" request.headers.add "access-control-request-method", "GET" request.headers.add "access-control-request-headers", "X-FOO" request.attributes.set ATH::Listeners::CORS::ALLOW_SET_ORIGIN, true end listener.on_response event event.response.headers["access-control-allow-origin"].should eq "https://app.example.com" event.response.headers["access-control-allow-credentials"].should eq "true" event.response.headers["access-control-expose-headers"].should eq "HEADER1, HEADER2" end end it "that should not allow setting origin" do listener = ATH::Listeners::CORS.new CONFIG event = new_response_event do |request| request.method = "GET" request.headers.add "origin", "https://example.com" request.headers.add "access-control-request-method", "GET" request.headers.add "access-control-request-headers", "X-FOO" request.attributes.set ATH::Listeners::CORS::ALLOW_SET_ORIGIN, false end listener.on_response event event.response.headers.size.should eq 2 end it "without a configuration defined" do listener = ATH::Listeners::CORS.new event = new_response_event listener.on_response event event.response.headers.size.should eq 2 end end end ================================================ FILE: src/components/framework/spec/listeners/file_spec.cr ================================================ require "../spec_helper" private class MockFileParser < ATH::FileParser getter? parse_called : Bool = false getter? clear_called : Bool = false def parse(request : AHTTP::Request) : Nil @parse_called = true end def clear : Nil @clear_called = true end end describe ATH::Listeners::File do describe "#on_request" do it "no-ops when the request is not `multipart/form-data`" do ATH::Listeners::File.new(file_parser = MockFileParser.new(nil, 1, 0)).on_request new_request_event file_parser.parse_called?.should be_false end it "calls parse when the request is `multipart/form-data`" do ATH::Listeners::File .new(file_parser = MockFileParser.new(nil, 1, 0)) .on_request new_request_event( headers: ::HTTP::Headers{ "content-type" => "multipart/form-data", } ) file_parser.parse_called?.should be_true end end describe "#on_terminate" do it "calls clear" do ATH::Listeners::File .new(file_parser = MockFileParser.new(nil, 1, 0)) .on_terminate AHK::Events::Terminate.new new_request, AHTTP::Response.new file_parser.clear_called?.should be_true end end end ================================================ FILE: src/components/framework/spec/listeners/format_spec.cr ================================================ require "../spec_helper" struct FormatListenerTest < ASPEC::TestCase def test_fallback_format : Nil event = new_request_event request_store = AHTTP::RequestStore.new request_store.request = event.request 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 listener.on_request event event.request.request_format.should eq "xml" 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" request_store = AHTTP::RequestStore.new request_store.request = event.request 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 listener.on_request event event.request.request_format.should eq "xml" event.request.attributes.get?("media_type").should be_nil end def test_cannot_resolve_format : Nil event = new_request_event request_store = AHTTP::RequestStore.new request_store.request = event.request negotiator = ATH::View::FormatNegotiator.new request_store listener = ATH::Listeners::Format.new negotiator expect_raises AHK::Exception::NotAcceptable, "No matching accepted Response format could be determined." do listener.on_request event end end @[DataProvider("format_provider")] # Doesn't override request format if it was already set. def test_uses_specified_format(format : String?, expected : String, media_type : String?) : Nil event = new_request_event if format event.request.request_format = format end request_store = AHTTP::RequestStore.new request_store.request = event.request 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 listener.on_request event event.request.request_format.should eq expected event.request.attributes.get?("media_type").should eq media_type end def format_provider : Tuple { {nil, "xml", "text/xml"}, {"html", "html", nil}, } end private def request_matcher(path : Regex) : AHTTP::RequestMatcher::Interface AHTTP::RequestMatcher.new AHTTP::RequestMatcher::Path.new path end end ================================================ FILE: src/components/framework/spec/listeners/view_spec.cr ================================================ require "../spec_helper" private class MockViewHandler include ATH::View::ViewHandlerInterface getter! view : ATH::ViewBase def register_handler(format : String, handler : ATH::View::FormatHandlerInterface | Proc(ATH::View::ViewHandlerInterface, ATH::ViewBase, AHTTP::Request, String, AHTTP::Response)) : Nil end def supports?(format : String) : Bool true end def handle(view : ATH::ViewBase, request : AHTTP::Request? = nil) : AHTTP::Response @view = view AHTTP::Response.new end def create_redirect_response(view : ATH::ViewBase, location : String, format : String) : AHTTP::Response AHTTP::Response.new end def create_response(view : ATH::ViewBase, request : AHTTP::Request, format : String) : AHTTP::Response AHTTP::Response.new end end private def get_ann_configs(config : ADI::AnnotationConfigurations::ConfigurationBase) : ADI::AnnotationConfigurations ADI::AnnotationConfigurations.new ADI::AnnotationConfigurations::AnnotationHash{ATHA::View => [config] of ADI::AnnotationConfigurations::ConfigurationBase} end describe ATH::Listeners::View do describe "#call" do it "non ATH::View" do request = new_request event = AHK::Events::View.new request, "FOO" view_handler = MockViewHandler.new ATH::Listeners::View.new(view_handler, MockAnnotationResolver.new).on_view event view_handler.view.data.should eq "FOO" view_handler.view.format.should eq "json" view_handler.view.context.groups.try &.should be_empty view_handler.view.context.emit_nil?.should be_nil end it ATH::View do request = new_request view = ATH::View.new("BAR") view.format = "xml" event = AHK::Events::View.new request, view view_handler = MockViewHandler.new ATH::Listeners::View.new(view_handler, MockAnnotationResolver.new).on_view event view_handler.view.data.should eq "BAR" view_handler.view.format.should eq "xml" view_handler.view.context.groups.try &.should be_empty end it "mutating response" do request = new_request event = AHK::Events::View.new request, "FOO" view_handler = MockViewHandler.new event.action_result = "BAR" ATH::Listeners::View.new(view_handler, MockAnnotationResolver.new).on_view event view_handler.view.data.should eq "BAR" view_handler.view.format.should eq "json" view_handler.view.context.groups.try &.should be_empty end describe ATHA::View do describe "status" do it "with status" do request = new_request event = AHK::Events::View.new request, "FOO" view_handler = MockViewHandler.new ATH::Listeners::View.new( view_handler, MockAnnotationResolver.new( action_annotations: get_ann_configs(ATHA::ViewConfiguration.new(status: :found)) ) ).on_view event view_handler.view.status.should eq ::HTTP::Status::FOUND end it "when the view already has a status" do request = new_request view = ATH::View.new "FOO", status: :gone event = AHK::Events::View.new request, view view_handler = MockViewHandler.new ATH::Listeners::View.new( view_handler, MockAnnotationResolver.new( action_annotations: get_ann_configs(ATHA::ViewConfiguration.new(status: :found)) ) ).on_view event view_handler.view.status.should eq ::HTTP::Status::GONE end it "when the view already has a status, but it's OK" do request = new_request view = ATH::View.new "FOO", status: :ok event = AHK::Events::View.new request, view view_handler = MockViewHandler.new ATH::Listeners::View.new( view_handler, MockAnnotationResolver.new( action_annotations: get_ann_configs(ATHA::ViewConfiguration.new(status: :found)) ) ).on_view event view_handler.view.status.should eq ::HTTP::Status::FOUND end end describe "serialization_groups" do it "and the view doesn't have any groups already" do request = new_request event = AHK::Events::View.new request, "FOO" view_handler = MockViewHandler.new ATH::Listeners::View.new( view_handler, MockAnnotationResolver.new( action_annotations: get_ann_configs(ATHA::ViewConfiguration.new(serialization_groups: ["one", "two"])) ) ).on_view event groups = view_handler.view.context.groups.should_not be_nil groups.should eq Set{"one", "two"} end it "and the view already has some groups" do request = new_request view = ATH::View.new "FOO" view.context.add_groups "three", "four" event = AHK::Events::View.new request, view view_handler = MockViewHandler.new ATH::Listeners::View.new( view_handler, MockAnnotationResolver.new( action_annotations: get_ann_configs(ATHA::ViewConfiguration.new(serialization_groups: ["one", "two"])) ) ).on_view event groups = view_handler.view.context.groups.should_not be_nil groups.should eq Set{"three", "four", "one", "two"} end end it "emit_nil" do request = new_request event = AHK::Events::View.new request, "FOO" view_handler = MockViewHandler.new ATH::Listeners::View.new( view_handler, MockAnnotationResolver.new( action_annotations: get_ann_configs(ATHA::ViewConfiguration.new(emit_nil: true)) ) ).on_view event view_handler.view.context.emit_nil?.should be_true end end end end ================================================ FILE: src/components/framework/spec/prefix_spec.cr ================================================ require "./spec_helper" struct ControllerPrefixTest < ATH::Spec::APITestCase def test_controller_with_prefix : Nil self.get "/prefix/index" self.assert_response_is_successful end end ================================================ FILE: src/components/framework/spec/routing_spec.cr ================================================ require "./spec_helper" struct RoutingTest < ATH::Spec::APITestCase def test_is_concurrently_safe : Nil spawn do sleep 100.milliseconds self.get("/get/safe?bar").body.should eq %("safe") end self.get("/get/safe?foo").body.should eq %("safe") end def test_head_request : Nil response = self.head "/head" response.status.should eq ::HTTP::Status::OK response.body.should be_empty response.headers["content-length"].should eq "6" # JSON encoding adds 2 extra `"` chars end def test_head_request_on_get_endpoint : Nil response = self.head "/get-head" response.status.should eq ::HTTP::Status::OK response.body.should be_empty response.headers["FOO"].should eq "BAR" # Actually runs the controller action code response.headers["content-length"].should eq "10" # JSON encoding adds 2 extra `"` chars end def test_does_not_reuse_container_with_keep_alive_connections : Nil response1 = self.get("/container/id", headers: ::HTTP::Headers{"connection" => "keep-alive"}).body self.init_container response2 = self.get("/container/id", headers: ::HTTP::Headers{"connection" => "keep-alive"}).body response1.should_not eq response2 end def test_route_doesnt_exist : Nil response = self.get "/fake/route" response.status.should eq ::HTTP::Status::NOT_FOUND response.body.should eq %({"code":404,"message":"No route found for 'GET /fake/route'."}) end def test_route_doesnt_exist_with_referrer : Nil # This is misspelled on purpose, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer. response = self.get "/fake/route", headers: ::HTTP::Headers{"referer" => "somebody"} # spellchecker:disable-line response.status.should eq ::HTTP::Status::NOT_FOUND response.body.should eq %({"code":404,"message":"No route found for 'GET /fake/route' (from: 'somebody')."}) end def test_invalid_method : Nil response = self.post "/art/response" response.status.should eq ::HTTP::Status::METHOD_NOT_ALLOWED response.body.should eq %({"code":405,"message":"No route found for 'POST /art/response': Method Not Allowed (Allow: GET)."}) end def test_allows_returning_an_athena_response : Nil response = self.get "/art/response" response.status.should eq ::HTTP::Status::IM_A_TEAPOT response.headers["content-type"].should eq "BAR" response.headers["content-length"].should eq "3" response.headers.has_key?("transfer-encoding").should be_false response.body.should eq "FOO" end def test_allows_returning_a_streamed_response : Nil response = self.get "/art/streamed-response" response.status.should eq ::HTTP::Status::IM_A_TEAPOT response.headers["content-type"].should eq "BAR" response.headers.has_key?("content-length").should be_false response.headers["transfer-encoding"].should eq "chunked" response.body.should eq %("FOO") end def test_it_supports_redirects : Nil response = self.get "/art/redirect" response.status.should eq ::HTTP::Status::FOUND response.headers["location"].should eq "https://crystal-lang.org" response.body.should be_empty end def test_it_supports_custom_http_methods : Nil self.request("FOO", "/custom-method").body.should eq %("FOO") end def test_custom_response_status_get : Nil self.get "/custom-status" self.assert_response_has_status :accepted end def test_custom_response_status_head : Nil self.head "/custom-status" self.assert_response_has_status :accepted end def test_uses_default_value_if_no_other_value_provided : Nil self.get("/default").body.should eq "10" end def test_uses_nil_if_no_other_value_provided_and_is_nilable : Nil self.get("/nilable").body.should eq "null" end def test_macro_dsl_nil_return_type : Nil response = self.get "/macro/get-nil" response.status.should eq ::HTTP::Status::NO_CONTENT response.body.should be_empty end def test_macro_dsl_with_arguments : Nil self.get("/macro/add/50/25").body.should eq "75" end def test_macro_dsl_get : Nil response = self.get "/macro" response.status.should eq ::HTTP::Status::OK response.body.should eq %("GET") end def test_macro_dsl_head : Nil response = self.head "/macro" response.status.should eq ::HTTP::Status::OK response.body.should be_empty end {% for method in ["POST", "PUT", "PATCH", "DELETE", "LINK", "UNLINK"] %} def test_macro_dsl_{{method.downcase.id}} : Nil self.request({{method}}, "/macro").body.should eq %({{method}}) self.{{method.downcase.id}}("/macro").body.should eq %({{method}}) end {% end %} def test_get_helper_method : Nil self.get("/macro").body.should eq %("GET") end def test_post_helper_method : Nil self.post("/macro").body.should eq %("POST") self.post("/echo", "BODY").body.should eq %("BODY") end def test_put_helper_method : Nil self.put("/macro").body.should eq %("PUT") self.put("/echo", "BODY").body.should eq %("BODY") end def test_delete_helper_method : Nil self.delete("/macro").body.should eq %("DELETE") end def test_athena_request : Nil self.request(AHTTP::Request.new("GET", "/macro")).body.should eq %("GET") end def test_http_request : Nil self.request(::HTTP::Request.new("GET", "/macro")).body.should eq %("GET") end def test_constraints_404_if_no_match : Nil response = self.get "/macro/bar" response.status.should eq ::HTTP::Status::NOT_FOUND response.body.should eq %({"code":404,"message":"No route found for 'GET /macro/bar'."}) end def test_constraints_routes_if_match : Nil self.get("/macro/foo").body.should eq %("foo") end def test_generate_url_no_args : Nil self.get("/url").body.should eq %("/art/response") end def test_generate_url_hash : Nil self.get("/url-hash").body.should eq %("/art/response?id=10") end def test_generate_url_named_tuple : Nil self.get("/url-nt").body.should eq %("/art/response?id=10") end def test_generate_url_named_tuple_abso : Nil self.get("/url-nt-abso", headers: ::HTTP::Headers{"host" => "crystal-lang.org"}).body.should eq %("http://crystal-lang.org/art/response?id=10") end def test_redirect_to_route : Nil self.get "/redirect-url" self.assert_response_redirects "/art/response", :found end def test_redirect_to_route_status : Nil self.get "/redirect-url-status" self.assert_response_redirects "/art/response", :permanent_redirect end def test_redirect_to_route_hash : Nil self.get "/redirect-url-hash" self.assert_response_redirects "/art/response?id=10", :found end def test_redirect_to_route_nt : Nil self.get "/redirect-url-nt" self.assert_response_redirects "/art/response?id=10", :found end def test_using_route_handler_directly_with_http_request : Nil response = self.client.container.athena_http_kernel.handle ::HTTP::Request.new "GET", "/art/response" response.status.should eq ::HTTP::Status::IM_A_TEAPOT response.content.should eq "FOO" end def test_applies_cookies_to_actual_response : Nil self.get "/cookies" self.assert_cookie_has_value "key", "value" end def test_redirects_get_request_to_route_without_trailing_slash : Nil self.get "/macro/get-nil/", headers: ::HTTP::Headers{"host" => "localhost"} self.assert_response_redirects "http://localhost/macro/get-nil" end def test_redirects_head_request_to_route_without_trailing_slash : Nil self.head "/head/", headers: ::HTTP::Headers{"host" => "localhost"} self.assert_response_redirects "http://localhost/head" end def test_redirects_get_request_to_route_with_trailing_slash : Nil self.get "/head-get", headers: ::HTTP::Headers{"host" => "localhost"} self.assert_response_redirects "http://localhost/head-get/" end def test_redirects_head_request_to_route_with_trailing_slash : Nil self.head "/head-get", headers: ::HTTP::Headers{"host" => "localhost"} self.assert_response_redirects "http://localhost/head-get/" end def test_does_not_redirect_post_requests : Nil self.post "/art/response/" self.assert_response_has_status :not_found end def test_unprocessable : Nil self.post "/unprocessable" self.assert_response_is_unprocessable end end ================================================ FILE: src/components/framework/spec/spec/expectations/request/attribute_equals_spec.cr ================================================ require "../../../spec_helper" struct AttributeEqualsExpectationTest < ASPEC::TestCase def test_match_valid : Nil request = new_request request.attributes.set "foo", "bar" ATH::Spec::Expectations::Request::AttributeEquals.new("foo", "bar").match(request).should be_true end def test_match_invalid : Nil request = new_request request.attributes.set "foo", "bar" ATH::Spec::Expectations::Request::AttributeEquals.new("foo", "baz").match(request).should be_false ATH::Spec::Expectations::Request::AttributeEquals.new("bar", "bar").match(request).should be_false end def test_failure_message : Nil ATH::Spec::Expectations::Request::AttributeEquals.new("foo", "bar") .failure_message(new_request) .should contain "Failed asserting that the request has attribute 'foo' with value 'bar'." end def test_failure_message_with_description : Nil ATH::Spec::Expectations::Request::AttributeEquals.new("foo", "bar", description: "Oh noes") .failure_message(new_request) .should contain "Oh noes\n\nFailed asserting that the request has attribute 'foo' with value 'bar'." end def test_negative_failure_message : Nil ATH::Spec::Expectations::Request::AttributeEquals.new("foo", "bar") .negative_failure_message(new_request) .should contain "Failed asserting that the request does not have attribute 'foo' with value 'bar'." end def test_negative_failure_message_with_description : Nil ATH::Spec::Expectations::Request::AttributeEquals.new("foo", "bar", description: "Oh noes") .negative_failure_message(new_request) .should contain "Oh noes\n\nFailed asserting that the request does not have attribute 'foo' with value 'bar'." end end ================================================ FILE: src/components/framework/spec/spec/expectations/response/cookie_value_equals_spec.cr ================================================ require "../../../spec_helper" struct CookieValueEqualsExpectationTest < ASPEC::TestCase def test_match_valid : Nil response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar" ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar").match(response).should be_true end def test_match_valid_custom_path : Nil response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", path: "/path" ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", path: "/path").match(response).should be_true end def test_match_valid_custom_domain : Nil response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", domain: "example.com" ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", domain: "example.com").match(response).should be_true end def test_match_valid_custom_path_and_domain : Nil response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", path: "/path", domain: "example.com" ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", path: "/path", domain: "example.com").match(response).should be_true end def test_match_invalid : Nil ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar").match(new_response).should be_false end def test_match_invalid_diff_path response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", path: "/path" ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar").match(response).should be_false ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", path: "/").match(response).should be_false ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", path: "/bar").match(response).should be_false end def test_match_invalid_diff_domain response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", domain: "example.com" ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar").match(response).should be_false ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", domain: "foo.example.com").match(response).should be_false ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", domain: "example.net").match(response).should be_false ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", domain: "domain.com").match(response).should be_false end def test_match_invalid_diff_domain_and_path response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", path: "/path", domain: "example.com" ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar").match(response).should be_false ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", path: "/bar", domain: "example.com").match(response).should be_false ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", path: "/path", domain: "domain.com").match(response).should be_false ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", path: "/bar", domain: "domain.com").match(response).should be_false end def test_failure_message : Nil ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar") .failure_message(new_response) .should contain "Failed asserting that the response has cookie 'foo' with value 'bar'." end def test_failure_message_with_path : Nil ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", path: "/path") .failure_message(new_response) .should contain "Failed asserting that the response has cookie 'foo' with path '/path' with value 'bar'." end def test_failure_message_with_domain : Nil ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", domain: "example.com") .failure_message(new_response) .should contain "Failed asserting that the response has cookie 'foo' for domain 'example.com' with value 'bar'." end def test_failure_message_with_path_and_domain : Nil ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", path: "/path", domain: "example.com") .failure_message(new_response) .should contain "Failed asserting that the response has cookie 'foo' with path '/path' for domain 'example.com' with value 'bar'." end def test_negative_failure_message : Nil ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar") .negative_failure_message(new_response) .should contain "Failed asserting that the response does not have cookie 'foo' with value 'bar'." end def test_negative_failure_message_with_path : Nil ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", path: "/path") .negative_failure_message(new_response) .should contain "Failed asserting that the response does not have cookie 'foo' with path '/path' with value 'bar'." end def test_negative_failure_message_with_domain : Nil ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", domain: "example.com") .negative_failure_message(new_response) .should contain "Failed asserting that the response does not have cookie 'foo' for domain 'example.com' with value 'bar'." end def test_negative_failure_message_with_path_and_domain : Nil ATH::Spec::Expectations::Response::CookieValueEquals.new("foo", "bar", path: "/path", domain: "example.com") .negative_failure_message(new_response) .should contain "Failed asserting that the response does not have cookie 'foo' with path '/path' for domain 'example.com' with value 'bar'." end end ================================================ FILE: src/components/framework/spec/spec/expectations/response/format_equals_spec.cr ================================================ require "../../../spec_helper" struct FormatEqualsExpectationTest < ASPEC::TestCase def test_match_valid : Nil ATH::Spec::Expectations::Response::FormatEquals.new(new_request, "json").match(new_response headers: ::HTTP::Headers{"content-type" => "application/json"}).should be_true end def test_match_valid_no_format : Nil ATH::Spec::Expectations::Response::FormatEquals.new(new_request).match(new_response headers: ::HTTP::Headers{"content-type" => ""}).should be_true end def test_match_invalid : Nil ATH::Spec::Expectations::Response::FormatEquals.new(new_request).match(new_response).should be_false ATH::Spec::Expectations::Response::FormatEquals.new(new_request, "json").match(new_response).should be_false ATH::Spec::Expectations::Response::FormatEquals.new(new_request, "json").match(new_response headers: ::HTTP::Headers{"content-type" => "text/html"}).should be_false end def test_failure_message : Nil ATH::Spec::Expectations::Response::FormatEquals.new(new_request, "json") .failure_message(new_response) .should contain "Failed asserting that the response format is 'json':\nHTTP/1.1 200" end def test_failure_message_no_format : Nil ATH::Spec::Expectations::Response::FormatEquals.new(new_request) .failure_message(new_response) .should contain "Failed asserting that the response format is 'null':\nHTTP/1.1 200" end def test_negative_failure_message : Nil ATH::Spec::Expectations::Response::FormatEquals.new(new_request, "json") .negative_failure_message(new_response) .should contain "Failed asserting that the response format is not 'json':\nHTTP/1.1 200" end def test_negative_failure_message_no_format : Nil ATH::Spec::Expectations::Response::FormatEquals.new(new_request) .negative_failure_message(new_response) .should contain "Failed asserting that the response format is not 'null':\nHTTP/1.1 200" end end ================================================ FILE: src/components/framework/spec/spec/expectations/response/has_cookie_spec.cr ================================================ require "../../../spec_helper" struct HasCookieExpectationTest < ASPEC::TestCase def test_match_valid : Nil response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar" ATH::Spec::Expectations::Response::HasCookie.new("foo").match(response).should be_true end def test_match_valid_custom_path : Nil response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", path: "/path" ATH::Spec::Expectations::Response::HasCookie.new("foo", path: "/path").match(response).should be_true end def test_match_valid_custom_domain : Nil response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", domain: "example.com" ATH::Spec::Expectations::Response::HasCookie.new("foo", domain: "example.com").match(response).should be_true end def test_match_valid_custom_path_and_domain : Nil response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", path: "/path", domain: "example.com" ATH::Spec::Expectations::Response::HasCookie.new("foo", path: "/path", domain: "example.com").match(response).should be_true end def test_match_invalid : Nil ATH::Spec::Expectations::Response::HasCookie.new("foo").match(new_response).should be_false end def test_match_invalid_diff_path response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", path: "/path" ATH::Spec::Expectations::Response::HasCookie.new("foo").match(response).should be_false ATH::Spec::Expectations::Response::HasCookie.new("foo", path: "/").match(response).should be_false ATH::Spec::Expectations::Response::HasCookie.new("foo", path: "/bar").match(response).should be_false end def test_match_invalid_diff_domain response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", domain: "example.com" ATH::Spec::Expectations::Response::HasCookie.new("foo").match(response).should be_false ATH::Spec::Expectations::Response::HasCookie.new("foo", domain: "foo.example.com").match(response).should be_false ATH::Spec::Expectations::Response::HasCookie.new("foo", domain: "example.net").match(response).should be_false ATH::Spec::Expectations::Response::HasCookie.new("foo", domain: "domain.com").match(response).should be_false end def test_match_invalid_diff_domain_and_path response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar", path: "/path", domain: "example.com" ATH::Spec::Expectations::Response::HasCookie.new("foo").match(response).should be_false ATH::Spec::Expectations::Response::HasCookie.new("foo", path: "/bar", domain: "example.com").match(response).should be_false ATH::Spec::Expectations::Response::HasCookie.new("foo", path: "/path", domain: "domain.com").match(response).should be_false ATH::Spec::Expectations::Response::HasCookie.new("foo", path: "/bar", domain: "domain.com").match(response).should be_false end def test_failure_message : Nil ATH::Spec::Expectations::Response::HasCookie.new("foo") .failure_message(new_response) .should contain "Failed asserting that the response has cookie 'foo'." end def test_failure_message_with_path : Nil ATH::Spec::Expectations::Response::HasCookie.new("foo", path: "/path") .failure_message(new_response) .should contain "Failed asserting that the response has cookie 'foo' with path '/path'." end def test_failure_message_with_domain : Nil ATH::Spec::Expectations::Response::HasCookie.new("foo", domain: "example.com") .failure_message(new_response) .should contain "Failed asserting that the response has cookie 'foo' for domain 'example.com'." end def test_failure_message_with_path_and_domain : Nil ATH::Spec::Expectations::Response::HasCookie.new("foo", path: "/path", domain: "example.com") .failure_message(new_response) .should contain "Failed asserting that the response has cookie 'foo' with path '/path' for domain 'example.com'." end def test_negative_failure_message : Nil ATH::Spec::Expectations::Response::HasCookie.new("foo") .negative_failure_message(new_response) .should contain "Failed asserting that the response does not have cookie 'foo'." end def test_negative_failure_message_with_path : Nil ATH::Spec::Expectations::Response::HasCookie.new("foo", path: "/path") .negative_failure_message(new_response) .should contain "Failed asserting that the response does not have cookie 'foo' with path '/path'." end def test_negative_failure_message_with_domain : Nil ATH::Spec::Expectations::Response::HasCookie.new("foo", domain: "example.com") .negative_failure_message(new_response) .should contain "Failed asserting that the response does not have cookie 'foo' for domain 'example.com'." end def test_negative_failure_message_with_path_and_domain : Nil ATH::Spec::Expectations::Response::HasCookie.new("foo", path: "/path", domain: "example.com") .negative_failure_message(new_response) .should contain "Failed asserting that the response does not have cookie 'foo' with path '/path' for domain 'example.com'." end end ================================================ FILE: src/components/framework/spec/spec/expectations/response/has_header_spec.cr ================================================ require "../../../spec_helper" struct HasHeaderExpectationTest < ASPEC::TestCase def test_match_valid : Nil ATH::Spec::Expectations::Response::HasHeader.new("date").match(new_response headers: ::HTTP::Headers{"date" => "now"}).should be_true end def test_match_invalid : Nil ATH::Spec::Expectations::Response::HasHeader.new("foobar").match(new_response).should be_false end def test_failure_message : Nil ATH::Spec::Expectations::Response::HasHeader.new("date") .failure_message(new_response) .should contain "Failed asserting that the response has header 'date'." end def test_failure_message_with_description : Nil ATH::Spec::Expectations::Response::HasHeader.new("date", description: "Oh noes") .failure_message(new_response) .should contain "Oh noes\n\nFailed asserting that the response has header 'date'." end def test_negative_failure_message : Nil ATH::Spec::Expectations::Response::HasHeader.new("date") .negative_failure_message(new_response) .should contain "Failed asserting that the response does not have header 'date'." end def test_negative_failure_message_with_description : Nil ATH::Spec::Expectations::Response::HasHeader.new("date", description: "Oh noes") .negative_failure_message(new_response) .should contain "Oh noes\n\nFailed asserting that the response does not have header 'date'." end end ================================================ FILE: src/components/framework/spec/spec/expectations/response/has_status_spec.cr ================================================ require "../../../spec_helper" struct HasStatusExpectationTest < ASPEC::TestCase def test_match_valid : Nil ATH::Spec::Expectations::Response::HasStatus.new(:ok).match(new_response).should be_true ATH::Spec::Expectations::Response::HasStatus.new(200).match(new_response).should be_true end def test_match_invalid : Nil ATH::Spec::Expectations::Response::HasStatus.new(:ok).match(new_response status: :not_found).should be_false end def test_failure_message : Nil ATH::Spec::Expectations::Response::HasStatus.new(:not_found) .failure_message(new_response) .should contain "Failed asserting that the response status is 'NOT_FOUND':\nHTTP/1.1 200 OK" end def test_negative_failure_message : Nil ATH::Spec::Expectations::Response::HasStatus.new(:ok) .negative_failure_message(new_response) .should contain "Failed asserting that the response status is not 'OK':\nHTTP/1.1 200 OK" end end ================================================ FILE: src/components/framework/spec/spec/expectations/response/header_equals_spec.cr ================================================ require "../../../spec_helper" struct HeaderEqualsExpectationTest < ASPEC::TestCase def test_match_valid : Nil ATH::Spec::Expectations::Response::HeaderEquals.new("date", "now").match(new_response headers: ::HTTP::Headers{"date" => "now"}).should be_true end def test_match_invalid : Nil ATH::Spec::Expectations::Response::HeaderEquals.new("foobar", "bizbaz").match(new_response).should be_false ATH::Spec::Expectations::Response::HeaderEquals.new("date", "now").match(new_response headers: ::HTTP::Headers{"date" => "yesterdar"}).should be_false end def test_failure_message : Nil ATH::Spec::Expectations::Response::HeaderEquals.new("date", "now") .failure_message(new_response) .should contain "Failed asserting that the response has header 'date' with value 'now'." end def test_negative_failure_message : Nil ATH::Spec::Expectations::Response::HeaderEquals.new("date", "now") .negative_failure_message(new_response) .should contain "Failed asserting that the response does not have header 'date' with value 'now'." end end ================================================ FILE: src/components/framework/spec/spec/expectations/response/is_redirected_spec.cr ================================================ require "../../../spec_helper" struct IsRedirectedExpectationTest < ASPEC::TestCase def initialize @target = ATH::Spec::Expectations::Response::IsRedirected.new end def test_match_valid : Nil @target.match(new_response status: :moved_permanently).should be_true end def test_match_invalid : Nil @target.match(new_response status: :im_a_teapot).should be_false end def test_failure_message : Nil @target.failure_message(new_response status: :not_found).should contain "Failed asserting that the response is redirected:\nHTTP/1.1 404 Not Found" end def test_negative_failure_message : Nil @target.negative_failure_message(new_response status: :moved_permanently).should contain "Failed asserting that the response is not redirected:\nHTTP/1.1 301 Moved Permanently" end end ================================================ FILE: src/components/framework/spec/spec/expectations/response/is_successful_spec.cr ================================================ require "../../../spec_helper" struct IsSuccessfulExpectationTest < ASPEC::TestCase def initialize @target = ATH::Spec::Expectations::Response::IsSuccessful.new end def test_match_valid : Nil @target.match(new_response).should be_true end def test_match_invalid : Nil @target.match(new_response status: :im_a_teapot).should be_false end def test_failure_message : Nil @target.failure_message(new_response status: :not_found).should contain "Failed asserting that the response is successful:\nHTTP/1.1 404 Not Found" end def test_negative_failure_message : Nil @target.negative_failure_message(new_response).should contain "Failed asserting that the response is not successful:\nHTTP/1.1 200 OK" end end ================================================ FILE: src/components/framework/spec/spec/expectations/response/is_unprocessable_spec.cr ================================================ require "../../../spec_helper" struct IsUnprocessableExpectationTest < ASPEC::TestCase def initialize @target = ATH::Spec::Expectations::Response::IsUnprocessable.new end def test_match_valid : Nil @target.match(new_response status: :unprocessable_entity).should be_true end def test_match_invalid : Nil @target.match(new_response).should be_false end def test_failure_message : Nil @target.failure_message(new_response status: :not_found).should contain "Failed asserting that the response is unprocessable:\nHTTP/1.1 404 Not Found" end def test_negative_failure_message : Nil @target.negative_failure_message(new_response status: :unprocessable_entity).should contain "Failed asserting that the response is not unprocessable:\nHTTP/1.1 422 Unprocessable Entity" end end ================================================ FILE: src/components/framework/spec/spec/web_test_case_spec.cr ================================================ require "../spec_helper" @[ASPEC::TestCase::Skip] private struct MockWebTestCase < ATH::Spec::WebTestCase def client=(@client : ATH::Spec::AbstractBrowser); end end private class MockClient < ATH::Spec::AbstractBrowser setter request : AHTTP::Request? setter response : ::HTTP::Server::Response? def do_request(request : AHTTP::Request) : NoReturn raise NotImplementedError.new "BUG: Invoked do_request method of MockClient" end end struct WebTestCaseTest < ASPEC::TestCase protected def before_all : Nil AHTTP::Request.register_format "custom", {"application/vnd.myformat"} end def test_assert_response_is_successful : Nil self.response_tester(new_response).assert_response_is_successful expect_raises Spec::AssertionFailed, "Failed asserting that the response is successful:\nHTTP/1.1 404 Not Found" do self.response_tester(new_response status: :not_found).assert_response_is_successful end end def test_assert_response_has_status : Nil self.response_tester(new_response).assert_response_has_status :ok self.response_tester(new_response status: :not_found).assert_response_has_status :not_found expect_raises Spec::AssertionFailed, "Failed asserting that the response status is 'OK':\nHTTP/1.1 404 Not Found" do self.response_tester(new_response status: :not_found).assert_response_has_status :ok end end def test_assert_response_is_redirected : Nil self.response_tester(new_response status: :moved_permanently).assert_response_redirects expect_raises Spec::AssertionFailed, "Failed asserting that the response is redirected:\nHTTP/1.1 200 OK" do self.response_tester(new_response).assert_response_redirects end end def test_assert_response_is_redirected_with_location : Nil self.response_tester(new_response status: :moved_permanently, headers: ::HTTP::Headers{"location" => "https://example.com"}).assert_response_redirects "https://example.com" expect_raises Spec::AssertionFailed, "Failed asserting that the response has header 'location' with value 'https://example.com'." do self.response_tester(new_response status: :moved_permanently).assert_response_redirects "https://example.com" end end def test_assert_response_is_redirected_with_status : Nil self.response_tester(new_response status: :moved_permanently).assert_response_redirects status: :moved_permanently expect_raises Spec::AssertionFailed, "Failed asserting that the response status is 'FOUND':\nHTTP/1.1 301 Moved Permanently" do self.response_tester(new_response status: :moved_permanently).assert_response_redirects status: 302 end end def test_assert_response_format_equals : Nil self.response_tester(new_response headers: ::HTTP::Headers{"content-type" => "application/vnd.myformat"}).assert_response_format_equals "custom" self.response_tester(new_response headers: ::HTTP::Headers{"content-type" => "application/json"}).assert_response_format_equals "json" expect_raises Spec::AssertionFailed, "Failed asserting that the response format is 'json':\nHTTP/1.1 200 OK" do self.response_tester(new_response headers: ::HTTP::Headers{"content-type" => "text/html"}).assert_response_format_equals "json" end end def test_assert_response_has_header : Nil self.response_tester(new_response headers: ::HTTP::Headers{"foo" => "bar"}).assert_response_has_header "foo" expect_raises Spec::AssertionFailed, "Failed asserting that the response has header 'baz'." do self.response_tester(new_response).assert_response_has_header "baz" end end def test_assert_response_not_has_header : Nil self.response_tester(new_response).assert_response_not_has_header "baz" expect_raises Spec::AssertionFailed, "Failed asserting that the response does not have header 'foo'." do self.response_tester(new_response headers: ::HTTP::Headers{"foo" => "bar"}).assert_response_not_has_header "foo" end end def test_assert_response_header_equals : Nil self.response_tester(new_response headers: ::HTTP::Headers{"foo" => "bar"}).assert_response_header_equals "foo", "bar" expect_raises Spec::AssertionFailed, "Failed asserting that the response has header 'foo' with value 'bar'" do self.response_tester(new_response).assert_response_header_equals "foo", "bar" end expect_raises Spec::AssertionFailed, "Failed asserting that the response has header 'baz' with value 'blah'." do self.response_tester(new_response headers: ::HTTP::Headers{"baz" => "bar"}).assert_response_header_equals "baz", "blah" end end def test_assert_response_not_header_equals : Nil self.response_tester(new_response headers: ::HTTP::Headers{"foo" => "baz"}).assert_response_header_not_equals "foo", "bar" expect_raises Spec::AssertionFailed, "ailed asserting that the response does not have header 'foo' with value 'bar'." do self.response_tester(new_response headers: ::HTTP::Headers{"foo" => "bar"}).assert_response_header_not_equals "foo", "bar" end end def test_assert_response_has_cookie : Nil response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar" self.response_tester(response).assert_response_has_cookie "foo" expect_raises Spec::AssertionFailed, "Failed asserting that the response has cookie 'foo'." do self.response_tester(new_response).assert_response_has_cookie "foo" end end def test_assert_response_not_has_cookie : Nil self.response_tester(new_response).assert_response_not_has_cookie "foo" expect_raises Spec::AssertionFailed, "Failed asserting that the response does not have cookie 'foo'." do response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar" self.response_tester(response).assert_response_not_has_cookie "foo" end end def test_assert_cookie_has_value : Nil response = new_response response.cookies << ::HTTP::Cookie.new "foo", "bar" self.response_tester(response).assert_cookie_has_value "foo", "bar" expect_raises Spec::AssertionFailed, "Failed asserting that the response has cookie 'foo'." do self.response_tester(new_response).assert_cookie_has_value "foo", "bar" end expect_raises Spec::AssertionFailed, "Failed asserting that the response has cookie 'foo' with value 'bar'." do response = new_response response.cookies << ::HTTP::Cookie.new "foo", "baz" self.response_tester(response).assert_cookie_has_value "foo", "bar" end end def test_assert_request_attribute_equals : Nil self.request_tester.assert_request_attribute_equals "foo", "bar" expect_raises Spec::AssertionFailed, "Failed asserting that the request has attribute 'foo' with value 'baz'." do self.request_tester.assert_request_attribute_equals "foo", "baz" end end def test_assert_route_equals : Nil self.request_tester.assert_route_equals "index", {"foo" => "bar"} expect_raises Spec::AssertionFailed, "Failed asserting that the request has attribute '_route' with value 'articles'." do self.request_tester.assert_route_equals "articles" end end def test_exception_on_server_error : Nil response = new_response( status: :internal_server_error, headers: ::HTTP::Headers{ "x-debug-exception-code" => "500", "x-debug-exception-file" => "/path/to/file:123:4", "x-debug-exception-class" => "MyException", "x-debug-exception-message" => "Oh noes!", } ) expect_raises Spec::AssertionFailed, "Caused By:\n Oh noes! (MyException)\n from /path/to/file:123:4" do self.response_tester(response).assert_response_is_successful end end private def response_tester(response : ::HTTP::Server::Response) : ATH::Spec::WebTestCase client = MockClient.new client.response = response client.request = AHTTP::Request.new "GET", "/" self.tester client end private def request_tester : ATH::Spec::WebTestCase client = MockClient.new request = AHTTP::Request.new "GET", "/" request.attributes.set "foo", "bar", String request.attributes.set "_route", "index", String client.request = request self.tester client end private def tester(client : ATH::Spec::AbstractBrowser) : ATH::Spec::WebTestCase obj = MockWebTestCase.new obj.client = client obj end end ================================================ FILE: src/components/framework/spec/spec_helper.cr ================================================ require "spec" require "log/spec" require "../src/athena" require "./controllers/*" require "../src/spec" Spec.before_each do ART.compile ATH::Routing::AnnotationRouteLoader.route_collection end # FIXME: Refactor these specs to not depend on calling a protected method. include Athena::Routing Spec.after_each do ART::RouteProvider.reset end ASPEC.run_all # TODO: Is there a better way to handle customizing the scheme of a request w/o monkey patching it? class AHTTP::Request property scheme : String = "http" end class TestController < ATH::Controller get "test" do "TEST" end end class MockSerializer include ASR::SerializerInterface setter data : String? = "SERIALIZED_DATA" setter context_assertion : Proc(ASR::SerializationContext, Nil)? def initialize(@context_assertion : Proc(ASR::SerializationContext, Nil)? = nil); end def serialize(data : _, format : ASR::Format | String, context : ASR::SerializationContext = ASR::SerializationContext.new, **named_args) : String String.build do |str| serialize data, format, str, context, **named_args end end def serialize(data : _, format : ASR::Format | String, io : IO, context : ASR::SerializationContext = ASR::SerializationContext.new, **named_args) : Nil @data.to_json io @context_assertion.try &.call context end def deserialize(type : ASR::Model.class, data : String | IO, format : ASR::Format | String, context : ASR::DeserializationContext = ASR::DeserializationContext.new) end end class DeserializableMockSerializer(T) < MockSerializer setter deserialized_response : T? = nil def deserialize(type : ASR::Model.class, data : String | IO, format : ASR::Format | String, context : ASR::DeserializationContext = ASR::DeserializationContext.new) @deserialized_response end end class MockAnnotationResolver < ATH::AnnotationResolver property action_annotations : ADI::AnnotationConfigurations property action_parameter_annotations : ADI::AnnotationConfigurations def initialize( @action_annotations : ADI::AnnotationConfigurations = ADI::AnnotationConfigurations.new, @action_parameter_annotations : ADI::AnnotationConfigurations = ADI::AnnotationConfigurations.new, *, @expected_controller : String? = nil, @expected_parameter_name : String? = nil, ); end def action_annotations(request : AHTTP::Request) : ADI::AnnotationConfigurations if expected_controller = @expected_controller request.attributes.get?("_controller", String).should eq expected_controller end @action_annotations end def action_parameter_annotations(request : AHTTP::Request, parameter_name : String) : ADI::AnnotationConfigurations if expected_controller = @expected_controller request.attributes.get?("_controller", String).should eq expected_controller end if expected_parameter_name = @expected_parameter_name parameter_name.should eq expected_parameter_name end @action_parameter_annotations end end macro create_action(return_type = String, &) AHK::Action.new( Proc(typeof(Tuple.new), {{return_type}}).new { {{yield}} }, Tuple.new, {{return_type}}, ) end def new_parameter : AHK::Controller::ParameterMetadata AHK::Controller::ParameterMetadata(Int32).new "id" end def new_action( *, arguments : Tuple = Tuple.new, ) : AHK::ActionBase AHK::Action.new( Proc(typeof(Tuple.new), String).new { test_controller = TestController.new; test_controller.get_test }, arguments, String, ) end def new_request( *, path : String = "/test", method : String = "GET", action : AHK::ActionBase = new_action, body : String | IO | Nil = nil, query : String? = nil, format : String = "json", files : Hash(String, Array(AHTTP::UploadedFile)) = {} of String => Array(AHTTP::UploadedFile), headers : ::HTTP::Headers = ::HTTP::Headers.new, ) : AHTTP::Request request = AHTTP::Request.new method, path, body: body request.files.merge! files request.attributes.set "_controller", "TestController#test", String request.attributes.set "_route", "test_controller_test", String request.attributes.set "_action", action request.query = query request.headers = ::HTTP::Headers{ "content-type" => AHTTP::Request::FORMATS[format].first, }.merge! headers request end def new_request_event(headers : ::HTTP::Headers = ::HTTP::Headers.new) new_request_event(headers) { } end def new_request_event(headers : ::HTTP::Headers = ::HTTP::Headers.new, & : AHTTP::Request -> _) request = new_request headers: headers yield request AHK::Events::Request.new request end def new_response( *, io : IO = IO::Memory.new, status : ::HTTP::Status = :ok, headers : ::HTTP::Headers = ::HTTP::Headers.new, ) : ::HTTP::Server::Response ::HTTP::Server::Response.new(io).tap do |resp| headers.each do |k, v| resp.headers[k] = v end resp.status = status end end ATH.configure({ framework: { file_uploads: { enabled: true, }, }, }) ================================================ FILE: src/components/framework/spec/view/context_spec.cr ================================================ require "../spec_helper" private struct IgnoreExclusionStrategy include ASR::ExclusionStrategies::ExclusionStrategyInterface # :inherit: def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool false end end struct ContextTest < ASPEC::TestCase @context : ATH::View::Context def initialize @context = ATH::View::Context.new end def test_default_values : Nil @context.version.should be_nil @context.groups.should be_nil @context.emit_nil?.should be_nil end def test_adding_groups : Nil @context.add_groups "one", "two" @context.add_groups({"three"}) @context.add_group "four" @context.groups.should eq Set{"one", "two", "three", "four"} end def test_set_groups : Nil @context.add_groups "foo", "bar" @context.groups.should eq Set{"foo", "bar"} @context.groups = {"one", "two"} @context.groups.should eq Set{"one", "two"} end def test_does_not_allow_duplicate_groups : Nil @context.add_group "one" @context.add_group "one" @context.add_group "two" @context.groups.should eq Set{"one", "two"} end def test_version : Nil @context.version = "1.2.3" @context.version.should eq SemanticVersion.new 1, 2, 3 sem_ver = SemanticVersion.new 10, 9, 8 @context.version = sem_ver @context.version.should eq sem_ver end def test_exclusion_strategies : Nil @context.exclusion_strategies.should be_empty strategy = IgnoreExclusionStrategy.new @context.add_exclusion_strategy strategy @context.exclusion_strategies.should eq [strategy] end end ================================================ FILE: src/components/framework/spec/view/format_negotiator_spec.cr ================================================ require "../spec_helper" private class MockRequestMatcher include AHTTP::RequestMatcher::Interface def initialize(@matches : Bool); end def matches?(request : AHTTP::Request) : Bool @matches end end struct FormatNegotiatorTest < ASPEC::TestCase @request_store : AHTTP::RequestStore @request : AHTTP::Request @negotiator : ATH::View::FormatNegotiator def initialize @request_store = AHTTP::RequestStore.new @request = AHTTP::Request.new "GET", "/" @request_store.request = @request @negotiator = ATH::View::FormatNegotiator.new( @request_store, {"json" => ["application/json;version=1.0"]} ) end def test_best_no_config : Nil @negotiator.best("").should be_nil end def test_best_stop_exception : Nil self.add_rule false self.add_rule stop: true expect_raises AHK::Exception::StopFormatListener, "Stopping format listener." do @negotiator.best "" end end def test_fallback_format : Nil self.add_rule @negotiator.best("").should be_nil self.add_rule fallback_format: "html" @negotiator.best("").should eq ANG::Accept.new "text/html" end def test_fallback_format_priorities : Nil self.add_rule priorities: ["json", "xml"], fallback_format: nil @negotiator.best("").should be_nil self.add_rule priorities: ["json", "xml"], fallback_format: "json" @negotiator.best("").should eq ANG::Accept.new "application/json" end def test_best : Nil @request.headers["accept"] = "application/xhtml+xml, text/html, application/xml;q=0.9, */*;q=0.8" priorities = ["text/html; charset=utf-8", "html", "application/json"] self.add_rule priorities: priorities @negotiator.best("").should eq ANG::Accept.new "text/html;charset=utf-8" @request.headers["accept"] = "application/xhtml+xml, application/xml;q=0.9, */*;q=0.8" @negotiator.best("", {"html", "json"}).should eq ANG::Accept.new "application/xhtml+xml" end def test_best_fallback : Nil @request.headers["accept"] = "text/html" self.add_rule priorities: ["application/json"], fallback_format: "xml" @negotiator.best("").should eq ANG::Accept.new "text/xml" end def test_best_format_from_mime_types_hash : Nil @request.headers["accept"] = "application/json;version=1.0" self.add_rule priorities: ["json"], fallback_format: "xml" @negotiator.best("").should eq ANG::Accept.new "application/json;version=1.0" end def test_best_format : Nil @request.headers["accept"] = "application/json" self.add_rule priorities: ["json"], fallback_format: "xml" @negotiator.best("").should eq ANG::Accept.new "application/json" end def test_best_with_prefer_extension : Nil priorities = ["text/html", "application/json"] self.add_rule priorities: priorities, prefer_extension: true @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_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(match : Bool = true, **args) rule = ATH::View::FormatNegotiator::Rule.new **args matcher = MockRequestMatcher.new match @negotiator.add matcher, rule end end ================================================ FILE: src/components/framework/spec/view/view_handler_spec.cr ================================================ require "../spec_helper" private class MockURLGenerator include Athena::Routing::Generator::Interface property context : ART::RequestContext = ART::RequestContext.new setter expected_route, expected_reference_type, generated_url def initialize( @expected_route : String = "some_route", @expected_reference_type : ART::Generator::ReferenceType = :absolute_path, @generated_url : String = "URL", ); end def generate(route : String, params : Hash = Hash(String, String | ::Nil).new, reference_type : ART::Generator::ReferenceType = :absolute_path) : String route.should eq @expected_route reference_type.should eq @expected_reference_type params.try &.each do |key, value| @generated_url = @generated_url.gsub "{{#{key}}}", value end @generated_url end # :ditto: def generate(route : String, reference_type : ART::Generator::ReferenceType = :absolute_path, **params) : String self.generate route, params.to_h.transform_keys(&.to_s), reference_type end end struct ViewHandlerTest < ASPEC::TestCase @url_generator : MockURLGenerator @serializer : MockSerializer @request_store : AHTTP::RequestStore def initialize @url_generator = MockURLGenerator.new @serializer = MockSerializer.new @request_store = AHTTP::RequestStore.new end @[DataProvider("format_provider")] def test_supports_format(expected : Bool, custom_format_name : String, format : String?) : Nil view_handler = self.create_view_handler view_handler.register_handler custom_format_name do AHTTP::Response.new end view_handler.supports?(format || "html").should eq expected end def format_provider : Tuple { {false, "xml", nil}, {true, "html", nil}, {true, "html", "json"}, } end @[DataProvider("status_provider")] def test_status(expected_status : ::HTTP::Status, view_status : ::HTTP::Status?, data : String?, empty_content_status : ::HTTP::Status?) : Nil view = if data ATH::View(String).new data: data, status: view_status else ATH::View(Nil).new status: view_status end view_handler = if empty_content_status self.create_view_handler empty_content_status: empty_content_status else self.create_view_handler end view_handler.create_response(view, AHTTP::Request.new("GET", "/"), "json").status.should eq expected_status end def status_provider : Hash { "custom view status" => {::HTTP::Status::IM_A_TEAPOT, ::HTTP::Status::IM_A_TEAPOT, nil, nil}, "non empty content" => {::HTTP::Status::OK, nil, "DATA", nil}, "empty content default empty status" => {::HTTP::Status::NO_CONTENT, nil, nil, nil}, "empty content custom empty status" => {::HTTP::Status::IM_A_TEAPOT, nil, nil, ::HTTP::Status::IM_A_TEAPOT}, } end def test_create_response_with_location : Nil view_handler = self.create_view_handler empty_content_status: ::HTTP::Status::USE_PROXY view = ATH::View(String?).new nil view.location = "location" response = view_handler.create_response view, AHTTP::Request.new("GET", "/"), "json" response.status.should eq ::HTTP::Status::USE_PROXY response.headers["location"].should eq "location" end def test_create_response_with_location_and_data : Nil view_handler = self.create_view_handler view = ATH::View(String).new "DATA", status: ::HTTP::Status::CREATED view.location = "location" response = view_handler.create_response view, AHTTP::Request.new("GET", "/"), "json" response.status.should eq ::HTTP::Status::CREATED response.headers["location"].should eq "location" response.content.should eq %("SERIALIZED_DATA") end def test_create_response_with_route : Nil view_handler = self.create_view_handler @url_generator.generated_url = "/foo/{{foo}}" @url_generator.expected_reference_type = ART::Generator::ReferenceType::ABSOLUTE_URL view = ATH::View(String).new "DATA", status: ::HTTP::Status::CREATED view.route = "some_route" view.route_params = {"foo" => "bar"} of String => String? response = view_handler.create_response view, AHTTP::Request.new("GET", "/"), "json" response.status.should eq ::HTTP::Status::CREATED response.headers["location"].should eq "/foo/bar" end def test_create_response_without_location : Nil view_handler = self.create_view_handler view = ATH::View.new "DATA" response = view_handler.create_response view, AHTTP::Request.new("GET", "/"), "json" response.status.should eq ::HTTP::Status::OK response.content.should eq %("SERIALIZED_DATA") end @[DataProvider("serialize_nil_provider")] def test_serialize_nil_view_handler(emit_nil : Bool) : Nil view_handler = self.create_view_handler emit_nil: emit_nil @serializer.context_assertion = ->(context : ASR::SerializationContext) do context.emit_nil?.should eq emit_nil end view_handler.create_response ATH::View(Nil).new, AHTTP::Request.new("GET", "/"), "json" end def serialize_nil_provider : Tuple { {true}, {false}, } end def test_handle_unsupported_format : Nil request = AHTTP::Request.new "GET", "/" request.request_format = "rss" expect_raises AHK::Exception::NotAcceptable, "The server is unable to return a response in the requested format: 'rss'." do self.create_view_handler.handle ATH::View(Nil).new, request end end def test_handle_custom_handler : Nil response = AHTTP::Response.new view_handler = self.create_view_handler view_handler.register_handler "rss" do response end request = AHTTP::Request.new "GET", "/" request.request_format = "rss" view_handler.handle(ATH::View(Nil).new, request).should be response end def test_configurable_values : Nil view_handler = self.create_view_handler view_handler.serialization_groups = {"one", "two"} view_handler.serialization_version = "1.2.3" view_handler.serialization_version = SemanticVersion.new 4, 5, 6 view_handler.emit_nil = true @serializer.context_assertion = ->(context : ASR::SerializationContext) do context.emit_nil?.should be_true context.version.should eq SemanticVersion.new 4, 5, 6 context.groups.should eq Set{"one", "two"} end view_handler.create_response ATH::View(Nil).new, AHTTP::Request.new("GET", "/"), "json" end private def create_view_handler(**args) : ATH::View::ViewHandler ATH::View::ViewHandler.new( @url_generator, @serializer, @request_store, ([] of Athena::Framework::View::FormatHandlerInterface), **args ) end end ================================================ FILE: src/components/framework/spec/view/view_spec.cr ================================================ require "../spec_helper" struct ViewTest < ASPEC::TestCase def test_location : Nil url = "users" status = ::HTTP::Status::OK view = ATH::View.create_redirect url, status view.location.should eq url view.route.should be_nil view.response.status.should eq status view = ATH::View(Nil).new view.location = "bar" view.location.should eq "bar" view.route.should be_nil end def test_route : Nil route = "users" status = ::HTTP::Status::OK view = ATH::View.create_route_redirect route, status: status view.location.should be_nil view.route.should eq route view.response.status.should eq status view = ATH::View(Nil).new view.route = "bar" view.route.should eq "bar" view.location.should be_nil end @[DataProvider("data_provider")] def test_data(data) : Nil view = ATH::View(Hash(String, String | Int32)?).new view.data = data view.data.should eq data end def data_provider : Tuple { {nil}, { {"foo" => "bar", "baz" => 10} }, } end def test_format : Nil view = ATH::View(Nil).new view.format = "format" view.format.should eq "format" end def test_headers : Nil view = ATH::View(Nil).new view.headers = ::HTTP::Headers{"foo" => "bar"} headers = view.response.headers view.headers.has_key?("foo").should be_true headers["foo"].should eq "bar" view.set_header "string", "str" view.set_header "non-string", 10 headers["string"].should eq "str" headers["non-string"].should eq "10" end def test_status : Nil view = ATH::View(Nil).new view.status = :not_found view.status.should eq ::HTTP::Status::NOT_FOUND view.response.status.should eq ::HTTP::Status::NOT_FOUND end def test_default_status_from_response : Nil view = ATH::View(Nil).new view.status.should be_nil view.response.status.should eq ::HTTP::Status::OK end end ================================================ FILE: src/components/framework/spec/view_controller_spec.cr ================================================ require "./spec_helper" struct ViewControllerTest < ATH::Spec::APITestCase def test_unserializable_object : Nil self.get "/view/unserializable" self.assert_response_has_status :internal_server_error end def test_nil : Nil self.get "/view/nil" self.assert_response_has_status :no_content end def test_json_serializable_object : Nil self.get("/view/json").body.should eq %({"id":10,"name":"Bob"}) end def test_json_serializable_array : Nil self.get("/view/json-array").body.should eq %([{"id":10,"name":"Bob"},{"id":20,"name":"Sally"}]) end def test_json_serializable_nested_array : Nil self.get("/view/json-array-nested").body.should eq %([[{"id":10,"name":"Bob"}]]) end def test_json_serializable_empty_array : Nil self.get("/view/json-array-empty").body.should eq %([]) end def test_json_nested_hash_collection : Nil self.get("/view/json-nested-hash-collection").body.should eq %({"foo":10,"obj":{"id":10,"name":"Bob"}}) end def test_json_nested_nt_collection : Nil self.get("/view/json-nested-nt-collection").body.should eq %({"foo":10,"obj":{"id":10,"name":"Bob"}}) end def test_json_nested_hash_array_collection : Nil self.get("/view/json-nested-hash-array-collection").body.should eq %({"foo":10,"objs":[{"id":10,"name":"Bob"}]}) end def test_json_nested_nt_array_collection : Nil self.get("/view/json-nested-nt-array-collection").body.should eq %({"foo":10,"objs":[{"id":10,"name":"Bob"}]}) end def test_asr_serializable_object : Nil self.get("/view/asr").body.should eq %({"id":20}) end def test_asr_serializable_array : Nil self.get("/view/asr-array").body.should eq %([{"id":10},{"id":20}]) end def test_custom_status : Nil self.post("/view/status").status.accepted?.should be_true end def test_view : Nil response = self.get("/view") response.body.should eq %("DATA") response.status.should eq ::HTTP::Status::IM_A_TEAPOT end def test_view_json_serializable_array : Nil response = self.get("/view/array") response.body.should eq %([{"id":10,"name":"Bob"},{"id":20,"name":"Sally"}]) response.status.should eq ::HTTP::Status::IM_A_TEAPOT end end ================================================ FILE: src/components/framework/src/annotation_resolver.cr ================================================ @[ADI::Register] # Allows resolving [custom annotations](/getting_started/configuration/#custom-annotations) defined on an `ATH::Controller` class or `ATH::Action` method, or one of its parameters. class Athena::Framework::AnnotationResolver # :nodoc: ACTION_ANNOTATIONS = {} of String => ADI::AnnotationConfigurations # :nodoc: ACTION_PARAMETER_ANNOTATIONS = {} of String => Hash(String, ADI::AnnotationConfigurations) # Returns an `ADI::AnnotationConfigurations` instance representing the custom annotations applied on an `ATH::Controller` class or `AHK::Action` method. # An empty instance is returned if there are no custom annotations applied. def action_annotations(request : AHTTP::Request) : ADI::AnnotationConfigurations return ADI::AnnotationConfigurations.new unless controller = request.attributes.get? "_controller", String ACTION_ANNOTATIONS[controller]? || ADI::AnnotationConfigurations.new end # Returns an `ADI::AnnotationConfigurations` instance representing the custom annotations applied on an `ATH::Action` method parameter. # An empty instance is returned if there are no custom annotations applied. def action_parameter_annotations(request : AHTTP::Request, parameter_name : String) : ADI::AnnotationConfigurations return ADI::AnnotationConfigurations.new unless controller = request.attributes.get? "_controller", String ACTION_PARAMETER_ANNOTATIONS[controller]?.try(&.[parameter_name]?) || ADI::AnnotationConfigurations.new end end ================================================ FILE: src/components/framework/src/annotations.cr ================================================ # Contains all the `Athena::Framework` based annotations. # See each annotation for more information. module Athena::Framework::Annotations # Configures how the `ATH::View::ViewHandlerInterface` should render the related controller action. # # ## Fields # # * status : `HTTP::Status` - The `::HTTP::Status` the endpoint should return. Defaults to `HTTP::Status::OK` (200). # * serialization_groups : `Array(String)?` - The serialization groups to use for this route as part of `ASR::ExclusionStrategies::Groups`. # * validation_groups : `Array(String)?` - Groups that should be used to validate any objects related to this route; see `AVD::Constraint@validation-groups`. # * emit_nil : `Bool` - If `nil` values should be serialized. Defaults to `false`. # # ## Example # # ``` # @[ARTA::Post(path: "/publish/{id}")] # @[ATHA::View(status: :accepted, serialization_groups: ["default", "detailed"])] # def publish(id : Int32) : Article # article = Article.find id # article.published = true # article # end # ``` ADI.configuration_annotation ::Athena::Framework::Annotations::View, status : ::HTTP::Status? = nil, serialization_groups : Array(String)? = nil, validation_groups : Array(String)? = nil, emit_nil : Bool? = nil end ================================================ FILE: src/components/framework/src/athena.cr ================================================ require "ecr" require "http/server" require "json" require "athena-contracts/event_dispatcher" require "athena-clock" require "athena-console" require "athena-dependency_injection" require "athena-http_kernel" require "athena-negotiation" require "./annotation_resolver" require "./annotations" require "./bundle" require "./controller" require "./file_parser" require "./logging" require "./ext/http" require "./ext/http_kernel" require "./ext/serializer" require "./commands/*" require "./controller/**" require "./compiler_passes/*" require "./listeners/*" require "./view/*" require "./ext/clock" require "./ext/console" require "./ext/event_dispatcher" require "./ext/routing" require "./ext/validator" # Convenience alias to make referencing `Athena::Framework` types easier. alias ATH = Athena::Framework # Convenience alias to make referencing `Athena::Framework::Annotations` types easier. alias ATHA = ATH::Annotations # Convenience alias to make referencing `ATH::Controller::ValueResolvers` types easier. alias ATHR = ATH::Controller::ValueResolvers module Athena::Framework VERSION = "0.22.0" # The name of the environment variable used to determine Athena's current environment. ENV_NAME = "ATHENA_ENV" # 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 # Registers the provided *bundle*. # # See the [Getting Started](/getting_started/configuration) docs for more information. # # NOTE: This is an alias of [ADI.register_bundle](/DependencyInjection/top_level/#Athena::DependencyInjection:register_bundle(bundle)). macro register_bundle(bundle) ADI.register_bundle({{bundle}}) end # Returns the current environment Athena is in based on `ENV_NAME`. Defaults to `development` if not defined. def self.environment : String ENV[ENV_NAME]? || "development" end # This type includes all of the built-in resolvers that Athena uses to try and resolve an argument for a particular controller action parameter. # They run in the following order: # # 1. `ATHR::QueryParameter` (110) - Attempts to resolve a value from the `AHTTP::Request` query parameters. # # 1. `ATHR::Enum` (105) - Attempts to resolve a value from [AHTTP::Request#attributes](/HTTP/Request/#Athena::HTTP::Request#attributes) into an enum member of the related type. # Works well in conjunction with `ART::Requirement::Enum`. # # 1. `ATHR::Time` (105) - Attempts to resolve a value from the request attributes into a `::Time` instance, # defaulting to [RFC 3339](https://crystal-lang.org/api/Time.html#parse_rfc3339%28time:String%29-class-method). # Format/location can be customized via the `ATHA::MapTime` annotation. # # 1. `ATHR::UUID` (105) - Attempts to resolve a value from the request attributes into a `::UUID` instance. # # 1. `ATHR::RequestBody` (105) - If enabled, attempts to deserialize the request body/query string into the type of the related parameter, running any defined validations if applicable. # # 1. `AHK::Controller::ValueResolvers::RequestAttribute` (100) - Provides a value stored in [AHTTP::Request#attributes](/HTTP/Request/#Athena::HTTP::Request#attributes) if one with the same name as the action parameter exists. # # 1. `AHK::Controller::ValueResolvers::Request` (50) - Provides the current `AHTTP::Request` if the related parameter is typed as such. # # 1. `AHK::Controller::ValueResolvers::DefaultValue` (-100) - Provides the default value of the parameter if it has one, or `nil` if it is nilable. # # See each resolver for more detailed information. # Custom resolvers may also be defined. # See `ATHR::Interface` for more information. module Controller::ValueResolvers; end # The event listeners that act upon `AHK::Events` to handle a request. # Custom listeners can also be defined, see `AEDA::AsEventListener`. # # See each listener and the [Getting Started](/getting_started/middleware) docs for more information. module Listeners; end # :nodoc: module CompilerPasses; end # Namespace for the built in `Athena::Console` commands that come bundled with the framework. # Currently it provides: # # - `ATH::Commands::DebugEventDispatcher` - Display configured listeners for an application # - `ATH::Commands::DebugRouter` - Display current routes for an application # - `ATH::Commands::DebugRouterMatch` - Simulate a path match to see which route, if any, would handle it # # See each command class for more information. module Commands; end # Runs an `HTTP::Server` listening on the given *port* and *host*. # # ``` # require "athena" # # class ExampleController < ATH::Controller # @[ARTA::Get("/")] # def root : String # "At the index" # end # end # # ATH.run # ``` # # *prepend_handlers* can be used to execute an array of `HTTP::Handler` _before_ Athena takes over. # This can be useful to provide backwards compatibility with existing handlers until they can ported to Athena concepts, # or for supporting things Athena does not support, such as WebSockets. # # See `ATH::Controller` for more information on defining controllers/route actions. def self.run( port : Int32 = 3000, host : String = "0.0.0.0", reuse_port : Bool = false, ssl_context : OpenSSL::SSL::Context::Server? = nil, *, prepend_handlers : Array(::HTTP::Handler) = [] of ::HTTP::Handler, ) : Nil ATH::Server.new(port, host, reuse_port, ssl_context, prepend_handlers).start end # Runs an `ATH::Console::Application` as the entrypoint of `Athena::Console`. # # Checkout the [Getting Started](/getting_started/commands) docs for more information. def self.run_console : Nil ADI.container.athena_console_application.run end # :nodoc: # # Currently an implementation detail. In the future could be exposed to allow having separate "groups" of controllers that a `Server` instance handles. struct Server def initialize( @port : Int32 = 3000, @host : String = "0.0.0.0", @reuse_port : Bool = false, @ssl_context : OpenSSL::SSL::Context::Server? = nil, prepend_handlers handlers : Array(::HTTP::Handler) = [] of ::HTTP::Handler, ) handler_proc = ::HTTP::Handler::HandlerProc.new do |context| # Reinitialize the container since keep-alive requests reuse the same fiber. Fiber.current.container = ADI::ServiceContainer.new handler = ADI.container.athena_http_kernel # Convert the raw `HTTP::Request` into an `AHTTP::Request` instance. request = AHTTP::Request.new context.request # Handle the request. athena_response = handler.handle request # Send the response based on the current context. athena_response.send request, context.response # Emit the terminate event now that the response has been sent. handler.terminate request, athena_response end @server = if handlers.empty? ::HTTP::Server.new &handler_proc else ::HTTP::Server.new handlers, &handler_proc end end def stop : Nil @server.close unless @server.closed? end def start : Nil # TODO: Is there a better place to do this? {% if (trusted_hosts = ADI::CONFIG["framework"]["trusted_hosts"]) && !trusted_hosts.empty? %} AHTTP::Request.set_trusted_hosts({{trusted_hosts}}) {% end %} {% if (trusted_proxies = ADI::CONFIG["framework"]["trusted_proxies"]) && (trusted_headers = ADI::CONFIG["framework"]["trusted_headers"]) %} AHTTP::Request.set_trusted_proxies({{trusted_proxies}}, {{trusted_headers}}) {% end %} {% for header, name in ADI::CONFIG["framework"]["trusted_header_overrides"] %} AHTTP::Request.override_trusted_header({{header}}, {{name}}) {% end %} {% if (file_uploads = ADI::CONFIG["framework"]["file_uploads"]) && file_uploads["enabled"] %} AHTTP::UploadedFile.max_file_size = {{file_uploads["max_file_size"]}} {% end %} {% if flag?(:without_openssl) %} @server.bind_tcp @host, @port, reuse_port: @reuse_port {% else %} if ssl = @ssl_context @server.bind_tls @host, @port, ssl, @reuse_port else @server.bind_tcp @host, @port, reuse_port: @reuse_port end {% end %} # Handle exiting correctly on interrupt signals Process.on_terminate { self.stop } Log.info { %(Server has started and is listening at #{@ssl_context ? "https" : "http"}://#{@server.addresses.first}) } @server.listen end end end ATH.register_bundle ATH::Bundle ================================================ FILE: src/components/framework/src/bundle.cr ================================================ @[ADI::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 < ADI::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 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. property default_locale : String = "en" # Controls the IP addresses of trusted proxies that'll be used to get precise information about the client. # # See the [external documentation](/guides/proxies) for more information. property trusted_proxies : Array(String)? = nil # Controls which headers your `#trusted_proxies` use. # # See the [external documentation](/guides/proxies) for more information. property trusted_headers : Athena::HTTP::Request::ProxyHeader = Athena::HTTP::Request::ProxyHeader[:forwarded_for, :forwarded_port, :forwarded_proto] # By default the application can handle requests from any host. # This property allows configuring regular expression patterns to control what hostnames the application is allowed to serve. # This effectively prevents [host header attacks](https://www.skeletonscribe.net/2013/05/practical-http-host-header-attacks.html). # # If there is at least one pattern defined, requests whose hostname does _NOT_ match any of the patterns, will receive a 400 response. property trusted_hosts : Array(Regex) = [] of Regex # Allows overriding the header name to use for a given `AHTTP::Request::ProxyHeader`. # # See the [external documentation](/guides/proxies/#custom-headers) for more information. property trusted_header_overrides : Hash(Athena::HTTP::Request::ProxyHeader, String) = {} of NoReturn => NoReturn # 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. # # [AHTTP::Request::FORMATS](/HTTP/Request/#Athena::HTTP::Request::FORMATS) is used to map the request's `MIME` type to its format. module FormatListener include ADI::Extension::Schema # 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. # # ### Example # # ``` # ATH.configure({ # framework: { # format_listener: { # enabled: true, # rules: [ # {priorities: ["json", "xml"], host: /api\.example\.com/, fallback_format: "json"}, # {path: /^\/image/, priorities: ["jpeg", "gif"], fallback_format: false}, # {path: /^\/admin/, priorities: ["xml", "html"]}, # {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. # # --- # >>path: Use this rules configuration if the request's path matches the regex. # >>host: Use this rules configuration if the request's hostname matches the regex. # >>methods: Use this rules configuration if the request's method is one of these configured methods. # >>priorities: Defines the order of media types the application prefers. If a format is provided instead of a media type, # the format is converted into a list of media types matching the format. # >>fallback_format: If `nil` and the `path`, `host`, or `methods` did not match the current request, skip this rule and try the next one. # If set to a format string, use that format. If `false`, return a `406` instead of considering the next rule. # >>stop: If `true`, disables the format listener for this and any following rules. # Can be used as a way to enable the listener on a subset of routes within the application. # >>prefer_extension: Determines if the `accept` header, or route path `_format` parameter takes precedence. # For example, say there is a routed defined as `/foo.{_format}`. When `false`, the format from `_format` placeholder is checked last against the defined `priorities`. # Whereas if `true`, it would be checked first. # --- array_of rules, path : Regex? = nil, host : Regex? = nil, methods : Array(String)? = nil, priorities : Array(String)? = nil, fallback_format : String | Bool | Nil = "json", stop : Bool = false, prefer_extension : Bool = true end # 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 # CORS defaults that affect all routes globally. module Defaults include ADI::Extension::Schema # Indicates whether the request can be made using credentials. # # Maps to the access-control-allow-credentials header. 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 | 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 # Configures settings related to file uploads. # # ``` # ATH.configure({ # framework: { # file_uploads: { # enabled: true, # }, # }, # }) # ``` # See the [Getting Started](/getting_started/routing/#file-uploads) docs for more information. module FileUploads include ADI::Extension::Schema # If `false`, native file upload support will be disabled and related types will not be included in the resulting binary. property enabled : Bool = false # The directory where temp files will be stored while requests are being processed. # If `nil`, then a directory called `athena` will be used within the system's [tempdir](https://crystal-lang.org/api/Dir.html#tempdir:String-class-method) by default. # # WARNING: If providing a custom directory, it _MUST_ already exist. property temp_dir : String? = nil # Controls how many files may be uploaded at once. property max_uploads : Int32 = 25 # The maximum allowed file size, in bytes, that are allowed to be uploaded. # Defaults to 10 MiB. property max_file_size : Int64 = 1024 * 1024 * 10 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 || !release_flag 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 # Normalize headers to lowercase for HTTP/2 support. allow_headers = cfg["defaults"]["allow_headers"].map &.downcase expose_headers = cfg["defaults"]["expose_headers"].map &.downcase # 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: #{allow_headers}, allow_methods: #{cfg["defaults"]["allow_methods"]}, expose_headers: #{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 = "default_router"] = { class: ATH::Routing::Router, 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%"}, }, } SERVICE_HASH["athena_framework_controllers_redirect"] = { class: Athena::Framework::Controller::Redirect, public: true, parameters: { 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? matcher_arguments_to_service_id_map = {} of Nil => Nil calls = [] 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 << "AHTTP::RequestMatcher::Path.new(#{v})".id end if v = rule["host"] matchers << "AHTTP::RequestMatcher::Hostname.new(#{v})".id end if v = rule["methods"] matchers << "AHTTP::RequestMatcher::Method.new(#{v})".id end SERVICE_HASH[matcher_service_id = "framework_view_handler_request_match_#{idx}"] = { class: AHTTP::RequestMatcher, parameters: { matchers: {value: "#{matchers} of AHTTP::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 calls << {"add", {matcher_service_id.id, "ATH::View::FormatNegotiator::Rule.new( stop: #{rule["stop"]}, priorities: #{rule["priorities"]}, prefer_extension: #{rule["prefer_extension"]}, fallback_format: #{rule["fallback_format"]} )".id}} end SERVICE_HASH["athena_framework_view_format_negotiator"] = { class: ATH::View::FormatNegotiator, calls: calls, } SERVICE_HASH["athena_framework_listeners_format"] = { class: ATH::Listeners::Format, } 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"]}, }, } %} # File Uploads {% cfg = CONFIG["framework"]["file_uploads"] if cfg["enabled"] SERVICE_HASH["athena_framework_listeners_file"] = { class: Athena::Framework::Listeners::File, } SERVICE_HASH["athena_framework_file_parser"] = { class: ATH::FileParser, parameters: { temp_dir: {value: cfg["temp_dir"]}, max_uploads: {value: cfg["max_uploads"]}, max_file_size: {value: cfg["max_file_size"]}, }, } end %} {% end %} end end end end ================================================ FILE: src/components/framework/src/commands/debug_event_dispatcher.cr ================================================ @[ACONA::AsCommand("debug:event-dispatcher", description: "Display configured listeners for an application")] @[ADI::Register] # Utility command to allow viewing information about an `AED::EventDispatcherInterface`. # Includes the type/method of each event listener, along with the order they run in based on their priority. # Accepts an optional argument to allow filtering the list to a specific event, or ones that contain the provided string. # # ```text # $ ./bin/console debug:event-dispatcher # Registered Listeners Grouped by Event # ===================================== # # Athena::Framework::Events::Exception event # ------------------------------------------ # # ------- -------------------------------------------------- ---------- # Order Callable Priority # ------- -------------------------------------------------- ---------- # #1 Athena::Framework::Listeners::Error#on_exception -50 # ------- -------------------------------------------------- ---------- # # Athena::Framework::Events::Request event # ---------------------------------------- # # ------- -------------------------------------------------- ---------- # Order Callable Priority # ------- -------------------------------------------------- ---------- # #1 Athena::Framework::Listeners::CORS#on_request 250 # #2 Athena::Framework::Listeners::Format#on_request 34 # #3 Athena::Framework::Listeners::Routing#on_request 32 # ------- -------------------------------------------------- ---------- # # ... # ``` # # TODO: Support dedicated `AED::EventDispatcherInterface` services other than the default. class Athena::Framework::Commands::DebugEventDispatcher < ACON::Command def initialize( @dispatcher : AED::EventDispatcherInterface, ) super() end protected def configure : Nil self .argument("event", description: "An event name or a part of the event name") { @dispatcher.listeners.keys.map &.to_s } .option("format", value_mode: :required, description: "The output format (txt)", default: "txt") { ACON::Helper::Descriptor.new.formats } .option("raw", nil, :none, "To output raw command help") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status style = ACON::Style::Athena.new input, output # TODO: Allow resolving a specific dispatcher service dispatcher = @dispatcher event_class_map = dispatcher.listeners.each_key.each_with_object({} of String => ACTR::EventDispatcher::Event.class) do |event, map| map[event.to_s] = event end event_class = nil event_classes = nil if event = input.argument "event" e = event_class_map[event]? if e && dispatcher.has_listeners? e event_class = e else events = self.search_for_event dispatcher, event if events.empty? style.error_style.warning "The event '#{event}' does not have any registered listeners." return Status::SUCCESS elsif 1 == events.size event_class = events.first else event_classes = events end end end helper = Athena::Framework::Console::Helper::Descriptor.new helper .describe( style, dispatcher, ATH::Console::Descriptor::EventDispatcherContext.new( output: style, event_class: event_class, event_classes: event_classes, format: input.option("format", String), raw_text: input.option("raw", Bool), ) ) Status::SUCCESS end private def search_for_event(dispatcher : AED::EventDispatcherInterface, event : String) : Array(ACTR::EventDispatcher::Event.class) event_class_string = event.downcase matching_events = [] of ACTR::EventDispatcher::Event.class dispatcher.listeners.each_key.each do |event_class| matching_events << event_class if event_class.to_s.downcase.includes? event_class_string end matching_events end end ================================================ FILE: src/components/framework/src/commands/debug_router.cr ================================================ @[ACONA::AsCommand("debug:router", description: "Display current routes for an application")] @[ADI::Register] # Utility command to allow viewing all of the routes the framework is aware of within your application. # # ```text # $ ./bin/console debug:router # ---------------- ------- ------- ----- -------------------------------------------- # Name Method Scheme Host Path # ---------------- ------- ------- ----- -------------------------------------------- # homepage ANY ANY ANY / # contact GET ANY ANY /contact # contact_process POST ANY ANY /contact # article_show ANY ANY ANY /articles/{_locale}/{year}/{title}.{_format} # blog ANY ANY ANY /blog/{page} # blog_show ANY ANY ANY /blog/{slug} # ---------------- ------- ------- ----- -------------------------------------------- # ``` # # The command also supports viewing additional information about a specific route: # ```text # $ ./bin/console debug:router test # +--------------+-------------------------------------+ # | Property | Value | # +--------------+-------------------------------------+ # | Route Name | test | # | Path | /{id}/{a} | # | Path Regex | ^/(?P\d+)/(?P
10)$ | # | Host | ANY | # | Host Regex | | # | Scheme | ANY | # | Methods | GET | # | Requirements | a: 10 | # | | id: \d+ | # | Class | Athena::Routing::Route | # | Defaults | _controller: ExampleController#root | # +--------------+-------------------------------------+ # ``` # # TIP: Checkout `ATH::Commands::DebugRouterMatch` to test which route a given path resolves to. class Athena::Framework::Commands::DebugRouter < ACON::Command def initialize( @router : ART::RouterInterface, ) super() end protected def configure : Nil self .argument("name", description: "A route name") { @router.route_collection.routes.keys } .option("show-controllers", value_mode: :none, description: "Show assigned controllers in overview") .option("format", value_mode: :required, description: "The output format (txt)", default: "txt") { ACON::Helper::Descriptor.new.formats } .option("raw", value_mode: :none, description: "To output raw command help") end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status helper = Athena::Framework::Console::Helper::Descriptor.new routes = @router.route_collection style = ACON::Style::Athena.new input, output if name = input.argument "name" route = routes[name]? matching_routes = self.find_route_name_containing name, routes if !input.interactive? && !route && !matching_routes.empty? helper .describe( style, self.find_route_containing(name, routes), ATH::Console::Descriptor::RoutingContext.new( output: style, show_controllers: input.option("show-controllers", Bool), format: input.option("format", String), raw_text: input.option("raw", Bool), ) ) return Status::SUCCESS end if !route && !matching_routes.empty? default = (1 == matching_routes.size) ? matching_routes.first : nil name = style.choice("Select one of the matching routes", matching_routes, default).as String route = routes[name] end if !route raise ACON::Exception::InvalidArgument.new "The route '#{name}' does not exist." end helper .describe( style, route, ATH::Console::Descriptor::RoutingContext.new( name: name, output: style, format: input.option("format", String), raw_text: input.option("raw", Bool), ) ) else helper .describe( style, routes, ATH::Console::Descriptor::RoutingContext.new( output: style, show_controllers: input.option("show-controllers", Bool), format: input.option("format", String), raw_text: input.option("raw", Bool), ) ) end Status::SUCCESS end private def find_route_name_containing(name : String, routes : ART::RouteCollection) : Array(String) routes.compact_map do |route_name, _| next unless route_name.includes? name route_name end end private def find_route_containing(name : String, routes : ART::RouteCollection) : ART::RouteCollection found_routes = ART::RouteCollection.new routes.each do |route_name, route| found_routes.add route_name, route if route_name.includes? name end found_routes end end ================================================ FILE: src/components/framework/src/commands/debug_router_match.cr ================================================ @[ACONA::AsCommand("debug:router:match", description: "Simulate a path match to see which route, if any, would handle it")] @[ADI::Register] # Similar to `ATH::Commands::DebugRouter`, but instead of providing the route name, you provide the request path # in order to determine which, if any, route that path maps to. # # ```text # $ ./bin/console debug:router:match /user/10 # [OK] Route 'example_controller_user' matches # # +--------------+-------------------------------------+ # | Property | Value | # +--------------+-------------------------------------+ # | Route Name | example_controller_user | # | Path | /user/{id} | # | Path Regex | ^/user/(?P\d+)$ | # | Host | ANY | # | Host Regex | | # | Scheme | ANY | # | Methods | GET | # | Requirements | id: \d+ | # | Class | Athena::Routing::Route | # | Defaults | _controller: ExampleController#user | # +--------------+-------------------------------------+ # ``` # # Or if the route only partially matches: # # ```text # $ ./bin/console debug:router:match /user/foo # Route 'example_controller_user' almost matches but requirement for 'id' does not match (\d+) # # [ERROR] None of the routes match the path '/user/foo' # ``` class Athena::Framework::Commands::DebugRouterMatch < ACON::Command def initialize( @router : ART::RouterInterface, ) super() end protected def configure : Nil self .argument("path_info", :required, "A path to test") .option("method", nil, :required, "Set the HTTP method to use") .option("host", nil, :required, "Set the URI host") .option("scheme", nil, :required, "Set the URI scheme (usually http or https)") .help( <<-HELP The %command.name% shows which routes match a given request and which don't and for what reason: %command.full_name% /foo or %command.full_name% /foo --method=POST --scheme=https --host=https://crystal-lang.org/ --verbose HELP ) end protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status style = ACON::Style::Athena.new input, output context = @router.context if method = input.option "method" context.method = method end if scheme = input.option "scheme" context.scheme = scheme end if host = input.option "host" context.host = host end matcher = ART::Matcher::TraceableURLMatcher.new @router.route_collection, context traces = matcher.traces input.argument "path_info", String style.new_line matches = false traces.each do |trace| if trace.level.partial? style.text "Route '#{trace.name}' almost matches but #{trace.message.sub 0, trace.message[0].downcase}}" elsif trace.level.full? style.success "Route '#{trace.name}' matches" router_debug_command = self.application.find("debug:router") router_debug_command.run ACON::Input::Hash.new({"name" => trace.name}), output matches = true else style.text "Route '#{trace.name}' does not match: #{trace.message}" end end unless matches style.error "None of the routes match the path '#{input.argument "path_info"}'" return Status::FAILURE end Status::SUCCESS end end ================================================ FILE: src/components/framework/src/compiler_passes/expose_controller_services.cr ================================================ module Athena::Framework::CompilerPasses::MakeControllerServicesPublicPass macro included macro finished {% verbatim do %} {% SERVICE_HASH.each do |service_id, metadata| if metadata["class"] <= ATH::Controller metadata["public"] = true end end %} {% end %} end end end ================================================ FILE: src/components/framework/src/controller/redirect.cr ================================================ # :nodoc: class Athena::Framework::Controller::Redirect def initialize( @http_port : Int32? = nil, @https_port : Int32? = nil, ); end # ameba:disable Metrics/CyclomaticComplexity: def redirect_url( request : AHTTP::Request, path : String, permanent : Bool = false, scheme : String? = nil, http_port : Int32? = nil, https_port : Int32? = nil, keep_request_method : Bool = false, ) : AHTTP::RedirectResponse if path.empty? raise AHK::Exception::HTTPException.new (permanent ? ::HTTP::Status::GONE : ::HTTP::Status::NOT_FOUND), "" end status = if keep_request_method permanent ? ::HTTP::Status::PERMANENT_REDIRECT : ::HTTP::Status::TEMPORARY_REDIRECT else permanent ? ::HTTP::Status::MOVED_PERMANENTLY : ::HTTP::Status::FOUND end scheme ||= request.scheme if path.starts_with? "//" path = "#{scheme}:#{path}" end uri = URI.parse path # If the path has a scheme, assume it is a full URI if uri.scheme.presence return AHTTP::RedirectResponse.new path, status end # If the request has query params of its own, be sure to retain both sets of params. if request.query.presence # Don't use `merge!` here so the query string is correctly refreshed on the uri. uri.query_params = uri.query_params.merge request.query_params, replace: false end if "http" == scheme if http_port.nil? http_port = if "http" == request.scheme request.port else @http_port end end uri.port = http_port if http_port && 80 != http_port elsif "https" == scheme if https_port.nil? https_port = if "https" == request.scheme request.port else @https_port end end uri.port = https_port if https_port && 443 != https_port end uri.host = request.host uri.scheme = scheme AHTTP::RedirectResponse.new uri.normalize!.to_s, status end end ================================================ FILE: src/components/framework/src/controller/value_resolvers/enum.cr ================================================ require "./interface" @[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: 105}])] # Handles resolving an [Enum](https://crystal-lang.org/api/Enum.html) member from a string value that is stored in the request's [AHTTP::Request#attributes](/HTTP/Request/#Athena::HTTP::Request#attributes). # This resolver supports both numeric and string based parsing, returning a proper error response if the provided value does not map to any valid member. # # ``` # require "athena" # # enum Color # Red # Blue # Green # end # # class ExampleController < ATH::Controller # @[ARTA::Get("/numeric/{color}")] # def get_color_numeric(color : Color) : Color # color # end # # @[ARTA::Get("/string/{color}")] # def get_color_string(color : Color) : Color # color # end # end # # ATH.run # # # GET /numeric/1 # => "blue" # # GET /string/red # => "red" # ``` # # TIP: Checkout `ART::Requirement::Enum` for an easy way to restrict routing to an enum's members, or a subset of them. struct Athena::Framework::Controller::ValueResolvers::Enum include Athena::Framework::Controller::ValueResolvers::Interface # :inherit: def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) return unless parameter.instance_of? ::Enum return unless enum_type = parameter.first_type_of ::Enum return unless value = request.attributes.get? parameter.name, String member = if (num = value.to_i128?(whitespace: false)) && (m = enum_type.from_value? num) m elsif m = enum_type.parse? value m end unless member raise AHK::Exception::BadRequest.new "Parameter '#{parameter.name}' of enum type '#{enum_type}' has no valid member for '#{value}'." end member end end ================================================ FILE: src/components/framework/src/controller/value_resolvers/interface.cr ================================================ # Value resolvers handle resolving the argument(s) to pass to a controller action based on values stored within the `AHTTP::Request`, or some other source. # # Custom resolvers can be defined by creating a service that implements this interface, and is tagged with `ATHR::Interface::TAG`. # The tag also accepts an optional *priority* field the determines the order in which the resolvers execute. # The list of built in resolvers and their priorities can be found on the `ATH::Controller::ValueResolvers` module. # # WARNING: Resolvers that mutate a value already within the [AHTTP::Request#attributes](/HTTP/Request/#Athena::HTTP::Request#attributes), such as one from a route or query parameter _MUST_ have a priority `>100` # to ensure the custom logic is applied before the raw value is resolved via the `ATHR::RequestAttribute` resolver. # # The first resolver to return a value wins and no other resolvers will be executed for that particular parameter. # The resolver should return `nil` to denote no value could be resolved, # such as if the parameter is of the wrong type, does not have a specific annotation applied, or anything else that can be deduced from either parameter. # If no resolver is able to resole a value for a specific parameter, an error is thrown and processing of the request ceases. # # For example: # # ``` # @[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: 10}])] # struct CustomResolver # include ATHR::Interface # # # :inherit: # def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) : MyCustomType? # # Return early if a value is unresolvable from the current *request* and/or *parameter*. # return if parameter.type != MyCustomType # # # Return the resolved value. It could either come from the request itself, an injected service, or hard coded. # MyCustomType.new "foo" # end # end # ``` # # Now, given the following controller: # # ``` # class ExampleController < ATH::Controller # @[ARTA::Get("/")] # def root(my_obj : MyCustomType) : String # my_obj.name # end # end # # # GET / # => "foo" # ``` # # Since none of the built-in resolvers are applicable for this parameter type, # nor is there a *my_obj* value in [AHTTP::Request#attributes](/HTTP/Request/#Athena::HTTP::Request#attributes), assuming no customer listeners manually add it, the `CustomResolver` would take over and provide the value for that parameter. # # ## Configuration # # In some cases, the request and parameter themselves may not be enough to know if a resolver should try to resolve a value or not. # A naive example would be say you want to have a resolver that multiplies certain `Int32` parameters by `10`. # It wouldn't be enough to just check if the parameter is an `Int32` as that leaves too much room for unexpected contexts to be resolved unexpectedly. # For such cases a `.configuration` annotation type may be defined to allow marking the specific parameters the related resolver should apply to. # # For example: # # ``` # # The priority _MUST_ be `>100` to ensure the value isnt preemptively resolved by the `ATHR::RequestAttribute` resolver. # @[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: 110}])] # struct Multiply # include ATHR::Interface # # # The value provided to the macro maps to the name of the annotation. # configuration Multiply::This # # def initialize( # @annotation_resolver : ATH::AnnotationResolver, # ); end # # # :inherit: # def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) : Int32? # # Return early if the controller action parameter doesn't have the annotation. # return unless @annotation_resolver.action_parameter_annotations(request, parameter.name).has? This # # # Return early if the parameter type is not `Int32`. # return if parameter.type != Int32 # # request.attributes.get(parameter.name, Int32) * 10 # end # end # # class ExampleController < ATH::Controller # @[ARTA::Get("/{num}")] # def multiply( # @[Multiply::This] # num : Int32, # ) : Int32 # num # end # end # # ATH.run # # # GET /10 # => 100 # ``` # # While this example is quite naive, this pattern is used as part of the `ATHR::RequestBody` to know if an object should be deserialized from the request body, or is intended be supplied some other way. # # ### Extra Data # # Another use case for this pattern is providing extra data on a per parameter basis. # For example, say we wanted to allow customizing the multiplier instead of having it hard coded to `10`. # # In order to do this we can pass properties to the `.configuration` macro to define what we want to be configurable via the annotation. # Next we can then use this value in our resolver, and when applying to a specific parameter: # # ``` # # The priority _MUST_ be `>100` to ensure the value isnt preemptively resolved by the `ATHR::RequestAttribute` resolver. # @[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: 110}])] # struct Multiply # include ATHR::Interface # # configuration Multiply::This, multiplier : Int32 = 10 # # def initialize( # @annotation_resolver : ATH::AnnotationResolver, # ); end # # # :inherit: # def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) : Int32? # # Return early if the controller action parameter doesn't have the annotation. # return unless @annotation_resolver.action_parameter_annotations(request, parameter.name).has? This # # # Return early if the parameter type is not `Int32`. # return if parameter.type != Int32 # # request.attributes.get(parameter.name, Int32) * config.multiplier # end # end # # class ExampleController < ATH::Controller # @[ARTA::Get("/{num}")] # def multiply( # @[Multiply::This(multiplier: 50)] # num : Int32, # ) : Int32 # num # end # end # # ATH.run # # # GET /10 # => 500 # ``` # # A more real-world example of this pattern is how the `ATHR::Time` resolver allows customizing the format and/or location that should be used to parse the datetime string via `ATHA::MapTime` annotation. # # TIP: The configuration annotation may be defined in another namespace via prefixing the FQN of the path with `::`. # E.g. `configuration ::MyApp::Annotation::Multiply`. # # ## Handling Multiple Types # # When using an annotation to enable a particular resolver, it may be required to handle parameters of varying types. # E.g. it should do one thing when enabled on an `Int32` parameter, while a different thing when applied to a `String` parameter. # But both things are related enough to not warrant dedicated resolvers. # Because the type of the parameter is stored within a generic type, it can be used to overload the `#resolve` method based on its type # For example: # # ``` # # The priority _MUST_ be `>100` to ensure the value isnt preemptively resolved by the `ATHR::RequestAttribute` resolver. # @[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: 110}])] # struct MyResolver # include ATHR::Interface # # configuration MyResolver::Enable # # def initialize( # @annotation_resolver : ATH::AnnotationResolver, # ); end # # # :inherit: # def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata(Int32)) : Int32? # return unless @annotation_resolver.action_parameter_annotations(request, parameter.name).has? Enable # # request.attributes.get(parameter.name, Int32) * 10 # end # # # :inherit: # def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata(String)) : String? # return unless @annotation_resolver.action_parameter_annotations(request, parameter.name).has? Enable # # request.attributes.get(parameter.name, String).upcase # end # # # :inherit: # # # # Fallback overload for types other than `Int32` and `String. # def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) : Nil # end # end # # class ExampleController < ATH::Controller # @[ARTA::Get("/integer/{value}")] # def integer( # @[MyResolver::Enable] # value : Int32, # ) : Int32 # value # end # # @[ARTA::Get("/string/{value}")] # def string( # @[MyResolver::Enable] # value : String, # ) : String # value # end # end # # ATH.run # # # GET /integer/10 # => 100 # # GET /string/foo # => "FOO" # ``` # # ### Free Vars # # If more precision is required, a [free variable](https://crystal-lang.org/reference/syntax_and_semantics/type_restrictions.html#free-variables) # can be used to extract the type of the related parameter such that it can be used to generate the proper code. # # An example of this is how `ATHR::RequestBody` handles both `ASR::Serializable` and `JSON::Serializable` types via: # # ``` # {% begin %} # {% if T.instance <= ASR::Serializable %} # object = @serializer.deserialize T, body, :json # {% elsif T.instance <= JSON::Serializable %} # object = T.from_json body # {% else %} # return # {% end %} # {% end %} # ``` # # This works well to make the compiler happy when previous methods are not enough. # # ### Strict Typing # # In all of the examples so far, the resolvers could be applied to any parameter of any type and all of the logic to resolve a value would happen at runtime. # In some cases a specific resolver may only support a single, or small subset of types. # Such as how the `ATHR::RequestBody` resolver only allows `ASR::Serializable`, `JSON::Serializable`, or `URI::Params::Serializable` types. # In this case, the `ATHR::Interface::Typed` module may be used to define the allowed parameter types. # # WARNING: Strict typing is _ONLY_ supported when a configuration annotation is used to enable the resolver. # # ``` # @[ADI::Register(tags: [{name: ATHR::Interface::TAG}])] # struct MyResolver # # Multiple types may also be supplied by providing it a comma separated list. # # If `nil` is a valid option, the `Nil` type should also be included. # include ATHR::Interface::Typed(String) # # configuration MyResolver::Enable # # def initialize( # @annotation_resolver : ATH::AnnotationResolver, # ); end # # # :inherit: # def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) : String? # return unless @annotation_resolver.action_parameter_annotations(request, parameter.name).has? Enable # # "foo" # end # end # # class ExampleController < ATH::Controller # @[ARTA::Get("/integer")] # def integer( # @[MyResolver::Enable] # value : Int32, # ) : Int32 # value # end # # @[ARTA::Get("/string")] # def string( # @[MyResolver::Enable] # value : String, # ) : String # value # end # end # # ATH.run # # # Error: The annotation '@[MyResolver::Enable]' cannot be applied to 'ExampleController#integer:value : Int32' # # since the 'MyResolver' resolver only supports parameters of type 'String'. # ``` # # Since `MyResolver` was defined to only support `String` types, a compile time error is raised when its annotation is applied to a non `String` parameter. # This feature pairs nicely with the [free var][Athena::Framework::Controller::ValueResolvers::Interface--free-vars] section as it essentially allows # scoping the possible types of `T` to the set of types defined as part of the module. module Athena::Framework::Controller::ValueResolvers::Interface include Athena::HTTPKernel::Controller::ValueResolvers::Interface # :nodoc: ANNOTATION_RESOLVER_MAP = {} of Nil => Nil # The tag name for `ATHR::Interface` services. TAG = "athena.controller.value_resolver" # Helper macro around `ADI.configuration_annotation` that allows defining resolver specific annotations. # See the underlying macro and the [configuration][Athena::Framework::Controller::ValueResolvers::Interface--configuration] section for more information. macro configuration(name, *args) ADI.configuration_annotation {{name.id}}{% unless args.empty? %}, {{args.splat}}{% end %} {% ANNOTATION_RESOLVER_MAP[name.id] = @type.resolve %} end # Represents an `ATHR::Interface` that only supports a subset of types. # # See the [strict typing][Athena::Framework::Controller::ValueResolvers::Interface--strict-typing] section for more information. module Typed(*SupportedTypes) include Athena::Framework::Controller::ValueResolvers::Interface end end ================================================ FILE: src/components/framework/src/controller/value_resolvers/query_parameter.cr ================================================ # Attempts to resolve the value from the request's query parameters for any parameter with the `ATHA::MapQueryParameter` annotation. # Supports most primitive types, as well as arrays of most primitive types, and enums. # # The name of the query parameter is assumed to be the same as the controller action parameter's name. # This can be customized via the `name` field on the annotation. # # If the controller action parameter is not-nilable nor has a default value and is missing, an `AHK::Exception::NotFound` exception will be raised by default. # Similarly, an exception will be raised if the value fails to be converted to the expected type. # The specific type of exception can be customized via the `validation_failed_status` field on the annotation. # # ``` # require "athena" # # enum Color # Red # Green # Blue # end # # class ExampleController < ATH::Controller # @[ARTA::Get("/")] # def index( # @[ATHA::MapQueryParameter] ids : Array(Int32), # @[ATHA::MapQueryParameter(name: "firstName")] first_name : String, # @[ATHA::MapQueryParameter] required : Bool, # @[ATHA::MapQueryParameter] age : Int32, # @[ATHA::MapQueryParameter] color : Color, # @[ATHA::MapQueryParameter] category : String = "", # @[ATHA::MapQueryParameter] theme : String? = nil, # ) : Nil # ids # => [1, 2] # first_name # => "Jon" # required # => false # age # => 123 # color # => Color::Blue # category # => "" # theme # => nil # end # end # # ATH.run # # # GET /?ids=1&ids=2&firstName=Jon&required=false&age=123&color=blue # ``` @[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: 110}])] struct Athena::Framework::Controller::ValueResolvers::QueryParameter include Athena::Framework::Controller::ValueResolvers::Interface # Enables the `ATHR::QueryParameter` resolver for the parameter this annotation is applied to. # See the related resolver documentation for more information. configuration ::Athena::Framework::Annotations::MapQueryParameter, name : String? = nil, validation_failed_status : ::HTTP::Status = :not_found def initialize( @annotation_resolver : ATH::AnnotationResolver, ); end # :inherit: def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) parameter_annotations = @annotation_resolver.action_parameter_annotations(request, parameter.name) return unless ann = parameter_annotations[ATHA::MapQueryParameter]? name = ann.name || parameter.name validation_failed_status = ann.validation_failed_status params = request.query_params unless params.has_key? name return if parameter.nilable? || parameter.has_default? raise AHK::Exception::HTTPException.from_status validation_failed_status, "Missing query parameter: '#{name}'." end value = if parameter.instance_of? Array params.fetch_all name else params[name] end begin parameter.type.from_parameter value rescue ex : ArgumentError # Catch type cast errors and bubble it up as a BadRequest raise AHK::Exception::HTTPException.from_status validation_failed_status, "Invalid query parameter: '#{name}'.", cause: ex end end end ================================================ FILE: src/components/framework/src/controller/value_resolvers/request_body.cr ================================================ require "uri/params/serializable" @[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: 105}])] # Attempts to resolve the value of any parameter with the `ATHA::MapRequestBody` annotation by # deserializing the request body into an object of the type of the related parameter. # The `ATHA::MapQueryString` annotation works similarly, but uses the request's query string instead of its body. # Lastly, the `ATHA::MapUploadedFile` annotation works by resolving one or more `AHTTP::UploadedFile` from [AHTTP::Request#files](/HTTP/Request/#Athena::HTTP::Request#files). # # If the object is also `AVD::Validatable`, any validations defined on it are executed before returning the object. # Requires the type of the related parameter to include one or more of: # # * `ASR::Serializable` # * `JSON::Serializable` # * `URI::Params::Serializable` # # ``` # require "athena" # # # A type representing the structure of the request body. # struct UserCreate # # Include some modules to tell Athena this type can be deserialized and validated # include AVD::Validatable # include JSON::Serializable # # # Assert the user's name is not blank. # @[Assert::NotBlank] # getter first_name : String # # # Assert the user's name is not blank. # @[Assert::NotBlank] # getter last_name : String # # # Assert the user's email is not blank and is a valid HTMl5 email. # @[Assert::NotBlank] # @[Assert::Email(:html5)] # getter email : String # end # # class UserController < ATH::Controller # @[ARTA::Post("/user")] # @[ATHA::View(status: :created)] # def new_user( # @[ATHA::MapRequestBody] # user_create : UserCreate, # ) : UserCreate # # Use the provided UserCreate instance to create an actual User DB record. # # For purposes of this example, just return the instance. # # user_create # end # end # # ATH.run # ``` # # Making a request to the `/user` endpoint with the following payload: # # ```json # { # "first_name": "George", # "last_name": "", # "email": "athenaframework.org" # } # ``` # # TIP: This resolver also supports `application/x-www-form-urlencoded` payloads. # # Would return the response: # # ```json # { # "code": 422, # "message": "Validation failed", # "errors": [ # { # "property": "last_name", # "message": "This value should not be blank.", # "code": "0d0c3254-3642-4cb0-9882-46ee5918e6e3" # }, # { # "property": "email", # "message": "This value is not a valid email address.", # "code": "ad9d877d-9ad1-4dd7-b77b-e419934e5910" # } # ] # } # ``` # # While a valid request would return this response body, with a 201 status code: # # ```json # { # "first_name": "George", # "last_name": "Dietrich", # "email": "contact@athenaframework.org" # } # ``` struct Athena::Framework::Controller::ValueResolvers::RequestBody include Athena::Framework::Controller::ValueResolvers::Interface::Typed(Athena::Serializer::Serializable, JSON::Serializable, URI::Params::Serializable, Athena::HTTP::UploadedFile?, Array(Athena::HTTP::UploadedFile)) # Enables the `ATHR::RequestBody` resolver for the parameter this annotation is applied to based on the request's body. # See the related resolver documentation for more information. # # ``` # class UserController < ATH::Controller # @[ARTA::Post("/user")] # def new_user( # @[ATHA::MapRequestBody] # user_create : UserCreateDTO, # ) : UserCreateDTO # user_create # end # end # ``` # # # Configuration # # ## Optional Arguments # # ### accept_formats # # **Type:** `Array(String)?` **Default:** `nil` # # Allows whitelisting the allowed [request format(s)](/HTTP/Request/#Athena::HTTP::Request::FORMATS). # If the [AHTTP::Request#content_type_format](/HTTP/Request/#Athena::HTTP::Request#content_type_format) is not included in this list, a `AHK::Exception::UnsupportedMediaType` error will be raised. # # ### validation_groups # # **Type:** `Array(String) | AVD::Constraints::GroupSequence | Nil` **Default:** `nil` # # The [validation groups](/Validator/Constraint/#Athena::Validator::Constraint--validation-groups) that should be used when validating the resolved object. configuration ::Athena::Framework::Annotations::MapRequestBody, accept_formats : Array(String)? = nil, validation_groups : Array(String) | AVD::Constraints::GroupSequence | Nil = nil # Enables the `ATHR::RequestBody` resolver for the parameter this annotation is applied to based on the request's query string. # See the related resolver documentation for more information. # # ``` # class ArticleController < ATH::Controller # @[ARTA::Get("/articles")] # def articles( # @[ATHA::MapQueryString] # pagination_context : PaginationContext, # ) : Array(Article) # # ... # end # end # ``` # # # Configuration # # ## Optional Arguments # # ### validation_groups # # **Type:** `Array(String) | AVD::Constraints::GroupSequence | Nil` **Default:** `nil` # # The [validation groups](/Validator/Constraint/#Athena::Validator::Constraint--validation-groups) that should be used when validating the resolved object. configuration ::Athena::Framework::Annotations::MapQueryString, validation_groups : Array(String) | AVD::Constraints::GroupSequence | Nil = nil # Enables the `ATHR::RequestBody` resolver for the parameter this annotation is applied to based on [AHTTP::Request#files](/HTTP/Request/#Athena::HTTP::Request#files), # if the related bundle configuration [is enabled](/Framework/Bundle/Schema/FileUploads/). # # If the type of the parameter this annotation is applied to is `AHTTP::UploadedFile`, then it will attempt to resolve the first file based on the name of the parameter. # This can be customized via the *name* field on the annotation. # If the type is a `Array(AHTTP::UploadedFile)` then all files with that name will be resolved, not just the first. # # When resolving a single file that is not found, and the parameter has a default value or is nilable, then that default value, or `nil`, will be used. # If the parameter does not have a default and is not nilable, then an error response is returned. # When resolving an array of files, then an empty array would be provided. # # ``` # class UserController < ATH::Controller # @[ARTA::Post("/avatar")] # def avatar( # @[ATHA::MapUploadedFile(constraints: AVD::Constraints::Image.new)] # profile_picture : AHTTP::UploadedFile, # ) : Nil # # ... # end # end # ``` # # # Configuration # # ## Optional Arguments # # ### name # # **Type:** `String?` **Default:** `nil` # # Use this value to resole the files instead of the name of the parameter the annotation is applied to. # # ### constraints # # **Type:** `AVD::Constraint | Array(AVD::Constraint) | Nil` **Default:** `nil` # # Validate the uploaded file(s) against these constraint(s). # Mostly commonly will be a single `AVD::Constraints::File` or `AVD::Constraints::Image` constraint. configuration ::Athena::Framework::Annotations::MapUploadedFile, constraints : AVD::Constraint | Array(AVD::Constraint) | Nil = nil, name : String? = nil def initialize( @serializer : ASR::SerializerInterface, @validator : AVD::Validator::ValidatorInterface, @annotation_resolver : ATH::AnnotationResolver, ); end # :inherit: def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) validation_groups = nil constraints = nil parameter_annotations = @annotation_resolver.action_parameter_annotations request, parameter.name object = if configuration = parameter_annotations[ATHA::MapQueryString]? validation_groups = configuration.validation_groups self.map_query_string request, parameter, configuration elsif configuration = parameter_annotations[ATHA::MapRequestBody]? validation_groups = configuration.validation_groups self.map_request_body request, parameter, configuration elsif configuration = parameter_annotations[ATHA::MapUploadedFile]? constraints = configuration.constraints self.map_uploaded_file request, parameter, configuration else return end if object && (object.is_a?(AVD::Validatable) || !constraints.nil?) if object.is_a?(Array) && constraints && !constraints.is_a?(AVD::Constraints::All) constraints = AVD::Constraints::All.new constraints end errors = @validator.validate object, constraints: constraints, groups: validation_groups raise AVD::Exception::ValidationFailed.new errors unless errors.empty? end object end private def map_query_string(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata, configuration : ATHA::MapQueryStringConfiguration) return unless query = request.query return if query.nil? && (parameter.nilable? || parameter.has_default?) self.deserialize_form query, parameter.type rescue ex : URI::SerializableError raise AHK::Exception::UnprocessableEntity.new ex.message.not_nil!, cause: ex rescue ex : URI::Error raise AHK::Exception::BadRequest.new "Malformed www form data payload.", cause: ex end # ameba:disable Metrics/CyclomaticComplexity: private def map_request_body(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata, configuration : ATHA::MapRequestBodyConfiguration) if !(body = request.body) || body.peek.try &.empty? raise AHK::Exception::BadRequest.new "Request does not have a body." end format = request.content_type_format if (accept_formats = configuration.accept_formats) && !accept_formats.includes? format raise AHK::Exception::UnsupportedMediaType.new "Unsupported format, expects one of: '#{accept_formats.join(", ")}', but got '#{format}'." end # We have to use separate deserialization methods with the case such that a type that includes multiple modules is handled as expected. case format when "form" self.deserialize_form body, parameter.type when "json" self.deserialize_json body, parameter.type else raise AHK::Exception::UnsupportedMediaType.new "Unsupported format." end rescue ex : JSON::SerializableError # JSON::Serializable seems to sometimes re-raise parse exceptions as `JSON::SerializableError`, # so we handle those first based on the cause. case cause = ex.cause when JSON::ParseException raise AHK::Exception::BadRequest.new "Malformed JSON payload.", cause: cause else raise AHK::Exception::UnprocessableEntity.new ex.message.not_nil! end rescue ex : JSON::ParseException | ASR::Exception::DeserializationException # Otherwise if it really is a `ParseException` we can be assured it's just malformed raise AHK::Exception::BadRequest.new "Malformed JSON payload.", cause: ex rescue ex : URI::SerializableError raise AHK::Exception::UnprocessableEntity.new ex.message.not_nil!, cause: ex rescue ex : URI::Error raise AHK::Exception::BadRequest.new "Malformed www form data payload.", cause: ex end private def deserialize_json(body : IO, klass : ASR::Serializable.class) @serializer.deserialize klass, body, :json end private def deserialize_json(body : IO, klass : JSON::Serializable.class) klass.from_json body end private def deserialize_json(body : IO, klass : _) : Nil end private def deserialize_form(body : IO, klass : URI::Params::Serializable.class) klass.from_www_form body.gets_to_end end private def deserialize_form(body : String, klass : URI::Params::Serializable.class) klass.from_www_form body end private def deserialize_form(body : IO | String, klass : _) end private def map_uploaded_file(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata, configuration : ATHA::MapUploadedFileConfiguration) : AHTTP::UploadedFile | Enumerable(AHTTP::UploadedFile) | Nil files = request.files[configuration.name || parameter.name]? || [] of AHTTP::UploadedFile if files.empty? && (parameter.nilable? || parameter.has_default?) return end if parameter.instance_of?(Array(AHTTP::UploadedFile)) return files end files.first? end end ================================================ FILE: src/components/framework/src/controller/value_resolvers/time.cr ================================================ @[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: 105}])] # Attempts to parse a date(time) string into a `::Time` instance. # # Optionally allows specifying the *format* and *location* to use when parsing the string via the `ATHA::MapTime` annotation. # If no *format* is specified, defaults to [RFC 3339](https://crystal-lang.org/api/Time.html#parse_rfc3339%28time:String%29-class-method). # Defaults to `UTC` if no *location* is specified with the annotation. # # Raises an `AHK::Exception::BadRequest` if the date(time) string could not be parsed. # # TIP: The format can be anything supported via [Time::Format](https://crystal-lang.org/api/Time/Format.html). # # ``` # require "athena" # # class ExampleController < ATH::Controller # @[ARTA::Get(path: "/event/{start_time}/{end_time}")] # def event( # @[ATHA::MapTime("%F", location: Time::Location.load("Europe/Berlin"))] # start_time : Time, # end_time : Time, # ) : Nil # start_time # => 2020-04-07 00:00:00.0 +02:00 Europe/Berlin # end_time # => 2020-04-08 12:34:56.0 UTC # end # end # # ATH.run # # # GET /event/2020-04-07/2020-04-08T12:34:56Z # ``` struct Athena::Framework::Controller::ValueResolvers::Time include Athena::Framework::Controller::ValueResolvers::Interface # Allows customizing the time format and/or location used to parse the string datetime as part of the `ATHR::Time` resolver. # See the related resolver documentation for more information. configuration ::Athena::Framework::Annotations::MapTime, format : String? = nil, location : ::Time::Location = ::Time::Location::UTC def initialize( @annotation_resolver : ATH::AnnotationResolver, ); end # :inherit: def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) : ::Time? return unless parameter.instance_of? ::Time if value = request.attributes.get? parameter.name, ::Time? return value end return unless value = request.attributes.get? parameter.name, String? parameter_annotations = @annotation_resolver.action_parameter_annotations(request, parameter.name) if !(configuration = parameter_annotations[ATHA::MapTime]?) || !(format = configuration.format) return ::Time.parse_rfc3339(value) end ::Time.parse value, format, configuration.location rescue ex : ::Time::Format::Error raise AHK::Exception::BadRequest.new "Invalid date(time) for parameter '#{parameter.name}'." end end ================================================ FILE: src/components/framework/src/controller/value_resolvers/uuid.cr ================================================ require "uuid" @[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: 105}])] # Handles resolving a [UUID](https://crystal-lang.org/api/UUID.html) from a string value that is stored in the request's [AHTTP::Request#attributes](/HTTP/Request/#Athena::HTTP::Request#attributes). # # ``` # require "athena" # # class ExampleController < ATH::Controller # @[ARTA::Get("/uuid/{uuid}")] # def get_uuid(uuid : UUID) : String # "Version: #{uuid.version} - Variant: #{uuid.variant}" # end # end # # ATH.run # # # GET /uuid/b115c7a5-0a13-47b4-b4ac-55b3e2686946 # => "Version: V4 - Variant: RFC4122" # ``` # # TIP: Checkout `ART::Requirement` for an easy way to restrict/validate the version of the UUID that is allowed. struct Athena::Framework::Controller::ValueResolvers::UUID include Athena::Framework::Controller::ValueResolvers::Interface # :inherit: def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) : ::UUID? return unless parameter.instance_of? ::UUID # TODO: Test making this not nil return unless value = request.attributes.get? parameter.name, String ::UUID.parse?(value) || raise AHK::Exception::BadRequest.new "Parameter '#{parameter.name}' with value '#{value}' is not a valid 'UUID'." end end ================================================ FILE: src/components/framework/src/controller.cr ================================================ # The core of any framework is routing; how a route is tied to an action. # Athena takes an annotation based approach; an annotation, such as `ARTA::Get` is applied to an instance method of a controller class, which will be executed when that endpoint receives a request. # # Additional annotations also exist for defining [query parameters](/getting_started/routing/#query-parameters). # # Child controllers must inherit from `ATH::Controller` (or an abstract child of it). Each request gets its own instance of the controller to better allow for DI via `Athena::DependencyInjection`. # # A route action can either return an `AHTTP::Response`, or some other type. If an `AHTTP::Response` is returned, then it is used directly. Otherwise an `AHK::Events::View` is emitted to convert # the action result into an `AHTTP::Response`. By default, `ATH::Listeners::View` will JSON encode the value if it is not handled earlier by another listener. # # ### Example # The following controller shows examples of the various routing features of Athena. `ATH::Controller` also defines various macro DSLs, such as `ATH::Controller.get` to make defining routes # seem more Sinatra/Kemal like. See the documentation on the macros for more details. # # ``` # require "athena" # require "mime" # # # The `ARTA::Route` annotation can also be applied to a controller class. # # This can be useful for applying a common path prefix, defaults, requirements, # # etc. to all actions in the controller. # @[ARTA::Route(path: "/athena")] # class TestController < ATH::Controller # # A GET endpoint returning an `AHTTP::Response`. # # Can be used to return raw data, such as HTML or CSS etc, in a one-off manor. # @[ARTA::Get(path: "/index")] # def index : AHTTP::Response # AHTTP::Response.new "

Welcome to my website!

", headers: ::HTTP::Headers{"content-type" => MIME.from_extension(".html")} # end # # # A GET endpoint returning an `AHTTP::StreamedResponse`. # # Can be used to stream the response content to the client; # # useful if the content is too large to fit into memory. # @[ARTA::Get(path: "/users")] # def users : AHTTP::Response # AHTTP::StreamedResponse.new headers: ::HTTP::Headers{"content-type" => "application/json; charset=utf-8"} do |io| # User.all.to_json io # end # end # # # A GET endpoint with no parameters returning a `String`. # # # # Action return type restrictions are required. # @[ARTA::Get("/me")] # def get_me : String # "Jim" # end # # # A GET endpoint with no parameters returning `Nil`. # # `Nil` return types are returned with a status # # of 204 no content # @[ARTA::Get("/no_content")] # def get_no_content : Nil # # Do stuff # end # # # A GET endpoint with two `Int32` parameters returning an `Int32`. # # # # The parameters of a route _MUST_ match the parameters of the action. # # Type restrictions on action parameters are required. # @[ARTA::Get("/add/{val1}/{val2}")] # def add(val1 : Int32, val2 : Int32) : Int32 # val1 + val2 # end # # # A GET endpoint with a required trailing slash, a `String` route parameter, # # and a required string query parameter; returning a `String`. # # # # Athena treats non `GET`/`HEAD` routes with a trailing slash as unique # # E.g. `POST /foo/bar/` versus `POST /foo/bar`. # # Be sure to keep you routes consistent! # # # # A non-nilable type denotes it as required. If the parameter is not supplied, # # and no default value is assigned, an `AHK::Exception::BadRequest` exception is raised. # @[ARTA::Get("/event/{event_name}/")] # def event_time(event_name : String, @[ATHA::MapQueryParameter] time : String) : String # "#{event_name} occurred at #{time}" # end # # # A GET endpoint with an optional query parameter and optional path parameter # # with a default value; returning a `NamedTuple(user_id : Int32?, page : Int32)`. # # # # A nilable type denotes it as optional. # # If the parameter is not supplied (or could not be converted), # # and no default value is assigned, it is `nil`. # @[ARTA::Get("/events/{page}")] # def events(@[ATHA::MapQueryParameter] user_id : Int32?, page : Int32 = 1) : NamedTuple(user_id: Int32?, page: Int32) # {user_id: user_id, page: page} # end # # # A GET endpoint with route parameter requirements. # # The parameter must match the supplied Regex or this route will not be matched. # # # # This feature can allow multiple routes to exist with parameters in the same location, # # but with different requirements. # @[ARTA::Get("/time/{time}/", requirements: {"time" => /\d{2}:\d{2}:\d{2}/})] # def get_constraint(time : String) : String # time # end # # # A POST endpoint with a route parameter and accessing the request body; returning a `Bool`. # # # # It is recommended to use `ATHR::RequestBody` to allow passing an actual object representing the data # # to the route's action; however the raw request body can be accessed by typing an action argument as `AHTTP::Request`. # @[ARTA::Post("/test/{expected}")] # def post_body(expected : String, request : AHTTP::Request) : Bool # expected == request.body.try &.gets_to_end # end # # # An endpoint may also have more than one route annotation applied to it. # # This can be useful in allowing for a route to support multiple aliases. # @[ARTA::Get("/users/{id}")] # @[ARTA::Get("/people/{id}")] # def get_user(id : Int64) : User # # Fetch the user # user = ... # # user # end # end # # ATH.run # # # GET /athena/index # =>

Welcome to my website!

# # GET /athena/users # => [{"id":1,...},...] # # GET /athena/wakeup/17 # => Morning, Allison it is currently 2020-02-01 18:38:12 UTC. # # GET /athena/me # => "Jim" # # GET /athena/add/50/25 # => 75 # # GET /athena/event/foobar?time=1:1:1 # => "foobar occurred at 1:1:1" # # GET /athena/event/foobar/?time=1:1:1 # => "foobar occurred at 1:1:1" # # GET /athena/events # => {"user_id":null,"page":1} # # GET /athena/events/17?user_id=19 # => {"user_id":19,"page":17} # # GET /athena/time/12:45:30 # => "12:45:30" # # GET /athena/time/12:aa:30 # => 404 not found # # GET /athena/no_content # => 204 no content # # GET /athena/users/19 # => {"user_id":19} # # GET /athena/people/19 # => {"user_id":19} # # POST /athena/test/foo, body: "foo" # => true # ``` abstract class Athena::Framework::Controller macro inherited private CONTROLLER_ACTION_METHODS = [] of {String, String} macro method_added(m) \{% if (m.annotation(ARTA::Get) || m.annotation(ARTA::Post) || m.annotation(ARTA::Put) || m.annotation(ARTA::Delete) || m.annotation(ARTA::Patch) || m.annotation(ARTA::Link) || m.annotation(ARTA::Unlink) || m.annotation(ARTA::Head) || m.annotation(ARTA::Route)) if CONTROLLER_ACTION_METHODS.includes?({@type.name.id, m.name.id}) m.raise "A controller action named '##{m.name}' already exists within '#{@type.name}'." end CONTROLLER_ACTION_METHODS << {@type.name.id, m.name.id} end %} end end # Generates a URL to the provided *route* with the provided *params*. # # See `ART::Generator::Interface#generate`. def generate_url(route : String, params : Hash(String, _) = Hash(String, String?).new, reference_type : ART::Generator::ReferenceType = :absolute_path) : String # TODO: Make this type leverage a service locator for these common types. ADI.container.router.generate route, params.transform_values(&.to_s.as(String?)), reference_type end # Generates a URL to the provided *route* with the provided *params*. # # See `ART::Generator::Interface#generate`. def generate_url(route : String, reference_type : ART::Generator::ReferenceType = :absolute_path, **params) self.generate_url route, params.to_h.transform_keys(&.to_s), reference_type end # Returns an `AHTTP::RedirectResponse` to the provided *route* with the provided *params*. # # ``` # require "athena" # # class ExampleController < ATH::Controller # # Define a route to redirect to, explicitly naming this route `add`. # # The default route name is controller + method down snake-cased; e.x. `example_controller_add`. # @[ARTA::Get("/add/{value1}/{value2}", name: "add")] # def add(value1 : Int32, value2 : Int32, negative : Bool = false) : Int32 # sum = value1 + value2 # negative ? -sum : sum # end # # # Define a route that redirects to the `add` route with fixed parameters. # @[ARTA::Get("/")] # def redirect : AHTTP::RedirectResponse # self.redirect_to_route "add", {"value1" => 8, "value2" => 2} # end # end # # ATH.run # # # GET / # => 10 # ``` def redirect_to_route(route : String, params : Hash(String, _) = Hash(String, String?).new, status : ::HTTP::Status = :found) : AHTTP::RedirectResponse self.redirect self.generate_url(route, params), status end # Returns an `AHTTP::RedirectResponse` to the provided *route* with the provided *params*. # # ``` # require "athena" # # class ExampleController < ATH::Controller # # Define a route to redirect to, explicitly naming this route `add`. # # The default route name is controller + method down snake-cased; e.x. `example_controller_add`. # @[ARTA::Get("/add/{value1}/{value2}", name: "add")] # def add(value1 : Int32, value2 : Int32, negative : Bool = false) : Int32 # sum = value1 + value2 # negative ? -sum : sum # end # # # Define a route that redirects to the `add` route with fixed parameters. # @[ARTA::Get("/")] # def redirect : AHTTP::RedirectResponse # self.redirect_to_route "add", value1: 8, value2: 2 # end # end # # ATH.run # # # GET / # => 10 # ``` def redirect_to_route(route : String, status : ::HTTP::Status = :found, **params) : AHTTP::RedirectResponse self.redirect_to_route route, params.to_h.transform_keys(&.to_s.as(String)), status end # Returns an `AHTTP::RedirectResponse` to the provided *url*, optionally with the provided *status*. # # ``` # class ExampleController < ATH::Controller # @[ARTA::Get("redirect/google")] # def redirect_to_google : AHTTP::RedirectResponse # self.redirect "https://google.com" # end # end # ``` def redirect(url : String | Path, status : ::HTTP::Status = ::HTTP::Status::FOUND) : AHTTP::RedirectResponse AHTTP::RedirectResponse.new url, status end # Returns an `ATH::View` that'll redirect to the provided *url*, optionally with the provided *status* and *headers*. # # Is essentially the same as `#redirect`, but invokes the [view](/getting_started/middleware#4-view-event) layer. def redirect_view(url : String, status : ::HTTP::Status = ::HTTP::Status::FOUND, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ATH::View ATH::View.create_redirect url, status, headers end # Returns an `ATH::View` that'll redirect to the provided *route*, optionally with the provided *params*, *status*, and *headers*. # # Is essentially the same as `#redirect_to_route`, but invokes the [view](/getting_started/middleware#4-view-event) layer. def route_redirect_view(route : String, params : Hash(String, _) = Hash(String, String?).new, status : ::HTTP::Status = ::HTTP::Status::CREATED, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ATH::View ATH::View.create_route_redirect route, params end # Returns an `ATH::View` with the provided *data*, and optionally *status* and *headers*. # # ``` # @[ARTA::Get("/{name}")] # def say_hello(name : String) : ATH::View(NamedTuple(greeting: String)) # self.view({greeting: "Hello #{name}"}, :im_a_teapot) # end # ``` def view(data = nil, status : ::HTTP::Status? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ATH::View ATH::View.new data, status, headers end {% begin %} {% for method in ["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT", "LINK", "UNLINK"] %} # Helper DSL macro for creating `{{method.id}}` actions. # # The first argument is the path that the action should handle; which maps to path on the HTTP method annotation. # The second argument is a variable amount of arguments with a syntax similar to Crystal's `record`. # There are also a few optional named arguments that map to the corresponding field on the HTTP method annotation. # # The macro simply defines a method based on the options passed to it. Additional annotations, such as for query params # or a param converter can simply be added on top of the macro. # # ### Optional Named Arguments # - `return_type` - The return type to set for the action. Defaults to `String` if not provided. # - `constraints` - Any constraints that should be applied to the route. # # ### Example # # ``` # class ExampleController < ATH::Controller # {{method.downcase.id}} "values/{value1<\\d+>}/{value2<\\d+\\.\\d+>}", value1 : Int32, value2 : Float64 do # "Value1: #{value1} - Value2: #{value2}" # end # end # ``` macro {{method.downcase.id}}(path, *args, **named_args, &) @[ARTA::{{method.capitalize.id}}(path: \{{path}})] def {{method.downcase.id}}_\{{path.gsub(/\W/, "_").id}}(\{{args.splat}}) : \{{named_args[:return_type] || String}} \{{yield}} end end {% end %} {% end %} # Renders a template. # # Uses `ECR` to render the *template*, creating an `AHTTP::Response` with its rendered content and adding a `text/html` `content-type` header. # # The response can be modified further before returning it if needed. # # Variables used within the template must be defined within the action's body manually if they are not provided within the action's arguments. # # ``` # # greeting.ecr # Greetings, <%= name %>! # # # example_controller.cr # class ExampleController < ATH::Controller # @[ARTA::Get("/{name}")] # def greet(name : String) : AHTTP::Response # render "greeting.ecr" # end # end # # ATH.run # # # GET /Fred # => Greetings, Fred! # ``` macro render(template) Athena::HTTP::Response.new ECR.render({{template}}), headers: ::HTTP::Headers{"content-type" => "text/html"} end # Renders a template within a layout. # ``` # # layout.ecr #

Content:

<%= content -%> # # # greeting.ecr # Greetings, <%= name %>! # # # example_controller.cr # class ExampleController < ATH::Controller # @[ARTA::Get("/{name}")] # def greet(name : String) : AHTTP::Response # render "greeting.ecr", "layout.ecr" # end # end # # ATH.run # # # GET /Fred # =>

Content:

Greetings, Fred! # ``` macro render(template, layout) content = ECR.render {{template}} {{@type}}.render {{layout}} end end ================================================ FILE: src/components/framework/src/ext/clock.cr ================================================ @[ADI::Register(name: "clock", factory: "create")] @[ADI::AsAlias(ACLK::Interface)] # :nodoc: class Athena::Clock # :nodoc: # # There a better way to handle this? # By default the `ACLK::Interface` causes some infinite recursion due to this service being aliased to the interface def self.create : self new end end ================================================ FILE: src/components/framework/src/ext/console/application.cr ================================================ @[ADI::Register(public: true, name: "athena_console_application")] # Entrypoint for the `Athena::Console` integration. # # ``` # # Require your code # require "./main" # # # Run the application # ATH.run_console # ``` # # Checkout the [Getting Started](/getting_started/commands) docs for more information. class Athena::Framework::Console::Application < ACON::Application protected def initialize( command_loader : ACON::Loader::Interface? = nil, event_dispatcher : ACTR::EventDispatcher::Interface? = nil, eager_commands : Enumerable(ACON::Command)? = nil, ) super "Athena", ATH::VERSION self.command_loader = command_loader # TODO: set event dispatcher when that's implemented in the console component. eager_commands.try &.each do |cmd| self.add cmd end end end ================================================ FILE: src/components/framework/src/ext/console/compiler_passes/register_commands.cr ================================================ # Contains types related to the `Athena::Console` integration. module Athena::Framework::Console::CompilerPasses::RegisterCommands TAG = "athena.console.command" macro included macro finished {% verbatim do %} {% command_map = {} of Nil => Nil command_refs = {} of Nil => Nil # Services that are not configured via the annotation so must be registered eagerly. eager_service_ids = [] of Nil (TAG_HASH[ATH::Console::Command::TAG] || [] of Nil).each do |(service_id, _attributes)| metadata = SERVICE_HASH[service_id] # TODO: Any benefit in allowing commands to be configured via tags instead of the annotation? ann = metadata["class"].annotation ACONA::AsCommand if ann == nil SERVICE_HASH[public_service_id = "_#{service_id.id}_public"] = metadata service_id = public_service_id eager_service_ids << service_id.id else name = ann[0] || ann[:name] unless name ann.raise "Console command '#{metadata["class"]}' has an 'ACONA::AsCommand' annotation but is missing the commands's name. It was not provided as the first positional argument nor via the 'name' field." end aliases = name.split '|' aliases = aliases + (ann["aliases"] || [] of Nil) if ann["hidden"] && "" != aliases[0] aliases.unshift "" end command_name = aliases[0] aliases = aliases[1..] if is_hidden = "" == command_name command_name = aliases[0] aliases = aliases[1..] end command_map[command_name] = metadata["class"] command_refs[metadata["class"]] = service_id aliases.each do |a| command_map[a] = metadata["class"] end SERVICE_HASH[lazy_service_id = "_#{service_id.id}_lazy"] = { class: ACON::Commands::Lazy, tags: [] of Nil, generics: [] of Nil, calls: [] of Nil, public: false, referenced_services: [service_id], parameters: { name: {value: command_name}, aliases: {value: "#{aliases} of String".id}, description: {value: ann["description"] || ""}, hidden: {value: is_hidden}, command: {value: "->{ #{service_id.id}.as(ACON::Command) }".id}, }, } command_refs[metadata["class"]] = lazy_service_id end end SERVICE_HASH[loader_id = "athena_console_command_loader_container"] = { class: "Athena::Framework::Console::ContainerCommandLoaderLocator", tags: [] of Nil, generics: [] of Nil, calls: [] of Nil, public: false, referenced_services: command_refs.values, parameters: { container: {value: "self".id}, }, } SERVICE_HASH[command_loader_service_id = "athena_console_command_loader"] = { class: Athena::Framework::Console::ContainerCommandLoader, tags: [] of Nil, generics: [] of Nil, calls: [] of Nil, public: false, parameters: { command_map: {value: "#{command_map} of String => ACON::Command.class".id}, loader: {value: loader_id.id}, }, } SERVICE_HASH["athena_console_application"]["parameters"]["command_loader"]["value"] = command_loader_service_id.id SERVICE_HASH["athena_console_application"]["parameters"]["eager_commands"]["value"] = "#{eager_service_ids} of ACON::Command".id # Track eager commands as referenced services to ensure their getters are generated eager_service_ids.each do |sid| SERVICE_HASH["athena_console_application"]["referenced_services"] << sid end %} # :nodoc: # # TODO: Define some more generic way to create these struct ::Athena::Framework::Console::ContainerCommandLoaderLocator def initialize(@container : ::ADI::ServiceContainer); end {% for service_type, service_id in command_refs %} def get(service : {{service_type}}.class) : ACON::Command @container.{{service_id.id}} end {% end %} def get(service) : ACON::Command {% begin %} case service {% for service_type, service_id in command_refs %} when {{service_type}} then @container.{{service_id.id}} {% end %} else raise "BUG: Couldn't find correct service." end {% end %} end end {% end %} end end end ================================================ FILE: src/components/framework/src/ext/console/container_command_loader.cr ================================================ # :nodoc: class Athena::Framework::Console::ContainerCommandLoader include Athena::Console::Loader::Interface @command_map : Hash(String, ACON::Command.class) def initialize( @command_map : Hash(String, ACON::Command.class), @loader : ATH::Console::ContainerCommandLoaderLocator, ); end # :inherit: def get(name : String) : ACON::Command if !self.has? name raise ACON::Exception::CommandNotFound.new "Command '#{name}' does not exist." end @loader.get @command_map[name] end # :inherit: def has?(name : String) : Bool @command_map.has_key? name end # :inherit: def names : Array(String) @command_map.keys end end ================================================ FILE: src/components/framework/src/ext/console/descriptor/descriptor.cr ================================================ # :nodoc: abstract class Athena::Framework::Console::Descriptor include Athena::Console::Descriptor::Interface getter! output : ACON::Output::Interface abstract class FrameworkContext < ACON::Descriptor::Context getter output : ACON::Output::Interface def initialize( @output : ACON::Output::Interface, format : String = "txt", raw_text : Bool = false, raw_output : Bool? = nil, namespace : String? = nil, total_width : Int32? = nil, short : Bool = false, ) super format, raw_text, raw_output, namespace, total_width, short end end class RoutingContext < FrameworkContext getter name : String? getter? show_controllers : Bool def initialize( output : ACON::Output::Interface, @name : String? = nil, @show_controllers : Bool = false, format : String = "txt", raw_text : Bool = false, raw_output : Bool? = nil, namespace : String? = nil, total_width : Int32? = nil, short : Bool = false, ) super output, format, raw_text, raw_output, namespace, total_width, short end end class EventDispatcherContext < FrameworkContext getter event_class : ACTR::EventDispatcher::Event.class | Nil getter event_classes : Array(ACTR::EventDispatcher::Event.class)? def initialize( output : ACON::Output::Interface, @event_class : ACTR::EventDispatcher::Event.class | Nil = nil, @event_classes : Array(ACTR::EventDispatcher::Event.class)? = nil, format : String = "txt", raw_text : Bool = false, raw_output : Bool? = nil, namespace : String? = nil, total_width : Int32? = nil, short : Bool = false, ) super output, format, raw_text, raw_output, namespace, total_width, short end end def describe(output : ACON::Output::Interface, object : _, context : ACON::Descriptor::Context) : Nil @output = output self.describe object, context end protected abstract def describe(route : ART::Route, context : RoutingContext) : Nil protected abstract def describe(routes : ART::RouteCollection, context : RoutingContext) : Nil protected abstract def describe(event_dispatcher : AED::EventDispatcherInterface, context : EventDispatcherContext) : Nil protected def describe(obj : _, context : ACON::Descriptor::Context) : Nil raise "BUG: Failed to describe #{obj}" end protected def write(content : String, decorated : Bool = false) : Nil self.output.print content, output_type: decorated ? Athena::Console::Output::Type::NORMAL : Athena::Console::Output::Type::RAW end end ================================================ FILE: src/components/framework/src/ext/console/descriptor/text.cr ================================================ # :nodoc: class Athena::Framework::Console::Descriptor::Text < Athena::Framework::Console::Descriptor protected def describe(route : ART::Route, context : ATH::Console::Descriptor::RoutingContext) : Nil defaults = route.defaults headers = %w(Property Value) rows = [ ["Route Name", context.name], ["Path", route.path], ["Path Regex", route.compile.regex.source], ["Host", (host = route.host) ? host : "ANY"], ["Host Regex", !route.host.nil? ? route.compile.host_regex.try(&.source) : ""], ["Scheme", (schemes = route.schemes) ? schemes.join('|') : "ANY"], ["Methods", (methods = route.methods) ? methods.join('|') : "ANY"], ["Requirements", !(requirements = route.requirements).empty? ? self.format_router_config(requirements) : "NO CUSTOM"], ["Class", route.class.to_s], ["Defaults", self.format_router_config(defaults.to_h)], ] ACON::Helper::Table.new(self.output) .headers(headers) .rows(rows) .render end protected def describe(routes : ART::RouteCollection, context : ATH::Console::Descriptor::RoutingContext) : Nil show_controllers = context.show_controllers? headers = %w(Name Method Scheme Host Path) headers << "Controller" if show_controllers rows = routes.map do |name, route| controller = route.default "_controller", String row = [ name, (methods = route.methods) ? methods.join('|') : "ANY", (schemes = route.schemes) ? schemes.join('|') : "ANY", (host = route.host) ? host : "ANY", route.path, ] if show_controllers && controller row << controller end row end if output = context.output output.as(ACON::Style::Athena).table(headers, rows) else ACON::Helper::Table.new(self.output) .headers(headers) .rows(rows) .render end end private def format_router_config(config : Hash) : String return "" if config.empty? # Sort hash via key. config = config .to_a .sort! { |(n1, _), (n2, _)| n1 <=> n2 } .to_h String.build do |io| config.each do |key, value| io << '\n' io << key io << ':' << ' ' io << case value when Regex then value.source else value end end end.strip end protected def describe(event_dispatcher : AED::EventDispatcherInterface, context : EventDispatcherContext) : Nil # TODO: Support specific dispatcher services title = "Registered Listeners" if event = context.event_class title = "#{title} for the #{event} Event" listeners = event_dispatcher.listeners event else title = "#{title} Grouped by Event" listeners = if events = context.event_classes events.each_with_object({} of ACTR::EventDispatcher::Event.class => Array(AED::Callable)) do |ec, map| map[ec] = event_dispatcher.listeners ec end else event_dispatcher.listeners end end output = context.output.as ACON::Style::Athena output.title title self.render_event_listener_table output, event_dispatcher, listeners end private def render_event_listener_table( output : ACON::Style::Athena, event_dispatcher : AED::EventDispatcherInterface, event_listeners : Hash(ACTR::EventDispatcher::Event.class, Array(AED::Callable)), ) : Nil sorted_listeners = event_listeners .to_a .sort! { |(n1, _), (n2, _)| n1.to_s <=> n2.to_s } .to_h sorted_listeners.each do |event, el| output.section "#{event} event" self.render_event_listener_table output, event_dispatcher, el end end private def render_event_listener_table( output : ACON::Style::Athena, event_dispatcher : AED::EventDispatcherInterface, event_listeners : Array(AED::Callable), ) : Nil table_headers = %w(Order Callable Priority) table_rows = [] of Array(String | Int32) event_listeners.each_with_index do |callable, idx| table_rows << ["##{idx + 1}", callable.name, callable.priority] end output.table table_headers, table_rows end end ================================================ FILE: src/components/framework/src/ext/console/helper/descriptor_helper.cr ================================================ # :nodoc: class Athena::Framework::Console::Helper::Descriptor < Athena::Console::Helper::Descriptor def initialize self.register "txt", ATH::Console::Descriptor::Text.new end end ================================================ FILE: src/components/framework/src/ext/console.cr ================================================ # :nodoc: module Athena::Framework::Console::Command TAG = "athena.console.command" end require "./console/**" @[ADI::Autoconfigure(tags: [ATH::Console::Command::TAG])] abstract class ACON::Command; end ================================================ FILE: src/components/framework/src/ext/event_dispatcher.cr ================================================ @[ADI::Register(name: "event_dispatcher", public: true)] @[ADI::AsAlias(AED::EventDispatcherInterface)] @[ADI::AsAlias(ACTR::EventDispatcher::Interface)] class AED::EventDispatcher; end # :nodoc: module Athena::Framework::EventDispatcher::CompilerPasses::RegisterEventListenersPass macro included macro finished {% verbatim do %} {% event_dispatcher_service = SERVICE_HASH["event_dispatcher"] SERVICE_HASH.each do |service_id, definition| # Include types with the annotation applied to class methods for proper error handling. if (klass = definition["class"]).is_a?(TypeNode) && ( klass.class.methods.any?(&.annotation AEDA::AsEventListener) || klass.methods.any?(&.annotation AEDA::AsEventListener) ) event_dispatcher_service["calls"] << {"listener", {service_id.id}} end end %} {% end %} end end end ================================================ FILE: src/components/framework/src/ext/http.cr ================================================ @[ADI::Register(name: "request_store", public: true)] class Athena::HTTP::RequestStore; end ================================================ FILE: src/components/framework/src/ext/http_kernel.cr ================================================ @[ADI::Register(name: "athena_http_kernel", public: true)] struct Athena::HTTPKernel::HTTPKernel; end @[ADI::Register] struct Athena::HTTPKernel::Listeners::Routing; end @[ADI::Register] struct Athena::HTTPKernel::Listeners::Error; end @[ADI::Register] @[ADI::AsAlias] class Athena::HTTPKernel::ActionResolver; end ADI.bind value_resolvers : Array(Athena::HTTPKernel::Controller::ValueResolvers::Interface), "!athena.controller.value_resolver" @[ADI::Register(name: "parameter_resolver_request_attribute", tags: [{name: ATHR::Interface::TAG, priority: 100}])] struct Athena::HTTPKernel::Controller::ValueResolvers::RequestAttribute; end @[ADI::Register(name: "parameter_resolver_request", tags: [{name: ATHR::Interface::TAG, priority: 50}])] struct Athena::HTTPKernel::Controller::ValueResolvers::Request; end @[ADI::Register(tags: [{name: ATHR::Interface::TAG, priority: -100}])] struct Athena::HTTPKernel::Controller::ValueResolvers::DefaultValue; end @[ADI::Register] @[ADI::AsAlias] struct Athena::HTTPKernel::Controller::ArgumentResolver; end @[ADI::Register(_debug: "%framework.debug%")] @[ADI::AsAlias(AHK::ErrorRendererInterface)] struct Athena::HTTPKernel::ErrorRenderer; end ================================================ FILE: src/components/framework/src/ext/routing/annotation_route_loader.cr ================================================ # :nodoc: # # Loads and caches a `ART::RouteCollection` from `ART::Controllers` as well as a mapping of route names to `ATH::Action`s. module Athena::Framework::Routing::AnnotationRouteLoader class_getter route_collection : ART::RouteCollection do populate_collection end # :nodoc: # # Abstracts the logic to create the `ART::RouteCollection` such that it can be tested in a more performant way. macro populate_collection(base = nil) collection = ART::RouteCollection.new {% begin %} {% for klass, c_idx in (base ? [base.resolve] : ATH::Controller.all_subclasses.reject &.abstract?) %} # Define global vars derived from the controller data. {% methods = klass.methods.select { |m| m.annotation(ARTA::Get) || m.annotation(ARTA::Post) || m.annotation(ARTA::Put) || m.annotation(ARTA::Delete) || m.annotation(ARTA::Patch) || m.annotation(ARTA::Link) || m.annotation(ARTA::Unlink) || m.annotation(ARTA::Head) || m.annotation(ARTA::Route) } class_actions = klass.class.methods.select { |m| m.annotation(ARTA::Get) || m.annotation(ARTA::Post) || m.annotation(ARTA::Put) || m.annotation(ARTA::Delete) || m.annotation(ARTA::Patch) || m.annotation(ARTA::Link) || m.annotation(ARTA::Unlink) || m.annotation(ARTA::Head) || m.annotation(ARTA::Route) } # Raise compile time error if a route is defined as a class method. unless class_actions.empty? class_actions.first.raise "Routes can only be defined as instance methods. Did you mean '#{klass.name}##{class_actions.first.name}'?" end globals = { path: "", localized_paths: nil, requirements: {} of Nil => Nil, defaults: {} of Nil => Nil, schemes: [] of Nil, methods: [] of Nil, host: nil, condition: nil, name: nil, priority: 0, } if controller_ann = klass.annotation ARTA::Route if (ann_path = (controller_ann[:path] || controller_ann[0])) && ann_path.is_a? HashLiteral globals[:localized_paths] = ann_path elsif ann_path != nil ann_path = ann_path.resolve if ann_path.is_a?(Path) if !ann_path.is_a?(StringLiteral) && !ann_path.is_a?(HashLiteral) ann_path.raise "Route action '#{klass.name}' expects a 'StringLiteral | HashLiteral(StringLiteral, StringLiteral)' for its 'ARTA::Route#path' field, but got a '#{ann_path.class_name.id}'." end globals[:path] = ann_path end if (value = controller_ann[:defaults]) != nil unless value.is_a? HashLiteral value.raise "Route action '#{klass.name}' expects a 'HashLiteral(StringLiteral, _)' for its 'ARTA::Route#defaults' field, but got a '#{value.class_name.id}'." end globals[:defaults] = value end if (value = controller_ann[:locale]) != nil unless value.is_a? StringLiteral value.raise "Route action '#{klass.name}' expects a 'StringLiteral' for its 'ARTA::Route#locale' field, but got a '#{value.class_name.id}'." end globals[:defaults]["_locale"] = value end if (value = controller_ann[:format]) != nil unless value.is_a? StringLiteral value.raise "Route action '#{klass.name}' expects a 'StringLiteral' for its 'ARTA::Route#format' field, but got a '#{value.class_name.id}'." end globals[:defaults]["_format"] = value end if controller_ann[:stateless] != nil value = controller_ann[:stateless] unless value.is_a? BoolLiteral value.raise "Route action '#{klass.name}' expects a 'BoolLiteral' for its 'ARTA::Route#stateless' field, but got a '#{value.class_name.id}'." end globals[:defaults]["_stateless"] = value end if (value = controller_ann[:name]) != nil unless value.is_a? StringLiteral value.raise "Route action '#{klass.name}' expects a 'StringLiteral' for its 'ARTA::Route#name' field, but got a '#{value.class_name.id}'." end globals[:name] = value end if (value = controller_ann[:requirements]) != nil unless value.is_a? HashLiteral value.raise "Route action '#{klass.name}' expects a 'HashLiteral(StringLiteral, StringLiteral | RegexLiteral)' for its 'ARTA::Route#requirements' field, but got a '#{value.class_name.id}'." end globals[:requirements] = value end if (value = controller_ann[:schemes]) != nil if !value.is_a?(StringLiteral) && !value.is_a?(ArrayLiteral) && !value.is_a?(TupleLiteral) value.raise "Route action '#{klass.name}' expects a 'StringLiteral | Enumerable(StringLiteral)' for its 'ARTA::Route#schemes' field, but got a '#{value.class_name.id}'." end globals[:schemes] = value end if (value = controller_ann[:methods]) != nil if !value.is_a?(StringLiteral) && !value.is_a?(ArrayLiteral) && !value.is_a?(TupleLiteral) value.raise "Route action '#{klass.name}' expects a 'StringLiteral | Enumerable(StringLiteral)' for its 'ARTA::Route#methods' field, but got a '#{value.class_name.id}'." end globals[:methods] = value end if (value = controller_ann[:host]) != nil if !value.is_a?(StringLiteral) && !value.is_a?(RegexLiteral) value.raise "Route action '#{klass.name}' expects a 'StringLiteral | RegexLiteral' for its 'ARTA::Route#host' field, but got a '#{value.class_name.id}'." end globals[:host] = value end if (value = controller_ann[:condition]) != nil if !value.is_a?(Call) || value.receiver.resolve != ART::Route::Condition value.raise "Route action '#{klass.name}' expects an 'ART::Route::Condition' for its 'ARTA::Route#condition' field, but got a '#{value.class_name.id}'." end globals[:condition] = value end if (value = controller_ann[:priority]) != nil if !value.is_a?(NumberLiteral) value.raise "Route action '#{klass.name}' expects a 'NumberLiteral' for its 'ARTA::Route#priority' field, but got a '#{value.class_name.id}'." end globals[:priority] = value end end %} %collection{c_idx} = ART::RouteCollection.new # Build out the routes {% for m in methods %} # Raise compile time error if the action doesn't have a return type. {% if m.return_type.is_a? Nop %} {% m.raise "Route action return type must be set for '#{klass.name}##{m.name}'." %} {% end %} # Determine routes that this method should handle {% routes_for_method = [] of Nil # Tuple(Annotation, Array(String)) # Numerical index used to make the route name more unique if no more explicit name was provided default_route_index = 0 m.annotations(ARTA::Route).each do |a| methods = a[:methods] || [] of Nil if !methods.is_a?(StringLiteral) && !methods.is_a?(ArrayLiteral) && !methods.is_a?(TupleLiteral) a.raise "Route action '#{klass.name}##{m.name}' expects a 'StringLiteral | ArrayLiteral | TupleLiteral' for its 'ARTA::Route#methods' field, but got a '#{methods.class_name.id}'." end methods = methods.is_a?(StringLiteral) ? [methods] : methods routes_for_method << {a, methods} end # Set the route_def and method(s) based on annotation. { {ARTA::Get, ["GET"]}, {ARTA::Post, ["POST"]}, {ARTA::Put, ["PUT"]}, {ARTA::Patch, ["PATCH"]}, {ARTA::Delete, ["DELETE"]}, {ARTA::Link, ["LINK"]}, {ARTA::Unlink, ["UNLINK"]}, {ARTA::Head, ["HEAD"]}, }.each do |(t, methods)| m.annotations(t).each do |a| routes_for_method << {a, methods} end end %} {% for route_info in routes_for_method %} {% route_def = route_info[0] methods = route_info[1] %} {% parameters = [] of Nil annotation_configurations = {} of Nil => Nil parameter_annotation_configuration_map = {} of Nil => Nil # Logic for creating the `ATH::Action` instances: arg_types = m.args.map &.restriction arg_names = m.args.map &.name.stringify # Disallow `methods` field when _NOT_ using `ARTA::Route`. if !m.annotation(ARTA::Route) && route_def[:methods] != nil route_def.raise "Route action '#{klass.name}##{m.name}' cannot change the required methods when _NOT_ using the 'ARTA::Route' annotation." end # Process controller action parameters. m.args.each do |arg| parameter_annotation_configurations = {} of Nil => Nil # Process custom annotation types ADI::CUSTOM_ANNOTATIONS.each do |ann_class| annotations = [] of Nil (arg.annotations ann_class.resolve).each do |ann| # See if this annotation relates to a typed resolver interface, # checking the namespace the configuration annotation was defined in for the interface. resolver = ATH::Controller::ValueResolvers::Interface::ANNOTATION_RESOLVER_MAP[ann_class.id] if resolver && (interface = resolver.resolve.ancestors.find &.<=(ATHR::Interface::Typed)) supported_types = interface.type_vars.first.type_vars unless supported_types.any? { |t| arg.restriction.resolve <= t.resolve } arg.raise %(The annotation '#{ann}' cannot be applied to '#{klass.name}##{m.name}:#{arg.name} : #{arg.restriction}' since the '#{resolver}' resolver only supports parameters of type '#{supported_types.join(" | ").id}'.) end end annotations << "#{ann_class}Configuration.new(#{ann.args.empty? ? "".id : "#{ann.args.splat},".id}#{ann.named_args.double_splat})".id end parameter_annotation_configurations[ann_class.resolve] = "(#{annotations} of ADI::AnnotationConfigurations::ConfigurationBase)".id unless annotations.empty? end if arg.restriction.is_a? Nop arg.raise "Route action parameter '#{klass.name}##{m.name}:#{arg.name}' must have a type restriction." end parameters << %(AHK::Controller::ParameterMetadata(#{arg.restriction}).new( #{arg.name.stringify}, #{!arg.default_value.is_a? Nop}, #{arg.default_value.is_a?(Nop) ? nil : arg.default_value}, )).id parameter_annotation_configuration_map[arg.name.stringify] = %(ADI::AnnotationConfigurations.new(#{parameter_annotation_configurations} of ADI::AnnotationConfigurations::Classes => Array(ADI::AnnotationConfigurations::ConfigurationBase))).id end # Process custom annotation types ADI::CUSTOM_ANNOTATIONS.each do |ann_class| ann_class = ann_class.resolve annotations = [] of Nil (klass.annotations(ann_class) + m.annotations(ann_class)).each do |ann| annotations << "#{ann_class}Configuration.new(#{ann.args.empty? ? "".id : "#{ann.args.splat},".id}#{ann.named_args.double_splat})".id end annotation_configurations[ann_class] = "(#{annotations} of ADI::AnnotationConfigurations::ConfigurationBase)".id unless annotations.empty? end # Setup the `ATH::Action` and set the `_controller` default so future logic knows which method it should handle it by default. route_name = if (value = route_def[:name]) != nil if !value.is_a?(StringLiteral) value.raise "Route action '#{klass.name}##{m.name}' expects a 'StringLiteral' for its '#{route_def.name}#name' field, but got a '#{value.class_name.id}'." end value else # MyApp::UserController#new_user # => my_app_user_controller_new_user name = "#{klass.name.stringify.split("::").join('_').underscore.downcase.id}_#{m.name.id}" # If more than one route is making use of this action, make the name unique if default_route_index > 0 name += "_#{default_route_index}" end default_route_index += 1 name end if globals_name = globals[:name] route_name = "#{globals_name.id}_#{route_name.id}" end globals[:defaults]["_controller"] = action_name = "#{klass.name}##{m.name}" %} {% unless annotation_configurations.empty? %} ::ATH::AnnotationResolver::ACTION_ANNOTATIONS[{{action_name}}] = ADI::AnnotationConfigurations.new({{annotation_configurations}} of ADI::AnnotationConfigurations::Classes => Array(ADI::AnnotationConfigurations::ConfigurationBase)) {% end %} {% unless parameter_annotation_configuration_map.empty? %} ::ATH::AnnotationResolver::ACTION_PARAMETER_ANNOTATIONS[{{action_name}}] = {{parameter_annotation_configuration_map}} of String => ADI::AnnotationConfigurations {% end %} %action{action_name} = AHK::Action.new( action: Proc({{arg_types.empty? ? "typeof(Tuple.new)".id : "Tuple(#{arg_types.splat})".id}}, {{m.return_type}}).new do |arguments| # If the controller is not registered as a service, simply new one up, otherwise fetch it directly from the SC. {% if klass.annotation(ADI::Register) %} %instance = ADI.container.get(::{{klass.id}}) {% else %} %instance = ::{{klass.id}}.new {% end %} %instance.{{m.name.id}} *arguments end, parameters: {{parameters.empty? ? "Tuple.new".id : "{#{parameters.splat}}".id}}, _return_type: {{m.return_type}}, ) {% paths = {} of Nil => Nil defaults = {} of Nil => Nil requirements = {} of Nil => Nil # Resolve `ART::Route` data from the route annotation and globals. globals[:defaults].each { |k, v| defaults[k] = v } globals[:requirements].each { |k, v| requirements[k] = v } if (value = route_def[:locale]) != nil unless value.is_a? StringLiteral value.raise "Route action '#{klass.name}##{m.name}' expects a 'StringLiteral' for its '#{route_def.name}#locale' field, but got a '#{value.class_name.id}'." end defaults["_locale"] = value end if (value = route_def[:format]) != nil unless value.is_a? StringLiteral value.raise "Route action '#{klass.name}##{m.name}' expects a 'StringLiteral' for its '#{route_def.name}#format' field, but got a '#{value.class_name.id}'." end defaults["_format"] = value end if route_def[:stateless] != nil value = route_def[:stateless] unless value.is_a? BoolLiteral value.raise "Route action '#{klass.name}##{m.name}' expects a 'BoolLiteral' for its '#{route_def.name}#stateless' field, but got a '#{value.class_name.id}'." end defaults["_stateless"] = value end if ann_defaults = route_def[:defaults] unless ann_defaults.is_a? HashLiteral ann_defaults.raise "Route action '#{klass.name}##{m.name}' expects a 'HashLiteral(StringLiteral, _)' for its '#{route_def.name}#defaults' field, but got a '#{ann_defaults.class_name.id}'." end ann_defaults.each { |k, v| defaults[k] = v } end if ann_requirements = route_def[:requirements] unless ann_requirements.is_a? HashLiteral ann_requirements.raise "Route action '#{klass.name}##{m.name}' expects a 'HashLiteral(StringLiteral, StringLiteral | RegexLiteral)' for its '#{route_def.name}#requirements' field, but got a '#{ann_requirements.class_name.id}'." end ann_requirements.each do |k, v| requirements[k] = if v.is_a?(StringLiteral) || v.is_a?(RegexLiteral) v else "#{v}.to_s".id end end end if (value = route_def[:host]) != nil if !value.is_a?(StringLiteral) && !value.is_a?(RegexLiteral) value.raise "Route action '#{klass.name}##{m.name}' expects a 'StringLiteral | RegexLiteral' for its '#{route_def.name}#host' field, but got a '#{value.class_name.id}'." end end if (value = route_def[:priority]) != nil if !value.is_a?(NumberLiteral) value.raise "Route action '#{klass.name}##{m.name}' expects a 'NumberLiteral' for its '#{route_def.name}#priority' field, but got a '#{value.class_name.id}'." end end if (value = route_def[:condition]) != nil if !value.is_a?(Call) || value.receiver.resolve != ART::Route::Condition value.raise "Route action '#{klass.name}##{m.name}' expects an 'ART::Route::Condition' for its '#{route_def.name}#condition' field, but got a '#{value.class_name.id}'." end end schemes = (globals[:schemes] + (route_def[:schemes] || [] of Nil)).uniq methods = (globals[:methods] + methods).uniq priority = route_def[:priority] || globals[:priority] host = route_def[:host] || globals[:host] condition = route_def[:condition] || globals[:condition] priority = route_def[:priority] || globals[:priority] unless path = route_def[:localized_paths] || route_def[0] || route_def[:path] m.raise "Route action '#{klass.name}##{m.name}' is missing its path." end path = path.resolve if path.is_a?(Path) if !path.is_a?(StringLiteral) && !path.is_a?(HashLiteral) path.raise "Route action '#{klass.name}##{m.name}' expects a 'StringLiteral | HashLiteral(StringLiteral, StringLiteral)' for its '#{route_def.name}#path' field, but got a '#{path.class_name.id}'." end prefix = globals[:localized_paths] || globals[:path] # Process path/prefix values to a hash of paths that should be created. if path.is_a? HashLiteral if !prefix.is_a? HashLiteral path.each do |locale, locale_path| paths[locale] = if !locale_path.empty? && !locale_path.starts_with?('/') "#{prefix.id}/#{locale_path.id}" else "#{prefix.id}#{locale_path.id}" end end elsif !(missing = prefix.keys.reject { |k| path[k] }).empty? m.raise "Route action '#{klass.name}##{m.name}' is missing paths for locale(s) '#{missing.join(",").id}'." else path.each do |locale, locale_path| if prefix[locale] == nil m.raise "Route action '#{klass.name}##{m.name}' is missing a corresponding route prefix for the '#{locale.id}' locale." end paths[locale] = if !locale_path.empty? && !locale_path.starts_with?('/') "#{prefix[locale].id}/#{locale_path.id}" else "#{prefix[locale].id}#{locale_path.id}" end end end elsif prefix.is_a? HashLiteral prefix.each do |locale, locale_prefix| paths[locale] = if !path.empty? && !path.starts_with?('/') "#{locale_prefix.id}/#{path.id}" else "#{locale_prefix.id}#{path.id}" end end else # Normalize non empty route specific paths so they always start with `/`. paths["_default"] = if !path.empty? && !path.starts_with?('/') "#{prefix.id}/#{path.id}" else "#{prefix.id}#{path.id}" end end m.args.each do |arg| paths.each do |_, pth| pth.split('/').each do |p| if p.starts_with?("{#{arg.name}") && defaults[arg.name.stringify] == nil && !arg.default_value.is_a?(Nop) && p =~ /\{\w+(?:<.*?>)?\}/ defaults[arg.name.stringify] = arg.default_value end end end end %} {% for locale, path in paths %} {% r_name = route_name r_defaults = defaults r_requirements = requirements if locale != "_default" r_defaults["_locale"] = locale r_requirements["_locale"] = "Regex.escape(#{locale})".id r_defaults["_canonical_route"] = r_name r_name = "#{route_name.id}.#{locale.id}" end %} %route{r_name} = ART::Route.new( path: {{path}}, defaults: {{r_defaults.empty? ? "ART::Parameters.new".id : r_defaults}}, requirements: {{r_requirements}} of String => Regex | String, host: {{host}}, schemes: {{schemes.empty? ? nil : schemes}}, methods: {{methods.empty? ? nil : methods}}, condition: {{condition}} ) %route{r_name}.set_default "_action", %action{action_name} %collection{c_idx}.add({{r_name}}, %route{r_name}, {{priority}}) {% end %} {% end %} {% end %} collection.add %collection{c_idx} {% end %} {% end %} ART.compile collection collection end end ================================================ FILE: src/components/framework/src/ext/routing/redirectable_url_matcher.cr ================================================ # :nodoc: class Athena::Framework::Routing::RedirectableURLMatcher < Athena::Routing::Matcher::URLMatcher include ART::Matcher::RedirectableURLMatcherInterface def redirect(path : String, route : String, scheme : String? = nil) : ART::Parameters? ART::Parameters.new({ "_controller" => "Athena::Framework::Controller::Redirect#redirect_url", "_action" => AHK::Action.new( action: Proc(Tuple(AHTTP::Request, String, Bool, String?, Int32?, Int32?, Bool), AHTTP::RedirectResponse).new do |arguments| ADI.container.get(Athena::Framework::Controller::Redirect).redirect_url *arguments end, parameters: { AHK::Controller::ParameterMetadata(AHTTP::Request).new("request"), AHK::Controller::ParameterMetadata(String).new("path"), AHK::Controller::ParameterMetadata(Bool).new("permanent", true, false), AHK::Controller::ParameterMetadata(String?).new("scheme", true, nil), AHK::Controller::ParameterMetadata(Int32?).new("http_port", true, nil), AHK::Controller::ParameterMetadata(Int32?).new("https_port", true, nil), AHK::Controller::ParameterMetadata(Bool).new("keep_request_method", true, false), }, _return_type: AHTTP::RedirectResponse, ), "_route" => route, "path" => path, "permanent" => "true", "scheme" => scheme, }) end end ================================================ FILE: src/components/framework/src/ext/routing/router.cr ================================================ # :nodoc: @[ADI::AsAlias(ART::Generator::Interface)] @[ADI::AsAlias(ART::Matcher::URLMatcherInterface)] @[ADI::AsAlias(ART::RouterInterface)] @[ADI::AsAlias(ART::RequestContextAwareInterface)] @[ADI::AsAlias("router", public: true)] class Athena::Framework::Routing::Router < Athena::Routing::Router getter matcher : ART::Matcher::URLMatcherInterface do ATH::Routing::RedirectableURLMatcher.new(@context) end def initialize( default_locale : String? = nil, strict_requirements : Bool? = true, request_context : ART::RequestContext? = nil, ) super( ATH::Routing::AnnotationRouteLoader.route_collection, default_locale, strict_requirements, request_context, ) end end ================================================ FILE: src/components/framework/src/ext/routing.cr ================================================ require "athena-routing" # :nodoc: module Athena::Framework::Routing; end require "./routing/*" ================================================ FILE: src/components/framework/src/ext/serializer.cr ================================================ require "athena-serializer" @[ADI::Register] @[ADI::AsAlias] struct Athena::Serializer::Serializer; end @[ADI::Register] struct Athena::Serializer::Navigators::NavigatorFactory; end @[ADI::Register] struct Athena::Serializer::InstantiateObjectConstructor; end ================================================ FILE: src/components/framework/src/ext/validator/validation_failed_exception.cr ================================================ # Wraps an `AVD::Violation::ConstraintViolationListInterface` as an `AHK::Exception::UnprocessableEntity`; exposing the violations within the response body. class Athena::Validator::Exception::ValidationFailed < AHK::Exception::UnprocessableEntity getter violations : Athena::Validator::Violation::ConstraintViolationListInterface def initialize(violations : AVD::Violation::ConstraintViolationInterface | AVD::Violation::ConstraintViolationListInterface, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) if violations.is_a? AVD::Violation::ConstraintViolationInterface violations = AVD::Violation::ConstraintViolationList.new [violations] end @violations = violations super "Validation failed", cause, headers end def to_json(builder : JSON::Builder) : Nil builder.object do builder.field "code", self.status_code builder.field "message", @message builder.field "errors" do @violations.to_json builder end end end end ================================================ FILE: src/components/framework/src/ext/validator.cr ================================================ require "athena-validator" require "./validator/validation_failed_exception" @[ADI::Register] @[ADI::AsAlias] class Athena::Validator::Validator::RecursiveValidator; end @[ADI::Autoconfigure(tags: ["athena.validator.constraint_validator"])] abstract class AVD::ServiceConstraintValidator; end @[ADI::Register] struct Athena::Validator::ConstraintValidatorFactory; end ADI.bind constraint_validators : Array(AVD::ServiceConstraintValidator), "!athena.validator.constraint_validator" ================================================ FILE: src/components/framework/src/file_parser.cr ================================================ # :nodoc: class Athena::Framework::FileParser # Store the tmp uploaded paths to use to validate `AHTTP::UploadedFile`s. @uploaded_files : Set(String) = Set(String).new protected class_getter default_temp_dir : String do temp_dir = Path.new Dir.tempdir, "athena" Dir.mkdir_p temp_dir temp_dir.to_s end def initialize( temp_dir : String?, @max_uploads : Int32, @max_file_size : Int64, ) @temp_dir = temp_dir || self.class.default_temp_dir end def parse(request : AHTTP::Request) : Nil uploaded_file_count = 0 ::HTTP::FormData.parse(request.request) do |part| case filename = part.filename when "" then next when .nil? request.attributes.set part.name, part.body.gets_to_end, String next end next if uploaded_file_count >= @max_uploads status : AHTTP::UploadedFile::Status = :ok size : Int64? = 0 temp_file = ::File.tempfile "file_upload.", nil, dir: @temp_dir do |file| size = self.copy_with_max part.body, file end file_path = temp_file.path if size.nil? status = :size_limit_exceeded temp_file.delete file_path = "" end if status.ok? @uploaded_files << file_path end request.files[part.name] << AHTTP::UploadedFile.new file_path, filename, part.headers["content-type"]?, status uploaded_file_count += 1 end end def clear : Nil @uploaded_files.each do |tmp_uploaded_file_path| ::File.delete? tmp_uploaded_file_path rescue ex Log.warn(exception: ex) { "Failed to cleanup temp file upload: '#{tmp_uploaded_file_path}'." } end end protected def uploaded_file?(path : String) : Bool @uploaded_files.includes? path end # Based off of https://github.com/crystal-lang/crystal/blob/54022594f84040c976634863ce5fac1b31a68048/src/io.cr#L1173 # but returns `nil` if more bytes than allowed were written. private def copy_with_max(src : IO, dest : IO) : Int64? buffer = uninitialized UInt8[IO::DEFAULT_BUFFER_SIZE] count = 0_i64 while (len = src.read(buffer.to_slice).to_i32) > 0 dest.write buffer.to_slice[0, len] count &+= len return if count > @max_file_size end count end end ================================================ FILE: src/components/framework/src/listeners/cors.cr ================================================ # 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. # See `ATH::Bundle::Schema::Cors` for information on configuring the listener. # # TIP: Set your [Log::Severity](https://crystal-lang.org/api/Log/Severity.html) to `TRACE` to help debug the listener. struct Athena::Framework::Listeners::CORS # :nodoc: struct Config getter? allow_credentials : Bool getter allow_origin : Array(String | Regex) getter allow_headers : Array(String) getter allow_methods : Array(String) getter expose_headers : Array(String) getter max_age : Int32 def initialize( @allow_credentials : Bool = false, allow_origin : Array(String | Regex) = Array(String | Regex).new, @allow_headers : Array(String) = [] of String, @allow_methods : Array(String) = Athena::Framework::Listeners::CORS::SAFELISTED_METHODS, @expose_headers : Array(String) = [] of String, @max_age : Int32 = 0, ) @allow_origin = allow_origin.map &.as String | Regex end end # The [CORS-safelisted request-headers](https://fetch.spec.whatwg.org/#cors-safelisted-request-header). SAFELISTED_HEADERS = [ "accept", "accept-language", "content-language", "content-type", "origin", ] # The [CORS-safelisted methods](https://fetch.spec.whatwg.org/#cors-safelisted-method). SAFELISTED_METHODS = [ "GET", "POST", "HEAD", ] # :nodoc: ALLOW_SET_ORIGIN = "athena.routing.cors.allow_set_origin" private WILDCARD = "*" private REQUEST_METHOD_HEADER = "access-control-request-method" private REQUEST_HEADERS_HEADER = "access-control-request-headers" private ALLOW_CREDENTIALS_HEADER = "access-control-allow-credentials" private ALLOW_HEADERS_HEADER = "access-control-allow-headers" private ALLOW_METHODS_HEADER = "access-control-allow-methods" private ALLOW_ORIGIN_HEADER = "access-control-allow-origin" private EXPOSE_HEADERS_HEADER = "access-control-expose-headers" private MAX_AGE_HEADER = "access-control-max-age" protected def initialize(@config : ATH::Listeners::CORS::Config = ATH::Listeners::CORS::Config.new); end @[AEDA::AsEventListener(priority: 250)] def on_request(event : AHK::Events::Request) : Nil request = event.request # Return early if there is no configuration. unless @config Log.trace { "#{self.class.name} is unconfigured, skipping CORS." } return end # Return early if not a CORS request. # TODO: optimize this by also checking if origin matches the request's host. unless request.headers.has_key? "origin" Log.trace { "Request does not have an 'origin' header, skipping CORS." } return end # If the request is a preflight, return the proper response. if request.method == "OPTIONS" && request.headers.has_key? REQUEST_METHOD_HEADER Log.trace { "Request is a pre-flight request, creating response." } return event.response = set_preflight_response event.request end unless check_origin event.request Log.trace { "Origin check failed." } return end Log.trace { "Origin is allowed, proceed with adding CORS response headers." } event.request.attributes.set ALLOW_SET_ORIGIN, true, Bool end @[AEDA::AsEventListener] def on_response(event : AHK::Events::Response) : Nil # Return early if the request shouldn't have CORS set. unless event.request.attributes.get? ALLOW_SET_ORIGIN Log.trace { "The origin is not allowed, skipping CORS response headers." } return end # Return early if there is no configuration. unless @config Log.trace { "#{self.class.name} is unconfigured, skipping CORS response headers." } return end origin = event.request.headers["origin"] Log.trace { "Setting '#{ALLOW_ORIGIN_HEADER}' to '#{origin}'." } # TODO: Add a configuration option to allow setting this explicitly event.response.headers[ALLOW_ORIGIN_HEADER] = origin if @config.allow_credentials? Log.trace { "Setting '#{ALLOW_CREDENTIALS_HEADER}' to 'true'." } event.response.headers[ALLOW_CREDENTIALS_HEADER] = "true" end unless @config.expose_headers.empty? headers = @config.expose_headers.join(", ") Log.trace { "Settings '#{EXPOSE_HEADERS_HEADER}' to '#{headers}'." } event.response.headers[EXPOSE_HEADERS_HEADER] = headers end end # Configures the given *response* for CORS preflight private def set_preflight_response(request : AHTTP::Request) : AHTTP::Response response = AHTTP::Response.new response.headers["vary"] = "origin" if @config.allow_credentials? Log.trace { "Setting '#{ALLOW_CREDENTIALS_HEADER}' response header to 'true'." } response.headers[ALLOW_CREDENTIALS_HEADER] = "true" end if @config.max_age > 0 max_age = @config.max_age.to_s Log.trace { "Setting '#{MAX_AGE_HEADER}' response header to '#{max_age}'." } response.headers[MAX_AGE_HEADER] = max_age end unless @config.allow_methods.empty? allow_methods = @config.allow_methods.join(", ") Log.trace { "Setting '#{ALLOW_METHODS_HEADER}' response header to '#{allow_methods}'." } response.headers[ALLOW_METHODS_HEADER] = allow_methods end unless @config.allow_headers.empty? headers : Array(String) = @config.allow_headers.includes?(WILDCARD) ? (request.headers[REQUEST_HEADERS_HEADER]?.try &.split(/,\ ?/) || [] of String) : @config.allow_headers unless headers.empty? allow_headers = headers.join(", ") Log.trace { "Setting '#{ALLOW_HEADERS_HEADER}' response header to '#{allow_headers}'." } response.headers[ALLOW_HEADERS_HEADER] = allow_headers end end unless check_origin request Log.trace { "Removing '#{ALLOW_ORIGIN_HEADER}' response header." } request.headers.delete ALLOW_ORIGIN_HEADER return response end origin = request.headers["origin"] Log.trace { "Setting '#{ALLOW_ORIGIN_HEADER}' response header to '#{origin}'." } response.headers[ALLOW_ORIGIN_HEADER] = origin unless @config.allow_methods.includes?(method = request.headers[REQUEST_METHOD_HEADER].upcase) Log.trace { "Method '#{method}' is not allowed." } response.status = :method_not_allowed return response end unless @config.allow_headers.includes? WILDCARD ((rh = request.headers[REQUEST_HEADERS_HEADER]?) ? rh.split(/,\ ?/) : [] of String).each do |header| next if SAFELISTED_HEADERS.includes? header next if @config.allow_headers.includes? header raise AHK::Exception::Forbidden.new "Unauthorized header: '#{header}'." end end response end private def check_origin(request : AHTTP::Request) : Bool origin = request.headers["origin"] if @config.allow_origin.includes?(WILDCARD) Log.trace { "Origin is a wildcard." } return true end # Use case equality in case an origin is a Regex @config.allow_origin.each do |ao| Log.trace { "Checking allowed origin '#{ao}' to origin '#{origin}'." } if ao === origin Log.trace { "Allowed origin '#{ao}' matches origin '#{origin}'." } return true end end Log.trace { "Origin '#{origin}' is not allowed." } false end end ================================================ FILE: src/components/framework/src/listeners/file.cr ================================================ # :nodoc: struct Athena::Framework::Listeners::File protected def initialize(@file_parser : ATH::FileParser); end @[AEDA::AsEventListener] def on_request(event : AHK::Events::Request) : Nil return unless event.request.headers["content-type"]?.try &.starts_with? "multipart/form-data" @file_parser.parse event.request end @[AEDA::AsEventListener] def on_terminate(event : AHK::Events::Terminate) : Nil @file_parser.clear end end ================================================ FILE: src/components/framework/src/listeners/format.cr ================================================ require "mime" # 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. # # [AHTTP::Request::FORMATS](/HTTP/Request/#Athena::HTTP::Request::FORMATS) is used to determine the related format from the request's `MIME` type. # # See the [Getting Started](/getting_started/routing#content-negotiation) docs for more information. struct Athena::Framework::Listeners::Format def initialize( @format_negotiator : ATH::View::FormatNegotiator, ); end @[AEDA::AsEventListener(priority: 34)] def on_request(event : AHK::Events::Request) : Nil request = event.request format = request.request_format nil if format.nil? accept = @format_negotiator.best "" if !accept.nil? && 0.0 < accept.quality format = request.format accept.header unless format.nil? request.attributes.set "media_type", accept.header, String end end end raise AHK::Exception::NotAcceptable.new "No matching accepted Response format could be determined." if format.nil? request.request_format = format rescue ex : AHK::Exception::StopFormatListener # ignore end end ================================================ FILE: src/components/framework/src/listeners/view.cr ================================================ class Athena::HTTPKernel::Action(ReturnType, ParameterTypeTuple, ParametersType) < Athena::HTTPKernel::ActionBase # This has to live here so the view is properly typed and not a union of all possible views. # Would be more ideal if we didn't have to monkey patch this in but :shrug:. protected def create_view(data : ReturnType) : ATH::View ATH::View(ReturnType).new data end protected def create_view(data : _) : NoReturn raise "BUG: Invoked wrong `create_view` overload." end end @[ADI::Register] # Listens on the `AHK::Events::View` event to convert a non `AHTTP::Response` into an `AHTTP::Response`. # Allows creating format agnostic controllers by allowing them to return format agnostic data that # is later used to render the content in the expected format. # # See the [Getting Started](/getting_started/routing#content-negotiation) docs for more information. struct Athena::Framework::Listeners::View def initialize( @view_handler : ATH::View::ViewHandlerInterface, @annotation_resolver : ATH::AnnotationResolver, ); end @[AEDA::AsEventListener(priority: 100)] def on_view(event : AHK::Events::View) : Nil request = event.request action = request.attributes.get "_action", AHK::ActionBase view = event.action_result unless view.is_a? ATH::View view = action.create_view view end if configuration = @annotation_resolver.action_annotations(request)[ATHA::View]? if (status = configuration.status) && (view.status.nil? || view.status.not_nil!.ok?) view.status = status end context = view.context if groups = configuration.serialization_groups if context_groups = context.groups context_groups.concat groups else context.groups = groups end end configuration.emit_nil.try do |emit_nil| context.emit_nil = emit_nil end end if view.format.nil? view.format = request.request_format end event.response = @view_handler.handle view, request end end ================================================ FILE: src/components/framework/src/logging.cr ================================================ require "log" module Athena::Framework Log = ::Log.for "athena.framework" end ================================================ FILE: src/components/framework/src/spec/abstract_browser.cr ================================================ # Simulates a browser to make requests to some destination. # # NOTE: Currently just acts as a client to make `HTTP` requests. This type exists to allow for introduction of other functionality in the future. abstract class Athena::Framework::Spec::AbstractBrowser @request : AHTTP::Request? @response : ::HTTP::Server::Response? # :nodoc: # # Makes a *request* and returns the response. abstract def do_request(request : AHTTP::Request) : ::HTTP::Server::Response def request : AHTTP::Request if request = @request return request end raise RuntimeError.new "The '#request' method must be called before a request is available." end def response : ::HTTP::Server::Response if response = @response return response end raise RuntimeError.new "The '#request' method must be called before a response is available." end # Makes an HTTP request with the provided *method*, at the provided *path*, with the provided *body* and/or *headers* and returns the resulting response. def request( method : String, path : String, headers : ::HTTP::Headers, body : String | Bytes | IO | Nil, ) : ::HTTP::Server::Response # At the moment this just calls into `do_request`. # Kept this as way allow for future expansion. self.request AHTTP::Request.new method, path, headers, body end # Makes an HTTP request with the provided *request*, returning the resulting response. def request(request : AHTTP::Request | ::HTTP::Request) : ::HTTP::Server::Response @request = AHTTP::Request.new request @response = self.do_request self.request end end ================================================ FILE: src/components/framework/src/spec/api_test_case.cr ================================================ require "./web_test_case" # A `WebTestCase` implementation with the intent of testing API controllers. # Can be extended to add additional application specific configuration, such as setting up an authenticated user to make the request as. # # ## Usage # # Say we want to test the following controller: # # ``` # class ExampleController < ATH::Controller # @[ARTA::Get("/add/{value1}/{value2}")] # def add(value1 : Int32, value2 : Int32, @[ATHA::MapQueryParameter] negative : Bool = false) : Int32 # sum = value1 + value2 # negative ? -sum : sum # end # end # ``` # # We can define a struct inheriting from `self` to implement our test logic: # # ``` # struct ExampleControllerTest < ATH::Spec::APITestCase # def test_add_positive : Nil # self.get("/add/5/3").body.should eq "8" # end # # def test_add_negative : Nil # self.get("/add/5/3?negative=true").body.should eq "-8" # end # end # ``` # # The `#request` method is used to make our requests to the API, then we run are assertions against the resulting `::HTTP::Server::Response`. # A key thing to point out is that there is no `::HTTP::Server` involved, thus resulting in more performant specs. # # TIP: Checkout the built in [expectations][Athena::Framework::Spec::Expectations::HTTP] to make testing easier. # # ATTENTION: Be sure to call `Athena::Spec.run_all` to your `spec_helper.cr` to ensure all test case instances are executed. # # ### Mocking External Dependencies # # The previous example was quite simple. However, most likely a controller is going to have dependencies on various other services; such as an API client to make requests to a third party API. # By default each test will be executed with the same services as it would normally, i.e. those requests to the third party API would actually be made. # To solve this we can create a mock implementation of the API client and make it so that implementation is injected when the test runs. # # ``` # # Create an example API client. # @[ADI::Register] # class APIClient # def fetch_latest_data : String # # Assume this method actually makes an `HTTP` request to get the latest data. # "DATA" # end # end # # # Define a mock implementation of our APIClient that does not make a request and just returns mock data. # class MockAPIClient < APIClient # def fetch_latest_data : String # # This could also be an instance variable that gets set when this mock is created. # "MOCK_DATA" # end # end # # # Enable our API client to be replaced in the service container. # class ADI::Spec::MockableServiceContainer # # Use the block version of the `property` macro to use our mocked client by default, while still allowing it to be replaced at runtime. # # # # The block version of `getter` could also be used if you don't need to set it at runtime. # # The `setter` macro could be also if you only want to allow replacing it at runtime. # property(api_client) { MockAPIClient.new } # end # # @[ADI::Register] # class ExampleServiceController < ATH::Controller # def initialize(@api_client : APIClient); end # # @[ARTA::Post("/sync")] # def sync_data : String # # Use the injected api client to get the latest data to sync. # data = @api_client.fetch_latest_data # # # ... # # data # end # end # # struct ExampleServiceControllerTest < ATH::Spec::APITestCase # def initialize # super # # # Our API client could also have been replaced at runtime; # # such as if you wanted provide it what data it should return on a test by test basis. # # self.client.container.api_client = MockAPIClient.new # end # # def test_sync_data : Nil # self.post("/sync").body.should eq %("MOCK_DATA") # end # end # ``` # # TIP: See `ADI::Spec::MockableServiceContainer` for more details on mocking services. # # Each `test_*` method has its own service container instance. # Any services that are mutated/replaced within the `initialize` method will affect all `test_*` methods. # However, services can also be mutated/replaced within specific `test_*` methods to scope it that particular test; # just be sure that you do it _before_ calling `#request`. abstract struct Athena::Framework::Spec::APITestCase < ATH::Spec::WebTestCase def initialize # Ensure each test method has a unique container. self.init_container super end # Returns a reference to the `AbstractBrowser` being used for the test. def client : ATH::Spec::HTTPBrowser @client.as(ATH::Spec::HTTPBrowser).not_nil! end # Makes a `DELETE` request to the provided *path*, optionally with the provided *headers*. def delete(path : String, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ::HTTP::Server::Response self.request "DELETE", path, headers: headers end # Makes a `GET` request to the provided *path*, optionally with the provided *headers*. def get(path : String, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ::HTTP::Server::Response self.request "GET", path, headers: headers end # Makes a `HEAD` request to the provided *path*, optionally with the provided *headers*. def head(path : String, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ::HTTP::Server::Response self.request "HEAD", path, headers: headers end # Makes a `LINK` request to the provided *path*, optionally with the provided *headers*. def link(path : String, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ::HTTP::Server::Response self.request "LINK", path, headers: headers end # Makes a `PATCH` request to the provided *path*, optionally with the provided *body* and *headers*. def patch(path : String, body : String | Bytes | IO | Nil = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ::HTTP::Server::Response self.request "PATCH", path, headers: headers end # Makes a `POST` request to the provided *path*, optionally with the provided *body* and *headers*. def post(path : String, body : String | Bytes | IO | Nil = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ::HTTP::Server::Response self.request "POST", path, body, headers end # Makes a `PUT` request to the provided *path*, optionally with the provided *body* and *headers*. def put(path : String, body : String | Bytes | IO | Nil = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ::HTTP::Server::Response self.request "PUT", path, body, headers end # Makes a `UNLINK` request to the provided *path*, optionally with the provided *headers*. def unlink(path : String, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ::HTTP::Server::Response self.request "UNLINK", path, headers: headers end # See `AbstractBrowser#request`. def request(method : String, path : String, body : String | Bytes | IO | Nil = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) : ::HTTP::Server::Response self.request AHTTP::Request.new method, path, headers, body end # :ditto: def request(request : ::HTTP::Request | AHTTP::Request) : ::HTTP::Server::Response self.client.request AHTTP::Request.new request end # Helper method to init the container. # Creates a new container instance and assigns it to the current fiber. protected def init_container : Nil Fiber.current.container = ADI::Spec::MockableServiceContainer.new end end ================================================ FILE: src/components/framework/src/spec/expectations/http.cr ================================================ require "./response/*" require "./request/*" # Provides expectation helper method for making assertions about the `AHTTP::Request` and/or `::HTTP::Server::Response` of a controller action. # For example asserting the response is successful, has a specific header/cookie (value), and/or if the request has an attribute with a specific value. # # ``` # struct ExampleControllerTest < ATH::Spec::APITestCase # def test_root : Nil # self.get "/" # # self.assert_response_is_successful # end # end # ``` # # Some expectations will also print more information upon failure to make it easier to understand _why_ it failed. # `#assert_response_is_successful` for example will include the response status, headers, and body as well as the exception that caused the failure if applicable. module Athena::Framework::Spec::Expectations::HTTP # Asserts the response returns with a [successful?](https://crystal-lang.org/api/HTTP/Status.html#success%3F%3ABool-instance-method) status code. def assert_response_is_successful(description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should Response::IsSuccessful.new(description), file: file, line: line end # Asserts the response returns with status of `422 Unprocessable Entity`. def assert_response_is_unprocessable(description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should Response::IsUnprocessable.new(description), file: file, line: line end # Asserts the response returns with a [redirection?](https://crystal-lang.org/api/HTTP/Status.html#redirection%3F%3ABool-instance-method) status code. # Optionally allows also asserting the `location` header is that of the provided *location*, # and/or the status is equal to the provided *status*. def assert_response_redirects(location : String? = nil, status : ::HTTP::Status | Int32 | Nil = nil, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should Response::IsRedirected.new(description), file: file, line: line self.response.should Response::HeaderEquals.new("location", location), file: file, line: line if location self.response.should Response::HasStatus.new(status), file: file, line: line if status end # Asserts the response has the same status as the one provided. def assert_response_has_status(status : ::HTTP::Status | Int32, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should Response::HasStatus.new(status.is_a?(Int32) ? ::HTTP::Status.from_value(status) : status, description), file: file, line: line end # Asserts the response has a header with the provided *name*. def assert_response_has_header(name : String, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should Response::HasHeader.new(name, description), file: file, line: line end # Asserts the response does not have a header with the provided *name*. def assert_response_not_has_header(name : String, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should_not Response::HasHeader.new(name, description), file: file, line: line end # Asserts the response has a cookie with the provided *name*, and optionally *path* and *domain*. def assert_response_has_cookie(name : String, path : String? = nil, domain : String? = nil, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should Response::HasCookie.new(name, path, domain, description), file: file, line: line end # Asserts the response does not have a cookie with the provided *name*, and optionally *path* and *domain*. def assert_response_not_has_cookie(name : String, path : String? = nil, domain : String? = nil, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should_not Response::HasCookie.new(name, path, domain, description), file: file, line: line end # Asserts the format of the response equals the provided *format*. def assert_response_format_equals(format : String, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should Response::FormatEquals.new(self.request, format, description), file: file, line: line end # Asserts the value of the header with the provided *name*, equals that of the provided *value*. def assert_response_header_equals(name : String, value : String, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should Response::HeaderEquals.new(name, value, description), file: file, line: line end # Asserts the value of the header with the provided *name*, does not equal that of the provided *value*. def assert_response_header_not_equals(name : String, value : String, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should_not Response::HeaderEquals.new(name, value, description), file: file, line: line end # Asserts the value of the cookie with the provided *name*, and optionally *path* and *domain*, equals that of the provided *value* def assert_cookie_has_value(name : String, value : String, path : String? = nil, domain : String? = nil, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.response.should Response::HasCookie.new(name, path, domain, description), file: file, line: line self.response.should Response::CookieValueEquals.new(name, value, path, domain, description), file: file, line: line end # Asserts the request attribute with the provided *name* equals the provided *value*. def assert_request_attribute_equals(name : String, value : _, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.request.should Request::AttributeEquals.new(name, value, description), file: file, line: line end # Asserts the request was matched against the route with the provided *name*. def assert_route_equals(name : String, parameters : Hash? = nil, description : String? = nil, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil self.request.should Request::AttributeEquals.new("_route", name, description), file: file, line: line parameters.try &.each do |k, v| self.request.should Request::AttributeEquals.new(k, v, description), file: file, line: line end end private abstract def client : AbstractBrowser? private def response : ::HTTP::Server::Response self.client.response end private def request : AHTTP::Request self.client.request end end ================================================ FILE: src/components/framework/src/spec/expectations/request/attribute_equals.cr ================================================ # :nodoc: struct Athena::Framework::Spec::Expectations::Request::AttributeEquals(T) @name : String @value : T @description : String? def initialize( @name : String, @value : T, @description : String? = nil, ); end def match(actual_value : AHTTP::Request) : Bool @value == actual_value.attributes.get?(@name, T) end def match(actual_value : _) : Bool false end def failure_message(actual_value : AHTTP::Request) : String String.build do |io| if desc = @description io << desc << '\n' << '\n' end io << "Failed asserting that the request has attribute '#{@name}' with value '#{@value}'." end end def negative_failure_message(actual_value : AHTTP::Request) : String String.build do |io| if desc = @description io << desc << '\n' << '\n' end io << "Failed asserting that the request does not have attribute '#{@name}' with value '#{@value}'." end end end ================================================ FILE: src/components/framework/src/spec/expectations/response/base.cr ================================================ # :nodoc: abstract struct Athena::Framework::Spec::Expectations::Response::Base @description : String? def initialize(@description : String? = nil); end abstract def match(actual_value : ::HTTP::Server::Response) : Bool abstract def failure_message : String abstract def negated_failure_message : String def match(actual_value : _) : Bool false end def failure_message(actual_value : ::HTTP::Server::Response) : String self.build_message actual_value, self.failure_message end def negative_failure_message(actual_value : ::HTTP::Server::Response) : String self.build_message actual_value, self.negated_failure_message end private def include_response? : Bool true end private def build_message(response : ::HTTP::Server::Response, message : String) : String String.build do |io| if desc = @description io << desc << '\n' << '\n' end io << "Failed asserting that the response #{message}#{self.include_response? ? ":\n#{response}" : "."}" if ("500" == response.headers["x-debug-exception-code"]?.presence) && (exception_message = response.headers["x-debug-exception-message"]?.presence) && (exception_file = response.headers["x-debug-exception-file"]?.presence) && (exception_class = response.headers["x-debug-exception-class"]?.presence) io << '\n' << '\n' io << "Caused By:\n" io << ' ' << ' ' URI.decode exception_message, io io << ' ' << '(' << exception_class << ')' << '\n' io << ' ' << ' ' << ' ' << ' ' << "from" << ' ' << exception_file end end end end ================================================ FILE: src/components/framework/src/spec/expectations/response/cookie_value_equals.cr ================================================ # :nodoc: struct Athena::Framework::Spec::Expectations::Response::CookieValueEquals < Athena::Framework::Spec::Expectations::Response::Base @name : String @value : String @path : String? @domain : String? def initialize( @name : String, @value : String, @path : String? = nil, @domain : String? = nil, description : String? = nil, ) super description end def match(actual_value : ::HTTP::Server::Response) : Bool return false unless cookie = actual_value.cookies[@name]? @path == cookie.path && @domain == cookie.domain && @value == cookie.value end private def failure_message : String String.build do |io| io << "has cookie '#{@name}'" io << " with path '#{@path}'" unless @path.nil? io << " for domain '#{@domain}'" unless @domain.nil? io << " with value '#{@value}'" end end private def negated_failure_message : String String.build do |io| io << "does not have cookie '#{@name}'" io << " with path '#{@path}'" unless @path.nil? io << " for domain '#{@domain}'" unless @domain.nil? io << " with value '#{@value}'" end end private def include_response? : Bool false end end ================================================ FILE: src/components/framework/src/spec/expectations/response/format_equals.cr ================================================ # :nodoc: struct Athena::Framework::Spec::Expectations::Response::FormatEquals < Athena::Framework::Spec::Expectations::Response::Base @request : AHTTP::Request @format : String? def initialize( @request : AHTTP::Request, @format : String? = nil, description : String? = nil, ) super description end def match(actual_value : ::HTTP::Server::Response) : Bool return false unless content_type = actual_value.headers["content-type"]? @format == @request.format(content_type) end private def failure_message : String "format is '#{@format || "null"}'" end private def negated_failure_message : String "format is not '#{@format || "null"}'" end end ================================================ FILE: src/components/framework/src/spec/expectations/response/has_cookie.cr ================================================ # :nodoc: struct Athena::Framework::Spec::Expectations::Response::HasCookie < Athena::Framework::Spec::Expectations::Response::Base @name : String @path : String? @domain : String? def initialize( @name : String, @path : String? = nil, @domain : String? = nil, description : String? = nil, ) super description end def match(actual_value : ::HTTP::Server::Response) : Bool return false unless cookie = actual_value.cookies[@name]? @path == cookie.path && @domain == cookie.domain end private def failure_message : String String.build do |io| io << "has cookie '#{@name}'" io << " with path '#{@path}'" unless @path.nil? io << " for domain '#{@domain}'" unless @domain.nil? end end private def negated_failure_message : String String.build do |io| io << "does not have cookie '#{@name}'" io << " with path '#{@path}'" unless @path.nil? io << " for domain '#{@domain}'" unless @domain.nil? end end private def include_response? : Bool false end end ================================================ FILE: src/components/framework/src/spec/expectations/response/has_header.cr ================================================ # :nodoc: struct Athena::Framework::Spec::Expectations::Response::HasHeader < Athena::Framework::Spec::Expectations::Response::Base @name : String def initialize( @name : String, description : String? = nil, ) super description end def match(actual_value : ::HTTP::Server::Response) : Bool actual_value.headers.has_key? @name end private def failure_message : String "has header '#{@name}'" end private def negated_failure_message : String "does not have header '#{@name}'" end private def include_response? : Bool false end end ================================================ FILE: src/components/framework/src/spec/expectations/response/has_status.cr ================================================ # :nodoc: struct Athena::Framework::Spec::Expectations::Response::HasStatus < Athena::Framework::Spec::Expectations::Response::Base @status : ::HTTP::Status def self.new( status_code : Int32, description : String? = nil, ) new ::HTTP::Status.from_value(status_code), description end def initialize( @status : ::HTTP::Status, description : String? = nil, ) super description end def match(actual_value : ::HTTP::Server::Response) : Bool actual_value.status == @status end private def failure_message : String "status is '#{@status}'" end private def negated_failure_message : String "status is not '#{@status}'" end end ================================================ FILE: src/components/framework/src/spec/expectations/response/header_equals.cr ================================================ # :nodoc: struct Athena::Framework::Spec::Expectations::Response::HeaderEquals < Athena::Framework::Spec::Expectations::Response::Base @name : String @value : String def initialize( @name : String, @value : String, description : String? = nil, ) super description end def match(actual_value : ::HTTP::Server::Response) : Bool @value == actual_value.headers[@name]? end private def failure_message : String "has header '#{@name}' with value '#{@value}'" end private def negated_failure_message : String "does not have header '#{@name}' with value '#{@value}'" end private def include_response? : Bool false end end ================================================ FILE: src/components/framework/src/spec/expectations/response/is_redirected.cr ================================================ # :nodoc: struct Athena::Framework::Spec::Expectations::Response::IsRedirected < Athena::Framework::Spec::Expectations::Response::Base def match(actual_value : ::HTTP::Server::Response) : Bool actual_value.status.redirection? end private def failure_message : String "is redirected" end private def negated_failure_message : String "is not redirected" end end ================================================ FILE: src/components/framework/src/spec/expectations/response/is_successful.cr ================================================ # :nodoc: struct Athena::Framework::Spec::Expectations::Response::IsSuccessful < Athena::Framework::Spec::Expectations::Response::Base def match(actual_value : ::HTTP::Server::Response) : Bool actual_value.status.success? end private def failure_message : String "is successful" end private def negated_failure_message : String "is not successful" end end ================================================ FILE: src/components/framework/src/spec/expectations/response/is_unprocessable.cr ================================================ # :nodoc: struct Athena::Framework::Spec::Expectations::Response::IsUnprocessable < Athena::Framework::Spec::Expectations::Response::Base def match(actual_value : ::HTTP::Server::Response) : Bool actual_value.status.unprocessable_entity? end private def failure_message : String "is unprocessable" end private def negated_failure_message : String "is not unprocessable" end end ================================================ FILE: src/components/framework/src/spec/http_browser.cr ================================================ # Simulates a browser and makes a requests to `ATH::RouteHandler`. class Athena::Framework::Spec::HTTPBrowser < ATH::Spec::AbstractBrowser # Returns a reference to an `ADI::Spec::MockableServiceContainer` to allow configuring the container before a test. def container : ADI::Spec::MockableServiceContainer ADI.container.as(ADI::Spec::MockableServiceContainer) end protected def do_request(request : AHTTP::Request) : ::HTTP::Server::Response response = ::HTTP::Server::Response.new IO::Memory.new handler = ADI.container.athena_http_kernel athena_response = handler.handle request athena_response.send request, response handler.terminate request, athena_response response end end ================================================ FILE: src/components/framework/src/spec/web_test_case.cr ================================================ require "./expectations/*" # Base `ASPEC::TestCase` for web based integration tests. # # NOTE: Currently only `API` based tests are supported. This type exists to allow for introduction of other types in the future. abstract struct Athena::Framework::Spec::WebTestCase < ASPEC::TestCase include ATH::Spec::Expectations::HTTP protected getter client : AbstractBrowser def initialize @client = create_client end # Returns the `AbstractBrowser` instance to which requests should be made against. def create_client : AbstractBrowser HTTPBrowser.new end end ================================================ FILE: src/components/framework/src/spec.cr ================================================ require "athena-spec" require "athena-clock/spec" require "athena-console/spec" require "athena-event_dispatcher/spec" require "athena-dependency_injection/spec" require "athena-validator/spec" require "./spec/*" # :nodoc: # # Monkey patch ::HTTP::Server::Response to allow accessing the response body directly and string representation of it. class ::HTTP::Server::Response @body_io : IO = IO::Memory.new @body : String? = nil def write(slice : Bytes) : Nil @body_io.write slice previous_def end def body : String @body ||= @body_io.to_s end def to_s(io : IO) : Nil io << @version << ' ' << self.status_code << ' ' << @status.description << '\n' << '\n' ::HTTP.serialize_headers_and_string_body io, @headers, self.body end end # A set of testing utilities/types to aid in testing `Athena::Framework` related types. # # ### Getting Started # # Require this module in your `spec_helper.cr` file. # # ``` # # This also requires "spec" and "athena-spec". # require "athena/spec" # ``` # # Add `Athena::Spec` as a development dependency, then run a `shards install`. # See the individual types for more information. module Athena::Framework::Spec # `ATH::Spec` includes a set of custom spec expectations for making it easier to test certain aspects of the application. # These expectations are exposed via helper methods within the modules defined within this namespace. # See each module for more information. module Expectations # :nodoc: module Request; end # :nodoc: module Response; end end end ================================================ FILE: src/components/framework/src/view/configurable_view_handler_interface.cr ================================================ # These live here so `Athena::Framework::View` is correctly created as a class versus a module. # Parent type of a view just used for typing. # # See `ATH::View`. module Athena::Framework::ViewBase; end class Athena::Framework::View(T) include Athena::Framework::ViewBase end require "./view_handler_interface" # Specialized `ATH::View::ViewHandlerInterface` that allows controlling various serialization `ATH::View::Context` aspects dynamically. module Athena::Framework::View::ConfigurableViewHandlerInterface include Athena::Framework::View::ViewHandlerInterface # Sets the *groups* that should be used as part of `ASR::ExclusionStrategies::Groups`. abstract def serialization_groups=(groups : Enumerable(String)) : Nil # Sets the *version* that should be used as part of `ASR::ExclusionStrategies::Version`. abstract def serialization_version=(version : SemanticVersion) : Nil # Determines if properties with `nil` values should be emitted. abstract def emit_nil=(emit_nil : Bool) : Nil end ================================================ FILE: src/components/framework/src/view/context.cr ================================================ # Represents (de)serialization options in a serializer agnostic way. class Athena::Framework::View::Context # Returns the groups that can be used to create different "views" of an object. # # `ASR::ExclusionStrategies::Groups` is an example of this. getter groups : Set(String)? = nil # Determines if properties with `nil` values should be emitted. property? emit_nil : Bool? = nil # Represents the version of an object. Can be used to control what properties are serialized based on the version. # # `ASR::ExclusionStrategies::Version` is an example of this. property version : SemanticVersion? = nil # Returns any `ASR::ExclusionStrategies::ExclusionStrategyInterface` that should be used by the serializer. getter exclusion_strategies = Array(ASR::ExclusionStrategies::ExclusionStrategyInterface).new # Adds the provided *strategy* to the `#exclusion_strategies` array. def add_exclusion_strategy(strategy : ASR::ExclusionStrategies::ExclusionStrategyInterface) : self @exclusion_strategies << strategy self end # Adds the provided *group* to the `#groups` array. def add_group(group : String) : self (@groups ||= Set(String).new) << group self end # Adds the provided *groups* to the `#groups` array. def add_groups(*groups : String) : self self.add_groups groups end # :ditto: def add_groups(groups : Enumerable(String)) : self groups.each do |group| self.add_group group end self end # Sets the `#groups` array to the provided *groups*. def groups=(groups : Enumerable(String)) : self @groups = groups.to_set self end # Sets the `#version` to the provided *version*. def version=(version : String) : self self.version = SemanticVersion.parse version self end end ================================================ FILE: src/components/framework/src/view/format_handler_interface.cr ================================================ # Represents custom logic that should be applied for a specific format in order to render an `ATH::View` into an `AHTTP::Response` # that is not handled by default by Athena. E.g. `HTML`. # # ``` # # Register our handler as a service. # @[ADI::Register] # class HTMLFormatHandler # # Implement the interface. # include Athena::Framework::View::FormatHandlerInterface # # # :inherit: # # # # Turn the provided data into a response that can be returned to the client. # def call(view_handler : ATH::View::ViewHandlerInterface, view : ATH::ViewBase, request : AHTTP::Request, format : String) : AHTTP::Response # AHTTP::Response.new "

#{view.data}

", headers: ::HTTP::Headers{"content-type" => "text/html"} # end # # # :inherit: # # # # Specify that `self` handles the `HTML` format. # def format : String # "html" # end # end # ``` # # The implementation for `HTML` for example could use `.to_s` as depicted here, or utilize a templating engine, possibly taking advantage # of [custom annotations](/getting_started/configuration#custom-annotations) to allow specifying the related template name. @[ADI::Autoconfigure(tags: [Athena::Framework::View::FormatHandlerInterface::TAG])] module Athena::Framework::View::FormatHandlerInterface TAG = "athena.format_handler" # Responsible for returning an `AHTTP::Response` for the provided *view* and *request* in the provided *format*. # # The `ATH::View::ViewHandlerInterface` is also provided to ease response creation. abstract def call(view_handler : ATH::View::ViewHandlerInterface, view : ATH::View, request : AHTTP::Request, format : String) : AHTTP::Response # Returns the format that `self` handles. # # The *format* must be registered with the [AHTTP::Request::FORMATS](/HTTP/Request/#Athena::HTTP::Request::FORMATS) hash; # either as a built in format, or a custom one that has registered via [AHTTP::Request.register_format](/HTTP/Request/#Athena::HTTP::Request.register_format(format,mime_types)). abstract def format : String end ================================================ FILE: src/components/framework/src/view/format_negotiator.cr ================================================ # An extension of `ANG::Negotiator` that supports resolving the format based on an applications `ATH::Bundle::Schema::FormatListener` rules. # # See the [Getting Started](/getting_started/routing#content-negotiation) docs for more information. class Athena::Framework::View::FormatNegotiator < ANG::Negotiator # :nodoc: record Rule, priorities : Array(String)? = nil, fallback_format : String | Bool | Nil = false, stop : Bool = false, prefer_extension : Bool = true @map : Array({AHTTP::RequestMatcher::Interface, ATH::View::FormatNegotiator::Rule}) = [] of {AHTTP::RequestMatcher::Interface, ATH::View::FormatNegotiator::Rule} def initialize( @request_store : AHTTP::RequestStore, @mime_types : Hash(String, Array(String)) = Hash(String, Array(String)).new, ) end protected def add(request_matcher : AHTTP::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 @map.each do |(matcher, rule)| next unless matcher.matches? request if rule.stop raise AHK::Exception::StopFormatListener.new "Stopping format listener." end if priorities.nil? && rule.priorities.nil? if fallback_format = rule.fallback_format request.mime_type(fallback_format.as(String)).try do |mime_type| return ANG::Accept.new mime_type end end next end 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 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 h, mime_types return mime_type end end rule.fallback_format.try do |ff| return if false == ff request.mime_type(ff.as(String)).try do |mt| return ANG::Accept.new mt end end end nil end private def normalize_mime_types(priorities : Indexable(String)) : Array(String) priorities = priorities.map &.gsub(/\s+/, "").downcase mime_types = [] of String priorities.each do |priority| if priority.includes? '/' mime_types << priority next end mime_types = mime_types.concat AHTTP::Request.mime_types priority if @mime_types.has_key? priority mime_types.concat @mime_types[priority] end end mime_types end end ================================================ FILE: src/components/framework/src/view/view.cr ================================================ # An `ATH::View` represents an `AHTTP::Response`, but in a format agnostic way. # # Returning a `ATH::View` is essentially the same as returning the data directly; but allows customizing # the response status and headers without needing to render the response body within the controller as an `AHTTP::Response`. # # ``` # require "athena" # # class HelloController < ATH::Controller # @[ARTA::Get("/{name}")] # def say_hello(name : String) : NamedTuple(greeting: String) # {greeting: "Hello #{name}"} # end # # @[ARTA::Get("/view/{name}")] # def say_hello_view(name : String) : ATH::View(NamedTuple(greeting: String)) # self.view({greeting: "Hello #{name}"}, :im_a_teapot) # end # end # # ATH.run # # # GET /Fred # => 200 {"greeting":"Hello Fred"} # # GET /view/Fred # => 418 {"greeting":"Hello Fred"} # ``` # # See the [Getting Started](/getting_started/routing#content-negotiation) docs for more information. class Athena::Framework::View(T) # The response data. property data : T # The `HTTP::Status` of the underlying `#response`. property status : ::HTTP::Status? # The format the view should be rendered in. # # The *format* must be registered with the [AHTTP::Request::FORMATS](/HTTP/Request/#Athena::HTTP::Request::FORMATS) hash; # either as a built in format, or a custom one that has registered via [AHTTP::Request.register_format](/HTTP/Request/#Athena::HTTP::Request.register_format(format,mime_types)). property format : String? = nil # The parameters that should be used when constructing the redirect `#route` URL. property route_params : Hash(String, String?) = Hash(String, String?).new property context : ATH::View::Context { ATH::View::Context.new } # Returns the `URL` that the current request should be redirected to. # # See the [Location](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location) header documentation. getter location : String? = nil # Returns the name of the route the current request should be redirected to. # # See the [Getting Started](/getting_started/routing#url-generation) docs for more information. getter route : String? = nil # The wrapped `AHTTP::Response` instance. property response : AHTTP::Response do response = AHTTP::Response.new if status = @status response.status = status end response end # Creates a view instance that'll redirect to the provided *url*. See `#location`. # # Optionally allows setting the underlying *status* and/or *headers*. def self.create_redirect( url : String, status : ::HTTP::Status = ::HTTP::Status::FOUND, headers : ::HTTP::Headers = ::HTTP::Headers.new, ) : self view = ATH::View(Nil).new status: status, headers: headers view.location = url view end # Creates a view instance that'll redirect to the provided *route*. See `#route`. # # Optionally allows setting the underlying route *params*, *status*, and/or *headers*. def self.create_route_redirect( route : String, params : Hash(String, _) = Hash(String, String?).new, status : ::HTTP::Status = ::HTTP::Status::FOUND, headers : ::HTTP::Headers = ::HTTP::Headers.new, ) : self view = ATH::View(Nil).new status: status, headers: headers view.route = route view.route_params = params.transform_values &.to_s.as(String?) view end def initialize(@data : T? = nil, @status : ::HTTP::Status? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) self.headers = headers unless headers.empty? end # Returns the headers of the underlying `#response`. def headers : AHTTP::Response::Headers self.response.headers end # Sets the redirect `#location`. def location=(@location : String) : Nil @route = nil end # Returns the type of the data represented by `self`. def return_type : T.class T end # Sets the redirect `#route`. def route=(@route : String) : Nil @location = nil end # Adds the provided header *name* and *value* to the underlying `#response`. def set_header(name : String, value : String) : Nil self.response.headers[name] = value end # :ditto: def set_header(name : String, value : _) : Nil self.set_header name, value.to_s end # Sets the *headers* that should be returned as part of the underlying `#response`. def headers=(headers : ::HTTP::Headers) : Nil self.response.headers.clear self.response.headers.merge! headers end # Recurses over all the types within `T` to determine what serializer the data should use. protected def serializable_data : T? {% begin %} {% types_to_recurse = [T] types_to_recurse.each do |t| t = t.resolve if t.union? t.union_types.each do |ut| types_to_recurse << ut end elsif t <= NamedTuple t.keys.each do |k| types_to_recurse << t[k].resolve end elsif t <= Enumerable t.type_vars.each do |ut| types_to_recurse << ut end # Use `to_json` if a type includes `JSON::Serializable` but not `ASR::Serializable` elsif (t <= JSON::Serializable) && !(t <= ASR::Serializable) use_serializer_component = false # Use the serializer component if the type is `ASR::Serializable` elsif t <= ASR::Serializable use_serializer_component = true else # Fallback on the serializer component use_serializer_component = true end end %} {{ use_serializer_component == true ? nil : "self.data".id }} {% end %} end end ================================================ FILE: src/components/framework/src/view/view_handler.cr ================================================ require "./configurable_view_handler_interface" ADI.bind format_handlers : Array(Athena::Framework::View::FormatHandlerInterface), "!athena.format_handler" @[ADI::AsAlias(ATH::View::ConfigurableViewHandlerInterface)] @[ADI::AsAlias(ATH::View::ViewHandlerInterface)] # Default implementation of `ATH::View::ConfigurableViewHandlerInterface`. class Athena::Framework::View::ViewHandler include Athena::Framework::View::ConfigurableViewHandlerInterface @custom_handlers = Hash(String, ATH::View::ViewHandlerInterface::HandlerType).new @serialization_groups : Set(String)? = nil @serialization_version : SemanticVersion? = nil @empty_content_status : ::HTTP::Status @failed_validation_status : ::HTTP::Status @emit_nil : Bool def initialize( @url_generator : ART::Generator::Interface, @serializer : ASR::SerializerInterface, @request_store : AHTTP::RequestStore, format_handlers : Array(Athena::Framework::View::FormatHandlerInterface), @failed_validation_status : ::HTTP::Status = ::HTTP::Status::UNPROCESSABLE_ENTITY, @empty_content_status : ::HTTP::Status = ::HTTP::Status::NO_CONTENT, @emit_nil : Bool = false, ) format_handlers.each do |format_handler| self.register_handler format_handler.format, format_handler end end # :inherit: def serialization_groups=(groups : Enumerable(String)) : Nil @serialization_groups = groups.to_set end # :inherit: def serialization_version=(version : String) : Nil self.serialization_version = SemanticVersion.parse version end # :inherit: def serialization_version=(version : SemanticVersion) : Nil @serialization_version = version end # :inherit: def emit_nil=(@emit_nil : Bool) : Nil end # :nodoc: # # This method is mainly for testing. def register_handler(format : String, &block : ATH::View::ViewHandlerInterface, ATH::ViewBase, AHTTP::Request, String -> AHTTP::Response) : Nil self.register_handler format, block end # :inherit: def register_handler(format : String, handler : ATH::View::ViewHandlerInterface::HandlerType) : Nil @custom_handlers[format] = handler end # :inherit: def supports?(format : String) : Bool # JSON is the only format supported via the serializer ATM. @custom_handlers.has_key?(format) || "json" == format end # :inherit: def handle(view : ATH::ViewBase, request : AHTTP::Request? = nil) : AHTTP::Response request = @request_store.request if request.nil? format = view.format || request.request_format unless self.supports? format raise AHK::Exception::NotAcceptable.new "The server is unable to return a response in the requested format: '#{format}'." end if custom_handler = @custom_handlers[format]? return custom_handler.call self, view, request, format end self.create_response view, request, format end # :inherit: def create_response(view : ATH::ViewBase, request : AHTTP::Request, format : String) : AHTTP::Response route = view.route if location = (route ? @url_generator.generate(route, view.route_params, :absolute_url) : view.location) return self.create_redirect_response view, location, format end response = self.init_response view, format unless response.headers.has_key? "content-type" mime_type = request.attributes.get? "media_type", String if mime_type.nil? mime_type = request.mime_type format end response.headers["content-type"] = mime_type if mime_type end response end # :inherit: def create_redirect_response(view : ATH::ViewBase, location : String, format : String) : AHTTP::Response content = nil if (vs = view.status) && (vs.created? || vs.accepted?) && !view.data.nil? response = self.init_response view, format else response = view.response end response.status = self.status view, content response.headers["location"] = location response end private def init_response(view : ATH::ViewBase, format : String) : AHTTP::Response content = nil # Skip serialization if the action's return type is explicitly `Nil`. if @emit_nil || view.return_type != Nil # TODO: Support Form typed views. # Fallback on `to_json` for non ASR::Serializable types. content = if data = view.serializable_data data.to_json else data = view.data context = self.serialization_context view # TODO: Implement some sort of Adapter system to convert ATH::View::Context # into the serializer's required format. Just do that here for now. athena_serializer_context = ASR::SerializationContext.new context.emit_nil?.try do |en| athena_serializer_context.emit_nil = en end context.version.try do |v| athena_serializer_context.version = v end context.groups.try do |g| athena_serializer_context.groups = g end context.exclusion_strategies.each do |s| athena_serializer_context.add_exclusion_strategy s end serialized_data = @serializer.serialize data, format, athena_serializer_context # If the serialized data is "null", but the data is not `nil`, assume this means the serializer component failed to serialize it, # raise an error as it is likely the user forgot to include either `JSON::Serializable` or `ASR::Serializable`. if "null" == serialized_data && !data.nil? raise AHK::Exception::Logic.new "Failed to serialize response body. Did you forget to include either `JSON::Serializable` or `ASR::Serializable`?" end serialized_data end end response = view.response response.status = self.status view, content response.content = content unless content.nil? response end private def serialization_context(view : ATH::ViewBase) : ATH::View::Context context = view.context groups = context.groups if groups.nil? && (view_handler_groups = @serialization_groups) && !view_handler_groups.empty? context.groups = view_handler_groups end if context.version.nil? && (view_handler_version = @serialization_version) context.version = view_handler_version end if context.emit_nil?.nil? && (view_handler_emit_nil = @emit_nil) context.emit_nil = view_handler_emit_nil end # TODO: Set status code in context attributes if that's ever implemented. context end private def status(view : ATH::ViewBase, content : _) : ::HTTP::Status # TODO: Handle validating Form data. if status = view.status return status end content.nil? ? @empty_content_status : ::HTTP::Status::OK end end ================================================ FILE: src/components/framework/src/view/view_handler_interface.cr ================================================ # Processes an `ATH::View` into an `AHTTP::Response` of the proper format. # # See the [Getting Started](/getting_started/routing/#content-negotiation) docs for more information. module Athena::Framework::View::ViewHandlerInterface # The possible types for a view format handler. alias HandlerType = ATH::View::FormatHandlerInterface | Proc(ATH::View::ViewHandlerInterface, ATH::ViewBase, AHTTP::Request, String, AHTTP::Response) # Registers the provided *handler* to handle the provided *format*. abstract def register_handler(format : String, handler : ATH::View::ViewHandlerInterface::HandlerType) : Nil # Determines if `self` can handle the provided *format*. # # First checks if a custom format handler supports the provided *format*, # otherwise falls back on the `ASR::SerializerInterface`. abstract def supports?(format : String) : Bool # Handles the conversion of the provided *view* into an `AHTTP::Response`. # # If no *request* is provided, it is fetched from `AHTTP::RequestStore`. abstract def handle(view : ATH::ViewBase, request : AHTTP::Request? = nil) : AHTTP::Response # Creates an `AHTTP::Response` based on the provided *view* that'll redirect to the provided *location*. # # *location* may either be a `URL` or the name of a route. abstract def create_redirect_response(view : ATH::ViewBase, location : String, format : String) : AHTTP::Response # Creates an `AHTTP::Response` based on the provided *view* and *request*. abstract def create_response(view : ATH::ViewBase, request : AHTTP::Request, format : String) : AHTTP::Response end ================================================ FILE: src/components/http/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/http/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/http/CHANGELOG.md ================================================ # Changelog ## [0.1.0] - 2026-04-19 _Initial release._ [0.1.0]: https://github.com/athena-framework/http/releases/tag/v0.1.0 ================================================ FILE: src/components/http/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/http/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2025 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/http/README.md ================================================ # HTTP [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/workflows/CI/badge.svg)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/http.svg)](https://github.com/athena-framework/http/releases) Shared common HTTP abstractions/utilities. ## Getting Started Checkout the [Documentation](https://athenaframework.org/HTTP). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/http/docs/README.md ================================================ The `Athena::HTTP` component provides various HTTP related Athena types and utilities. ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-http: github: athena-framework/http version: ~> 0.1.0 ``` ================================================ FILE: src/components/http/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: HTTP site_url: https://athenaframework.org/HTTP/ repo_url: https://github.com/athena-framework/http nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-mime/src/athena-mime.cr - ./lib/athena-http/src/athena-http.cr source_locations: lib/athena-http: https://github.com/athena-framework/http/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/http/shard.yml ================================================ name: athena-http version: 0.1.0 crystal: ~> 1.4 license: MIT repository: https://github.com/athena-framework/http documentation: https://athenaframework.org/HTTP Shared common HTTP abstractions/utilities: | Shared common HTTP abstractions/utilities. authors: - George Dietrich ================================================ FILE: src/components/http/spec/assets/.unknownextension ================================================ ================================================ FILE: src/components/http/spec/assets/directory/.empty ================================================ ================================================ FILE: src/components/http/spec/assets/file-big.txt ================================================ I'm not big, but I'm big enough to carry more than 50 bytes inside me. ================================================ FILE: src/components/http/spec/assets/file-small.txt ================================================ I'm a file with less than 50 bytes. ================================================ FILE: src/components/http/spec/assets/foo.txt ================================================ foo ================================================ FILE: src/components/http/spec/assets/fööö.html ================================================

Hello!

================================================ FILE: src/components/http/spec/assets/webkitdirectory/nested/test.txt ================================================ nested webkitdirectory text ================================================ FILE: src/components/http/spec/assets/webkitdirectory/test.txt ================================================ webkitdirectory text ================================================ FILE: src/components/http/spec/binary_file_response_spec.cr ================================================ require "./spec_helper" struct AHTTP::BinaryFileResponseTest < ASPEC::TestCase def test_new_without_disposition : Nil response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/foo.txt", 418, ::HTTP::Headers{"FOO" => "BAR"}, true, nil, true, true response.status.should eq ::HTTP::Status::IM_A_TEAPOT response.headers["FOO"]?.should eq "BAR" response.headers.has_key?("etag").should be_true response.headers.has_key?("last-modified").should be_true response.headers.has_key?("content-disposition").should be_false end def test_new_with_disposition : Nil response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/foo.txt", 418, public: true, content_disposition: :inline response.status.should eq ::HTTP::Status::IM_A_TEAPOT response.headers.has_key?("etag").should be_false response.headers.has_key?("content-disposition").should be_true response.headers["content-disposition"]?.should eq %(inline; filename=foo.txt) end def test_new_with_non_ascii_filename : Nil AHTTP::BinaryFileResponse.new("#{__DIR__}/assets/fööö.html").file.basename.should eq "fööö.html" end def test_set_file_unreadable : Nil pending! "Windows does not have unreadable files" if {{ flag? :windows }} path = Path[Dir.tempdir, "unreadable"].to_s begin ::File.write path, "", 0 ex = expect_raises AHTTP::Exception::File, "The file must be readable." do AHTTP::BinaryFileResponse.new path end ex.file.should eq path ensure ::File.delete? path end end def test_set_content : Nil expect_raises(::Exception, "The content cannot be set on a BinaryFileResponse instance.") do AHTTP::BinaryFileResponse.new(__FILE__).content = "FOO" end end def test_content : Nil AHTTP::BinaryFileResponse.new(__FILE__).content.should be_empty end def test_set_content_disposition_custom_fallback_filename : Nil response = AHTTP::BinaryFileResponse.new __FILE__ response.set_content_disposition :attachment, "föö.html", "FILE" response.headers["content-disposition"]?.should eq %(attachment; filename=FILE; filename*=utf-8''f%C3%B6%C3%B6.html) end def test_set_content_disposition_custom_filename : Nil response = AHTTP::BinaryFileResponse.new __FILE__ response.set_content_disposition :attachment, "foo.html" response.headers["content-disposition"]?.should eq %(attachment; filename=foo.html) end def test_range_requests_without_last_modified_header : Nil response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif", content_disposition: nil, auto_last_modified: false # Request to get ETag request = AHTTP::Request.new "GET", "/", ::HTTP::Headers{ "if-range" => Time::Format::HTTP_DATE.format(Time.utc), "range" => "bytes=1-4", } response.prepare request output = String.build do |io| response.write io end ::File.read("#{__DIR__}/assets/test.gif").should eq output response.headers.has_key?("content-range").should be_false end def test_range_on_post_method : Nil response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif" request = AHTTP::Request.new "POST", "/", ::HTTP::Headers{"range" => "bytes=10-20"} expected_output = ::File.open "#{__DIR__}/assets/test.gif", &.read_string(35) response.prepare request output = String.build do |io| response.write io end output.should eq expected_output response.status.should eq ::HTTP::Status::OK response.headers["content-length"].should eq "35" response.headers.has_key?("content-range").should be_false end def test_unprepared_response_sends_full_file : Nil response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif" expected_output = ::File.read "#{__DIR__}/assets/test.gif" output = String.build do |io| response.write io end output.should eq expected_output response.status.should eq ::HTTP::Status::OK end def test_delete_file_after_send : Nil path = Path[Dir.tempdir, "unreadable"] begin ::File.touch path ::File.file?(path).should be_true request = AHTTP::Request.new "GET", "/" response = AHTTP::BinaryFileResponse.new path response.delete_file_after_send = true response.prepare request response.write IO::Memory.new ::File.file?(path).should be_false ensure ::File.delete? path end end def test_accept_range_unsafe_methods : Nil request = AHTTP::Request.new "POST", "/" response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif" response.prepare request response.headers["accept-ranges"]?.should eq "none" end def test_accept_range_not_overridden : Nil request = AHTTP::Request.new "POST", "/" response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif", headers: ::HTTP::Headers{"accept-ranges" => "foo"} response.prepare request response.headers["accept-ranges"]?.should eq "foo" end def test_prepare_cache_request_etag : Nil request = AHTTP::Request.new "GET", "/", headers: ::HTTP::Headers{"if-none-match" => "\"ETAG\""} response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif", headers: ::HTTP::Headers{"accept-ranges" => "foo", "etag" => "\"ETAG\""} response.prepare request response.status.should eq ::HTTP::Status::NOT_MODIFIED response.headers.has_key?("date").should be_true response.headers.has_key?("content-length").should be_false response.headers.has_key?("content-type").should be_false end def test_prepare_cache_request_etag_star : Nil request = AHTTP::Request.new "GET", "/", headers: ::HTTP::Headers{"if-none-match" => "*"} response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif", headers: ::HTTP::Headers{"accept-ranges" => "foo", "etag" => "\"ETAG\""} response.prepare request response.status.should eq ::HTTP::Status::NOT_MODIFIED response.headers.has_key?("date").should be_true response.headers.has_key?("content-length").should be_false response.headers.has_key?("content-type").should be_false end def test_prepare_cache_request_last_modified : Nil now = Time.utc request = AHTTP::Request.new "GET", "/", headers: ::HTTP::Headers{"if-modified-since" => ::HTTP.format_time(now)} response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif", headers: ::HTTP::Headers{"accept-ranges" => "foo", "last-modified" => ::HTTP.format_time(now)} response.prepare request response.status.should eq ::HTTP::Status::NOT_MODIFIED response.headers.has_key?("date").should be_true response.headers.has_key?("content-length").should be_false response.headers.has_key?("content-type").should be_false end @[DataProvider("ranges")] def test_requests(request_range : String, offset : Int32, length : Int32, response_range : String) : Nil response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif", auto_etag: true # Request to get ETag request = AHTTP::Request.new "GET", "/" response.prepare request etag = response.headers["etag"] # Request for a range of test file request = AHTTP::Request.new "GET", "/", ::HTTP::Headers{"if-range" => etag, "range" => request_range} expected_output = ::File.open("#{__DIR__}/assets/test.gif", &.read_at(offset, length, &.gets_to_end)) response.prepare request output = String.build do |io| response.write io end output.should eq expected_output response.status.should eq ::HTTP::Status::PARTIAL_CONTENT response.headers["content-range"]?.should eq response_range response.headers["content-length"]?.should eq length.to_s end @[DataProvider("ranges")] def test_requests_without_etag(request_range : String, offset : Int32, length : Int32, response_range : String) : Nil response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif" # Request to get LastModified request = AHTTP::Request.new "GET", "/" response.prepare request last_modified = response.headers["last-modified"] # Request for a range of test file request = AHTTP::Request.new "GET", "/", ::HTTP::Headers{"if-range" => last_modified, "range" => request_range} expected_output = ::File.open("#{__DIR__}/assets/test.gif", &.read_at(offset, length, &.gets_to_end)) response.prepare request output = String.build do |io| response.write io end output.should eq expected_output response.status.should eq ::HTTP::Status::PARTIAL_CONTENT response.headers["content-range"]?.should eq response_range end def ranges : Tuple { {"bytes=1-4", 1, 4, "bytes 1-4/35"}, {"bytes=-5", 30, 5, "bytes 30-34/35"}, {"bytes=30-", 30, 5, "bytes 30-34/35"}, {"bytes=30-30", 30, 1, "bytes 30-30/35"}, {"bytes=30-34", 30, 5, "bytes 30-34/35"}, {"bytes=30-40", 30, 5, "bytes 30-34/35"}, } end @[DataProvider("full_file_ranges")] def test_full_file_requests(request_range : String) : Nil response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif", auto_etag: true request = AHTTP::Request.new "GET", "/", ::HTTP::Headers{"range" => request_range} expected_output = ::File.open "#{__DIR__}/assets/test.gif", &.read_string(35) response.prepare request output = String.build do |io| response.write io end output.should eq expected_output response.status.should eq ::HTTP::Status::OK end def full_file_ranges : Tuple { {"bytes=0-"}, {"bytes=0-34"}, {"bytes=-35"}, # Syntactical invalid range-request should also return the full resource {"bytes=20-10"}, {"bytes=50-40"}, # range units other than bytes must be ignored {"unknown=10-20"}, } end @[DataProvider("invalid_ranges")] def test_invalid_requests(request_range : String) : Nil response = AHTTP::BinaryFileResponse.new "#{__DIR__}/assets/test.gif", auto_etag: true request = AHTTP::Request.new "GET", "/", ::HTTP::Headers{"range" => request_range} response.prepare request response.write IO::Memory.new response.status.should eq ::HTTP::Status::RANGE_NOT_SATISFIABLE response.headers["content-range"]?.should eq "bytes */35" end def invalid_ranges : Tuple { {"bytes=-40"}, {"bytes=40-50"}, } end def test_content_disposition_to_s : Nil AHTTP::BinaryFileResponse::ContentDisposition::Attachment.to_s.should eq "attachment" AHTTP::BinaryFileResponse::ContentDisposition::Inline.to_s.should eq "inline" end end ================================================ FILE: src/components/http/spec/ext/conversion_types_spec.cr ================================================ private enum Color Red Green Blue end describe Athena::HTTP do describe ".from_parameter" do describe Number do it Int64 do Int64.from_parameter("123").should eq 123_i64 end it "Int with whitespace" do expect_raises ArgumentError, "Invalid Int32" do Int32.from_parameter(" 123") end end it Float32 do Float32.from_parameter("3.14").should eq 3.14_f32 end it "Float with whitespace" do expect_raises ArgumentError, "Invalid Float64" do Float64.from_parameter(" 123.5") end end end describe Bool do it "true" do Bool.from_parameter("true").should be_true Bool.from_parameter("on").should be_true Bool.from_parameter("1").should be_true Bool.from_parameter("yes").should be_true end it "false" do Bool.from_parameter("false").should be_false Bool.from_parameter("off").should be_false Bool.from_parameter("0").should be_false Bool.from_parameter("no").should be_false end it "invalid" do expect_raises ArgumentError, "Invalid Bool" do Bool.from_parameter("foo") end end end it Enum do Color.from_parameter("green").should eq Color::Green end it Object do str = "foo" String.from_parameter(str).should be str end describe Array do it "single type" do Array(Int32).from_parameter([1, 2]).should eq [1, 2] end it "Union type" do Array(Int32 | Bool).from_parameter([1, false]).should eq [1, false] end end describe Nil do it "valid" do Nil.from_parameter("null").should be_nil end it "invalid" do expect_raises ArgumentError, "Invalid Nil" do Nil.from_parameter("foo") end end end end describe ".from_parameter?" do describe Number do it Int64 do Int64.from_parameter?("123").should eq 123_i64 end it "Int with whitespace" do Int32.from_parameter?(" 123").should be_nil end it Float32 do Float32.from_parameter?("3.14").should eq 3.14_f32 end it "Float with whitespace" do Float64.from_parameter?(" 123.5").should be_nil end end describe Bool do it "true" do Bool.from_parameter?("true").should be_true Bool.from_parameter?("on").should be_true Bool.from_parameter?("1").should be_true Bool.from_parameter?("yes").should be_true end it "false" do Bool.from_parameter?("false").should be_false Bool.from_parameter?("off").should be_false Bool.from_parameter?("0").should be_false Bool.from_parameter?("no").should be_false end it "invalid" do Bool.from_parameter?("foo").should be_nil end end it Enum do Color.from_parameter?("green").should eq Color::Green Color.from_parameter?("black").should be_nil end it Object do str = "foo" String.from_parameter?(str).should be str end describe Array do it "single type" do Array(Int32).from_parameter?([1, 2]).should eq [1, 2] end it "Union type" do Array(Int32 | Bool).from_parameter?([1, false]).should eq [1, false] end end describe Nil do it "valid" do Nil.from_parameter?("null").should be_nil end it "invalid" do Nil.from_parameter?("foo").should be_nil end end end end ================================================ FILE: src/components/http/spec/file_spec.cr ================================================ struct FileTest < ASPEC::TestCase def test_initialize_non_existent_file : Nil ex = expect_raises ::AHTTP::Exception::FileNotFound, "The file does not exist." do AHTTP::File.new "#{__DIR__}/assets/missing" end ex.file.should eq "#{__DIR__}/assets/missing" end def test_mime_type : Nil file = AHTTP::File.new "#{__DIR__}/assets/test.gif" file.mime_type.should eq "image/gif" end def test_guess_extension_unknown : Nil file = AHTTP::File.new "#{__DIR__}/assets/directory/.empty" file.guess_extension.should be_nil end def test_guess_extension_known : Nil pending! "MIME guessing is not available" if {{ flag?("windows") && !flag?("gnu") }} file = AHTTP::File.new "#{__DIR__}/assets/test" file.guess_extension.should eq "gif" end def test_move : Nil path = "#{Dir.tempdir}/test.copy.gif" target_dir = "#{Dir.tempdir}/test" target_path = "#{target_dir}/test.copy.gif" ::File.delete? path ::File.delete? target_path ::File.copy "#{__DIR__}/assets/test.gif", path Dir.mkdir target_dir unless Dir.exists? target_dir file = AHTTP::File.new path moved_file = file.move target_dir moved_file.should be_a AHTTP::File ::File.exists?(target_path).should be_true ::File.exists?(path).should be_false ::File.realpath(target_path).should eq moved_file.realpath FileUtils.rm_rf target_dir end def test_move_new_name : Nil path = "#{Dir.tempdir}/test.copy.gif" target_dir = "#{Dir.tempdir}/test" target_path = "#{target_dir}/test.new.gif" ::File.delete? path ::File.delete? target_path ::File.copy "#{__DIR__}/assets/test.gif", path Dir.mkdir target_dir unless Dir.exists? target_dir file = AHTTP::File.new path moved_file = file.move target_dir, "test.new.gif" ::File.exists?(target_path).should be_true ::File.exists?(path).should be_false ::File.realpath(target_path).should eq moved_file.realpath FileUtils.rm_rf target_dir end def test_move_non_existent_directory : Nil path = "#{Dir.tempdir}/test.copy.gif" target_dir = "#{Dir.tempdir}/test" target_path = "#{target_dir}/test.copy.gif" ::File.delete? path ::File.delete? target_path ::File.copy "#{__DIR__}/assets/test.gif", path FileUtils.rm_rf target_dir file = AHTTP::File.new path moved_file = file.move target_dir moved_file.should be_a AHTTP::File ::File.exists?(target_path).should be_true ::File.exists?(path).should be_false ::File.realpath(target_path).should eq moved_file.realpath FileUtils.rm_rf target_dir end @[TestWith( {"original.gif", "original.gif"}, {"..\\..\\original.gif", "original.gif"}, {"../../original.gif", "original.gif"}, {"файлfile.gif", "файлfile.gif"}, {"..\\..\\файлfile.gif", "файлfile.gif"}, {"../../файлfile.gif", "файлfile.gif"}, )] def test_move_non_latin_names(filename : String, sanitized_filename : String) : Nil path = "#{Dir.tempdir}/#{sanitized_filename}" target_dir = "#{Dir.tempdir}/test" target_path = "#{target_dir}/#{sanitized_filename}" ::File.delete? path ::File.delete? target_path ::File.copy "#{__DIR__}/assets/test.gif", path Dir.mkdir target_dir unless Dir.exists? target_dir file = AHTTP::File.new path moved_file = file.move target_dir, filename ::File.exists?(target_path).should be_true ::File.exists?(path).should be_false ::File.realpath(target_path).should eq moved_file.realpath FileUtils.rm_rf target_dir end def test_realpath : Nil AHTTP::File.new("#{__DIR__}/../spec/assets/foo.txt").realpath.should eq ::File.realpath(Path[__DIR__, "assets", "foo.txt"].to_s) end def test_basename : Nil AHTTP::File.new("#{__DIR__}/assets/foo.txt").basename.should eq "foo.txt" AHTTP::File.new("#{__DIR__}/assets/foo.txt").basename(".txt").should eq "foo" end def test_content : Nil AHTTP::File.new(__FILE__).content.should eq ::File.read __FILE__ end end ================================================ FILE: src/components/http/spec/header_utils_spec.cr ================================================ require "./spec_helper" struct AHTTP::HeaderUtilsTest < ASPEC::TestCase @[TestWith( {"foo", "foo"}, {"az09!#$%&'*.^_`|~-", "az09!#$%&'*.^_`|~-"}, {"\"foo bar\"", "foo bar"}, {"\"foo [bar]\"", "foo [bar]"}, {"\"foo \\\"bar\\\"\"", "foo \"bar\""}, {"\"foo \\\"\\b\\a\\r\\\"\"", "foo \"bar\""}, {"\"foo \\\\ bar\"", "foo \\ bar"}, )] def test_unquote(input : String, expected : String) : Nil AHTTP::HeaderUtils.unquote(input).should eq expected end @[TestWith( {[["foo", "123"]], {"foo" => "123"}}, {[["foo"]], {"foo" => true}}, {[["Foo"]], {"foo" => true}}, {[["foo", "123"], ["bar"]], {"foo" => "123", "bar" => true}} )] def test_combine(input : Array, expected : Hash) : Nil AHTTP::HeaderUtils.combine(input).should eq expected end def test_to_string : Nil AHTTP::HeaderUtils.to_string({"foo" => true}, ',').should eq "foo" AHTTP::HeaderUtils.to_string({"foo" => true, "bar" => true}, ';').should eq "foo;bar" AHTTP::HeaderUtils.to_string({"foo" => 123}, ',').should eq "foo=123" AHTTP::HeaderUtils.to_string({"foo" => "1 2 3"}, ',').should eq "foo=1\\ 2\\ 3" AHTTP::HeaderUtils.to_string({"foo" => "1 2 3", "bar" => true}, ',').should eq "foo=1\\ 2\\ 3,bar" # Named arg overload AHTTP::HeaderUtils.to_string("-", foo: true, bar: 2.0).should eq "foo-bar=2.0" # IO overload String.build do |io| io << '~' AHTTP::HeaderUtils.to_string io, {"foo" => true, "bar" => 100, "baz" => false}, "|" io << '~' end.should eq "~foo|bar=100|baz=false~" end @[DataProvider("headers_to_split")] def test_split(expected : Array, header : String, separator : String) : Nil AHTTP::HeaderUtils.split(header, separator).should eq expected end def headers_to_split : Tuple { {["foo=123", "bar"], "foo=123,bar", ","}, {["foo=123", "bar"], "foo=123, bar", ","}, {[["foo=123", "bar"]], "foo=123; bar", ",;"}, {[["foo=123"], ["bar"]], "foo=123, bar", ",;"}, {["foo", "123, bar"], "foo=123, bar", "="}, {["foo", "123, bar"], " foo = 123, bar ", "="}, {[["foo", "123"], ["bar"]], "foo=123, bar", ",="}, {[[["foo", "123"]], [["bar"], ["foo", "456"]]], "foo=123, bar;; foo=456", ",;="}, {[[["foo", "a,b;c=d"]]], "foo=\"a,b;c=d\"", ",;="}, {["foo", "bar"], "foo,,,, bar", ","}, {["foo", "bar"], ",foo, bar,", ","}, {["foo", "bar"], " , foo, bar, ", ","}, {["foo bar"], "foo \"bar\"", ","}, {["foo bar"], "\"foo\" bar", ","}, {["foo bar"], "\"foo\" \"bar\"", ","}, {[["foo_cookie", "foo=1&bar=2&baz=3"], ["expires", "Tue, 22-Sep-2020 06:27:09 GMT"], ["path", "/"]], "foo_cookie=foo=1&bar=2&baz=3; expires=Tue, 22-Sep-2020 06:27:09 GMT; path=/", ";="}, {[["foo_cookie", "foo=="], ["expires", "Tue, 22-Sep-2020 06:27:09 GMT"], ["path", "/"]], "foo_cookie=foo==; expires=Tue, 22-Sep-2020 06:27:09 GMT; path=/", ";="}, {[["foo_cookie", "foo="], ["expires", "Tue, 22-Sep-2020 06:27:09 GMT"], ["path", "/"]], "foo_cookie=foo=; expires=Tue, 22-Sep-2020 06:27:09 GMT; path=/", ";="}, {[["foo_cookie", "foo=a=b"], ["expires", "Tue, 22-Sep-2020 06:27:09 GMT"], ["path", "/"]], "foo_cookie=foo=\"a=b\"; expires=Tue, 22-Sep-2020 06:27:09 GMT; path=/", ";="}, # These are not a valid header values. We test that they parse anyway, and that both the valid and invalid parts are returned. {[] of String, "", ","}, {[] of String, ",,,", ","}, {[["", "foo"], ["bar", ""]], "=foo,bar=", ",="}, {["foo", "foobar", "baz"], "foo, foo\"bar\", \"baz", ","}, {["foo", "bar, baz"], "foo, \"bar, baz", ","}, {["foo", "bar, baz\\"], "foo, \"bar, baz\\", ","}, {["foo", "bar, baz\\"], "foo, \"bar, baz\\", ","}, } end @[DataProvider("dispositions")] def test_make_disposition(disposition : AHTTP::BinaryFileResponse::ContentDisposition, filename : String, fallback_filename : String?, expected : String) : Nil AHTTP::HeaderUtils.make_disposition(disposition, filename, fallback_filename).should eq expected end def dispositions : Tuple { {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, "foo.html", "foo.html", "attachment; filename=foo.html"}, {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, "foo.html", nil, "attachment; filename=foo.html"}, {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, "foo bar.html", nil, "attachment; filename=foo\\ bar.html"}, {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, %(foo "bar".html), nil, %(attachment; filename=foo\\ \\"bar\\".html)}, {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, "foo%20bar.html", "foo bar.html", "attachment; filename=foo\\ bar.html; filename*=utf-8''foo%2520bar.html"}, {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, "föö.html", "foo.html", "attachment; filename=foo.html; filename*=utf-8''f%C3%B6%C3%B6.html"}, } end @[DataProvider("invalid_dispositions")] def test_invalid_dispositions(disposition : AHTTP::BinaryFileResponse::ContentDisposition, filename : String, expected : String, fallback_filename : String? = nil) : Nil expect_raises ArgumentError, expected do AHTTP::HeaderUtils.make_disposition disposition, filename, fallback_filename end end def invalid_dispositions : Tuple { {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, "foo%20bar.html", "The fallback filename cannot contain the '%' character.", nil}, {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, "foo/bar.html", "The filename cannot include path separators.", nil}, {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, "/foo.html", "The filename cannot include path separators.", nil}, {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, "foo\\bar.html", "The filename cannot include path separators.", nil}, {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, "\\foo.html", "The filename cannot include path separators.", nil}, {AHTTP::BinaryFileResponse::ContentDisposition::Attachment, "foo.html", "The fallback filename cannot include path separators.", "f/oo.html"}, } end end ================================================ FILE: src/components/http/spec/ip_utils_spec.cr ================================================ require "./spec_helper" struct AHTTP::HeaderUtilsTest < ASPEC::TestCase def test_separate_caches_per_protocol : Nil ip = "192.168.52.1" subnet = "192.168.0.0/16" AHTTP::IPUtils.check_ipv6(ip, subnet).should be_false AHTTP::IPUtils.check_ipv4(ip, subnet).should be_true ip = "2a01:198:603:0:396e:4789:8e99:890f" subnet = "2a01:198:603:0::/65" AHTTP::IPUtils.check_ipv4(ip, subnet).should be_false AHTTP::IPUtils.check_ipv6(ip, subnet).should be_true end @[DataProvider("ipv4_data")] def test_check_ipv4(is_match : Bool, remote_address : String, cidr : String | Array(String)) : Nil AHTTP::IPUtils.check(remote_address, cidr).should eq is_match end def ipv4_data : Tuple { {true, "192.168.1.1", "192.168.1.1"}, {true, "192.168.1.1", "192.168.1.1/1"}, {true, "192.168.1.1", "192.168.1.0/24"}, {false, "192.168.1.1", "1.2.3.4/1"}, {false, "192.168.1.1", "192.168.1.1/33"}, # invalid subnet {true, "192.168.1.1", ["1.2.3.4/1", "192.168.1.0/24"]}, {true, "192.168.1.1", ["192.168.1.0/24", "1.2.3.4/1"]}, {false, "192.168.1.1", ["1.2.3.4/1", "4.3.2.1/1"]}, {true, "1.2.3.4", "0.0.0.0/0"}, {true, "1.2.3.4", "192.168.1.0/0"}, {false, "1.2.3.4", "256.256.256/0"}, # invalid CIDR notation {false, "an_invalid_ip", "192.168.1.0/24"}, {false, "", "1.2.3.4/1"}, } end @[DataProvider("ipv6_data")] def test_check_ipv6(is_match : Bool, remote_address : String, cidr : String | Array(String)) : Nil AHTTP::IPUtils.check(remote_address, cidr).should eq is_match end def ipv6_data : Tuple { {true, "2a01:198:603:0:396e:4789:8e99:890f", "2a01:198:603:0::/65"}, {false, "2a00:198:603:0:396e:4789:8e99:890f", "2a01:198:603:0::/65"}, {false, "2a01:198:603:0:396e:4789:8e99:890f", "::1"}, {true, "0:0:0:0:0:0:0:1", "::1"}, {false, "0:0:603:0:396e:4789:8e99:0001", "::1"}, {true, "0:0:603:0:396e:4789:8e99:0001", "::/0"}, {true, "0:0:603:0:396e:4789:8e99:0001", "2a01:198:603:0::/0"}, {true, "2a01:198:603:0:396e:4789:8e99:890f", ["::1", "2a01:198:603:0::/65"]}, {true, "2a01:198:603:0:396e:4789:8e99:890f", ["2a01:198:603:0::/65", "::1"]}, {false, "2a01:198:603:0:396e:4789:8e99:890f", ["::1", "1a01:198:603:0::/65"]}, {false, "}__test|O:21:"JDatabaseDriverMysqli":3:{s:2", "::1"}, {false, "2a01:198:603:0:396e:4789:8e99:890f", "unknown"}, {false, "", "::1"}, {false, "127.0.0.1", "::1"}, {false, "0.0.0.0/8", "::1"}, {false, "::1", "127.0.0.1"}, {false, "::1", "0.0.0.0/8"}, {true, "::ffff:10.126.42.2", "::ffff:10.0.0.0/0"}, } end @[DataProvider("invalid_ip_address_data")] def test_invalid_ip_addresses(remote_address : String, cidr : String | Array(String)) : Nil AHTTP::IPUtils.check_ipv4(remote_address, cidr).should be_false end def invalid_ip_address_data : Hash { "invalid proxy wildcard" => {"192.168.20.13", "*"}, "invalid proxy missing netmask" => {"192.168.20.13", "0.0.0.0"}, "invalid request IP with invalid proxy wildcard" => {"0.0.0.0", "*"}, } end @[DataProvider("ipv4_zero_mask_data")] def test_check_ipv4_zero_mask_data(is_match : Bool, remote_address : String, cidr : String | Array(String)) : Nil AHTTP::IPUtils.check_ipv4(remote_address, cidr).should eq is_match end def ipv4_zero_mask_data : Tuple { {true, "1.2.3.4", "0.0.0.0/0"}, {true, "1.2.3.4", "192.168.1.0/0"}, {false, "1.2.3.4", "256.256.256/0"}, # invalid CIDR notation } end end ================================================ FILE: src/components/http/spec/parameter_bag_spec.cr ================================================ require "./spec_helper" private alias DATATYPE = Hash(String, Int32 | String) describe AHTTP::ParameterBag do describe "#has?" do it "returns false if that value isn't in the bag" do bag = AHTTP::ParameterBag.new bag.has?("value").should be_false end it "returns true if that value is in the bag" do bag = AHTTP::ParameterBag.new bag.set "value", "foo" bag.has?("value").should be_true end describe "with type" do it "returns true with a valid type" do bag = AHTTP::ParameterBag.new bag.set "value", "foo" bag.set "num", 1 bag.set "nil", nil bag.has?("value", String).should be_true bag.has?("num", Int32).should be_true bag.has?("nil", Nil).should be_true end it "returns false with a invalid type" do bag = AHTTP::ParameterBag.new bag.set "value", "foo" bag.set "num", 1 bag.set "nil", nil bag.has?("value", Int32).should be_false bag.has?("nil", Int32).should be_false bag.has?("num", String).should be_false bag.has?("num", String?).should be_false bag.has?("num", Int32?).should be_false bag.has?("nil", Int32?).should be_false end end end describe "#get?" do it "returns nil if the value is missing" do bag = AHTTP::ParameterBag.new bag.get?("value").should be_nil end it "returns the value is set" do bag = AHTTP::ParameterBag.new bag.set "value", "foo" bag.get?("value").should eq "foo" end describe "with a complex T" do it "returns an nilable T" do bag = AHTTP::ParameterBag.new bag.set "data", {"foo" => "bar", "baz" => 10}, DATATYPE data = bag.get "data", DATATYPE data.class.should eq DATATYPE data["foo"].should eq "bar" end end it "returns nil if the value is set, but of a different type" do bag = AHTTP::ParameterBag.new bag.set "value", "foo" bag.get?("value", Int32).should be_nil end end describe "#get" do describe "by name" do it "raises if the value is missing" do bag = AHTTP::ParameterBag.new expect_raises KeyError, "No parameter exists with the name 'value'." do bag.get "value" end end it "returns the value is set" do bag = AHTTP::ParameterBag.new bag.set "value", "foo" bag.get("value").should eq "foo" end it "is able to get falsey values" do bag = AHTTP::ParameterBag.new bag.set "n", nil bag.set "f", false bag.get("n").should be_nil bag.get("f").should be_false end end describe "by name and type" do it String do bag = AHTTP::ParameterBag.new bag.set "value", "foo" value = bag.get "value", String value.should eq "foo" value.class.should eq String end it Bool do bag = AHTTP::ParameterBag.new bag.set "value", true value = bag.get "value", Bool value.should be_true value.class.should eq Bool end it Int do bag = AHTTP::ParameterBag.new bag.set "value", 123 value = bag.get "value", Int32 value.should eq 123 value.class.should eq Int32 end it Float do bag = AHTTP::ParameterBag.new bag.set "value", 3.14 value = bag.get "value", Float64 value.should eq 3.14 value.class.should eq Float64 end it Union do bag = AHTTP::ParameterBag.new bag.set "pi", 3.14, Float64 bag.set "e", 2.71, Float64 bag.set "fav", 16, Int32 bag.set "data", {"foo" => "bar", "baz" => 10}, DATATYPE a, b, c = bag.get("pi", Float64), bag.get("e", Float64), bag.get("fav", Int32) (a + b + c).should eq 21.85 data = bag.get "data", DATATYPE data.class.should eq DATATYPE data["foo"].should eq "bar" end end end describe "#set" do it "with name, type, and value" do AHTTP::ParameterBag.new.set("value", "foo", String) end it "with name and value" do end it "with a hash" do bag = AHTTP::ParameterBag.new bag.set({"key" => "value", "age" => 123}) bag.get("key").should eq "value" bag.get("age").should eq 123 end end it "#remove" do bag = AHTTP::ParameterBag.new bag.set "value", "foo" bag.has?("value").should be_true bag.remove "value" bag.has?("value").should be_false end end ================================================ FILE: src/components/http/spec/redirect_response_spec.cr ================================================ require "./spec_helper" describe AHTTP::RedirectResponse do describe ".new" do it "raises if the url is empty" do expect_raises(ArgumentError, "Cannot redirect to an empty URL.") do AHTTP::RedirectResponse.new "" end end it "allows passing a `Path` instance" do response = AHTTP::RedirectResponse.new Path["/app/assets/foo.txt"] response.status.should eq ::HTTP::Status::FOUND response.headers["location"].should eq "/app/assets/foo.txt" response.content.should be_empty end end describe "#status" do it "defaults to 302" do AHTTP::RedirectResponse.new("address").status.should eq ::HTTP::Status::FOUND end it "disallows non redirect codes" do expect_raises(ArgumentError, "'422' is not an HTTP redirect status code.") do AHTTP::RedirectResponse.new("address", 422) end end it Int do AHTTP::RedirectResponse.new("address", 301).status.should eq ::HTTP::Status::MOVED_PERMANENTLY end it ::HTTP::Status do AHTTP::RedirectResponse.new("address", ::HTTP::Status::MOVED_PERMANENTLY).status.should eq ::HTTP::Status::MOVED_PERMANENTLY end end describe "#headers" do it "with an empty url" do expect_raises(ArgumentError, "Cannot redirect to an empty URL.") do AHTTP::RedirectResponse.new("") end end it "adds the location header" do AHTTP::RedirectResponse.new("address").headers["location"].should eq "address" end end end ================================================ FILE: src/components/http/spec/request_matcher/attributes_spec.cr ================================================ require "../spec_helper" struct AttributesRequestMatcherTest < ASPEC::TestCase @[TestWith( {"foo", %r(foo_.*), true}, {"foo", %r(foo), true}, {"foo", %r(^foo_bar$), true}, {"foo", %r(barbar), false}, {"some_num", %r(\d\d), false}, )] def test_matches(key : String, regex : Regex, is_match : Bool) : Nil matcher = AHTTP::RequestMatcher::Attributes.new({key => regex}) request = AHTTP::Request.new "GET", "/admin/foo" request.attributes.set "foo", "foo_bar" request.attributes.set "some_num", 42 matcher.matches?(request).should eq is_match end end ================================================ FILE: src/components/http/spec/request_matcher/header_spec.cr ================================================ require "../spec_helper" struct HeaderRequestMatcherTest < ASPEC::TestCase @[TestWith( {::HTTP::Headers{"x-foo" => "foo", "bar" => "bar", "baz" => "baz"}, true}, {::HTTP::Headers{"x-foo" => "foo", "bar" => "bar"}, true}, {::HTTP::Headers{"bar" => "bar", "baz" => "baz"}, false}, {::HTTP::Headers{"bar" => "bar"}, false}, {::HTTP::Headers.new, false}, )] def test_matches(headers : ::HTTP::Headers, is_match : Bool) : Nil matcher = AHTTP::RequestMatcher::Header.new "x-foo", "bar" request = AHTTP::Request.new "GET", "/" headers.each do |k, v| request.headers[k] = v end matcher.matches?(request).should eq is_match end end ================================================ FILE: src/components/http/spec/request_matcher/hostname_spec.cr ================================================ require "../spec_helper" struct HostnameRequestMatcherTest < ASPEC::TestCase @[TestWith( { %r(.*.example.com), true }, { %r(.example.com$), true }, { %r(^.*.example.com$), true }, { %r(.*.crystal.com), false }, { %r(.*.example.COM), true }, { %r(.example.COM$), true }, { %r(^.*.example.COM$), true }, { %r(.*.crystal.COM), false }, )] def test_matches(regex : Regex, is_match : Bool) : Nil matcher = AHTTP::RequestMatcher::Hostname.new regex request = AHTTP::Request.new "GET", "/", ::HTTP::Headers{"host" => "foo.example.com"} matcher.matches?(request).should eq is_match end end ================================================ FILE: src/components/http/spec/request_matcher/method_spec.cr ================================================ require "../spec_helper" struct MethodRequestMatcherTest < ASPEC::TestCase @[TestWith( {"get", "get", true}, {"get", ["get", "post"], true}, {"get", "post", false}, {"get", "GET", true}, {"get", ["GET", "POST"], true}, {"get", "POST", false}, )] def test_matches(request_method : String, matcher_methods : String | Enumerable(String), is_match : Bool) : Nil matcher = AHTTP::RequestMatcher::Method.new matcher_methods request = AHTTP::Request.new request_method, "/" matcher.matches?(request).should eq is_match end end ================================================ FILE: src/components/http/spec/request_matcher/path_spec.cr ================================================ require "../spec_helper" struct PathRequestMatcherTest < ASPEC::TestCase @[TestWith( { %r(/admin/.*), true }, { %r(/admin), true }, { %r(^/admin/.*$), true }, { %r(/blog/.*), false }, )] def test_matches(regex : Regex, is_match : Bool) : Nil matcher = AHTTP::RequestMatcher::Path.new regex request = AHTTP::Request.new "GET", "/admin/foo" matcher.matches?(request).should eq is_match end def test_encoded_characters : Nil matcher = AHTTP::RequestMatcher::Path.new %r(^/admin/fo o*$) request = AHTTP::Request.new "GET", "/admin/fo%20o" matcher.matches?(request).should be_true end end ================================================ FILE: src/components/http/spec/request_matcher/query_parameter_spec.cr ================================================ require "../spec_helper" struct QueryParameterRequestMatcherTest < ASPEC::TestCase @[TestWith( {"foo=&bar=", true}, {"foo=foo1&bar=bar1", true}, {"foo=foo1&bar=bar1&baz=baz1", true}, {"foo=", false}, {"", false}, )] def test_matches(query_string : String, is_match : Bool) : Nil matcher = AHTTP::RequestMatcher::QueryParameter.new "foo", "bar" request = AHTTP::Request.new "GET", "/" request.query = query_string matcher.matches?(request).should eq is_match end end ================================================ FILE: src/components/http/spec/request_matcher_spec.cr ================================================ require "./spec_helper" describe AHTTP::RequestMatcher do it "matches" do matcher = AHTTP::RequestMatcher.new( AHTTP::RequestMatcher::Path.new(%r(/admin/foo)), AHTTP::RequestMatcher::Method.new("GET"), ) matcher.matches?(AHTTP::Request.new "GET", "/admin/foo").should be_true end it "does not match" do matcher = AHTTP::RequestMatcher.new( AHTTP::RequestMatcher::Method.new("POST"), AHTTP::RequestMatcher::Path.new(%r(/admin/foo)), ) matcher.matches?(AHTTP::Request.new "GET", "/admin/foo").should be_false end end ================================================ FILE: src/components/http/spec/request_spec.cr ================================================ require "./spec_helper" struct AHTTP::RequestTest < ASPEC::TestCase def tear_down : Nil AHTTP::Request.set_trusted_hosts [] of Regex AHTTP::Request.set_trusted_proxies [] of String, :none AHTTP::Request.trusted_header_overrides.clear end def test_construct_with_self : Nil request = AHTTP::Request.new "GET", "/" request2 = AHTTP::Request.new request request2.should be request end # This spec tests the built-in `#hostname` method def test_hostname : Nil request = AHTTP::Request.new "GET", "/" request.hostname.should be_nil request = AHTTP::Request.new "GET", "/", ::HTTP::Headers{"host" => "www.domain.com"} request.hostname.should eq "www.domain.com" request = AHTTP::Request.new "GET", "/", ::HTTP::Headers{"host" => "www.domain.com:8080"} request.hostname.should eq "www.domain.com" request = AHTTP::Request.new "GET", "/", ::HTTP::Headers{"host" => "[::1]:8080"} request.hostname.should eq "::1" end def test_content_type_format_present : Nil AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "content-type" => "application/json", }).content_type_format.should eq "json" end def test_content_type_format_missing : Nil AHTTP::Request.new("GET", "/").content_type_format.should be_nil end @[DataProvider("mime_type_provider")] def test_mime_type(format : String, mime_types : Indexable(String)) : Nil request = AHTTP::Request.new "GET", "/" mime_types.each do |mt| request.format(mt).should eq format end request.class.register_format format, mime_types mime_types.each do |mt| request.format(mt).should eq format if !format.nil? mime_types[0].should eq request.mime_type format end end end def test_request_format : Nil request = AHTTP::Request.new "GET", "/" request.request_format.should eq "json" request.request_format("html").should eq "html" request.request_format("json").should eq "json" request = AHTTP::Request.new "GET", "/" request.request_format(nil).should be_nil request = AHTTP::Request.new "GET", "/" request.request_format = "foo" request.request_format.should eq "foo" end def mime_type_provider : Tuple { {"txt", {"text/plain"}}, {"js", {"application/javascript", "application/x-javascript", "text/javascript"}}, {"css", {"text/css"}}, {"json", {"application/json", "application/x-json"}}, {"jsonld", {"application/ld+json"}}, {"xml", {"text/xml", "application/xml", "application/x-xml"}}, {"rdf", {"application/rdf+xml"}}, {"atom", {"application/atom+xml"}}, {"form", {"application/x-www-form-urlencoded", "multipart/form-data"}}, {"hal", {"application/hal+json", "application/hal+xml"}}, {"jsonapi", {"application/vnd.api+json"}}, {"pdf", {"application/pdf"}}, {"problem", {"application/problem+json"}}, {"soap", {"application/soap+xml"}}, {"wbxml", {"application/vnd.wap.wbxml"}}, {"yaml", {"text/yaml", "application/x-yaml"}}, } end @[DataProvider("structured_suffix_format_provider")] def test_structured_suffix_format(mime_type : String, expected : String?) : Nil AHTTP::Request.new("GET", "/").format(mime_type).should eq expected end def structured_suffix_format_provider : Tuple { {"application/vnd.github+json", "json"}, {"application/vnd.oci.image.manifest.v1+json", "json"}, {"application/foo+xml", "xml"}, {"application/foo+yaml", "yaml"}, {"application/foo+cbor", "cbor"}, {"application/ber-stream+ber", "asn1"}, {"application/foo+json; charset=utf-8", "json"}, {"application/ld+json", "jsonld"}, {"application/vnd.api+json", "jsonapi"}, {"text/vnd.foo+xml", nil}, } end @[DataProvider("subtype_fallback_provider")] def test_format_subtype_fallback(mime_type : String, subtype_fallback : Bool, expected : String?) : Nil AHTTP::Request.new("GET", "/").format(mime_type, subtype_fallback: subtype_fallback).should eq expected end def subtype_fallback_provider : Tuple { {"application/unknown", false, nil}, {"application/foo", false, nil}, {"application/x-foo", false, nil}, {"application/foo", true, "foo"}, {"application/x-foo", true, "foo"}, {"application/foo+bar", true, nil}, {"text/unknown", true, "unknown"}, {"garbage", true, nil}, {"application/json", true, "json"}, } end def test_trusted_proxy_conflict : Nil AHTTP::Request.set_trusted_proxies ["3.3.3.3"], AHTTP::Request::ProxyHeader[:forwarded, :forwarded_proto] request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "forwarded" => "proto=http", "x-forwarded-proto" => "https", }) request.remote_address = Socket::IPAddress.v4 3, 3, 3, 3, port: 1 expect_raises AHTTP::Exception::ConflictingHeaders, "The request has both a trusted 'forwarded' header and a trusted 'x-forwarded-proto' header, conflicting with each other. You should either configure your proxy to remove one of them, or configure your project to distrust the offending one." do request.secure? end end def test_trusted_proxies_cache : Nil request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "x-forwarded-for" => "1.1.1.1, 2.2.2.2", "x-forwarded-proto" => "https", }) request.remote_address = Socket::IPAddress.v4 3, 3, 3, 3, port: 1 request.secure?.should be_false AHTTP::Request.set_trusted_proxies ["3.3.3.3", "2.2.2.2"], AHTTP::Request::ProxyHeader[:forwarded_for, :forwarded_host, :forwarded_port, :forwarded_proto] request.secure?.should be_true # Cache must not be hit due to change in header request.headers["x-forwarded-proto"] = "http" request.secure?.should be_false end def test_trusted_proxies_forwarded_for : Nil request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "x-forwarded-for" => "1.1.1.1, 2.2.2.2", "x-forwarded-host" => "foo.example.com:1234, real.example.com:8080", "x-forwarded-proto" => "https", "x-forwarded-port" => "443", }) request.remote_address = Socket::IPAddress.v4 3, 3, 3, 3, port: 1 # No trusted proxies request.from_trusted_proxy?.should be_false request.host.should eq "example.com" request.port.should eq 80 request.secure?.should be_false # Disabling proxy trusting AHTTP::Request.set_trusted_proxies [] of String, :forwarded_for request.from_trusted_proxy?.should be_false request.host.should eq "example.com" request.port.should eq 80 request.secure?.should be_false # Request is from non trusted proxy AHTTP::Request.set_trusted_proxies ["2.2.2.2"], :forwarded_for request.from_trusted_proxy?.should be_false request.host.should eq "example.com" request.port.should eq 80 request.secure?.should be_false # Trusted proxy AHTTP::Request.set_trusted_proxies ["3.3.3.3", "2.2.2.2"], AHTTP::Request::ProxyHeader[:forwarded_for, :forwarded_host, :forwarded_port, :forwarded_proto] request.from_trusted_proxy?.should be_true request.host.should eq "foo.example.com" request.port.should eq 443 request.secure?.should be_true # Trusted proxy AHTTP::Request.set_trusted_proxies ["3.3.3.4", "2.2.2.2"], AHTTP::Request::ProxyHeader[:forwarded_for, :forwarded_host, :forwarded_port, :forwarded_proto] request.from_trusted_proxy?.should be_false request.host.should eq "example.com" request.port.should eq 80 request.secure?.should be_false # Alternate proto header values AHTTP::Request.set_trusted_proxies ["3.3.3.3"], :forwarded_proto request.headers["x-forwarded-proto"] = "ssl" request.secure?.should be_true request.headers["x-forwarded-proto"] = "https, http" request.secure?.should be_true end def test_trusted_proxies_forwarded : Nil request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "forwarded" => "for=1.1.1.1, host=foo.example.com:8080, proto=https, for=2.2.2.2, host=real.example.com:8080", }) request.remote_address = Socket::IPAddress.v4 3, 3, 3, 3, port: 1 # No trusted proxies request.from_trusted_proxy?.should be_false request.host.should eq "example.com" request.port.should eq 80 request.secure?.should be_false # Disabling proxy trusting AHTTP::Request.set_trusted_proxies [] of String, :forwarded request.from_trusted_proxy?.should be_false request.host.should eq "example.com" request.port.should eq 80 request.secure?.should be_false # Request is from non trusted proxy AHTTP::Request.set_trusted_proxies ["2.2.2.2"], :forwarded request.from_trusted_proxy?.should be_false request.host.should eq "example.com" request.port.should eq 80 request.secure?.should be_false # Trusted proxy AHTTP::Request.set_trusted_proxies ["3.3.3.3", "2.2.2.2"], :forwarded request.from_trusted_proxy?.should be_true request.host.should eq "foo.example.com" request.port.should eq 8080 request.secure?.should be_true # Trusted proxy AHTTP::Request.set_trusted_proxies ["3.3.3.4", "2.2.2.2"], :forwarded request.from_trusted_proxy?.should be_false request.host.should eq "example.com" request.port.should eq 80 request.secure?.should be_false # Alternate proto header values AHTTP::Request.set_trusted_proxies ["3.3.3.3"], :forwarded request.headers["forwarded"] = "proto=ssl" request.secure?.should be_true request.headers["forwarded"] = "proto=https, proto=http" request.secure?.should be_true end @[TestWith( { %(a#{".a"*40_000}) }, {":" * 101} )] def test_very_long_hosts(host : String) : Nil request = AHTTP::Request.new "GET", "/", headers: ::HTTP::Headers{"host" => host} request.host.should eq host end @[TestWith( {".a", false, nil, nil}, {"a..", false, nil, nil}, {"a.", true, nil, nil}, {"é", false, nil, nil}, {"[::1]", true, nil, nil}, {"[::1]:80", true, "[::1]", 80}, {"." * 101, false, nil, nil}, )] def test_host_valididy(host : String, is_valid : Bool, expected_host : String?, expected_port : Int32?) : Nil request = AHTTP::Request.new "GET", "/", headers: ::HTTP::Headers{"host" => host} if is_valid request.host.should eq expected_host || host if expected_port request.port.should eq expected_port end else expect_raises AHTTP::Exception::SuspiciousOperation, "Invalid Host: " do request.host end end end def test_trusted_host_localhost : Nil AHTTP::Request.set_trusted_proxies ["1.1.1.1"], :all request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "forwarded" => "host=localhost:8080", "x-forwarded-host" => "localhost:8080", }) request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.from_trusted_proxy?.should be_true request.host.should eq "localhost" request.port.should eq 8080 end def test_trusted_host_ipv6 : Nil AHTTP::Request.set_trusted_proxies ["1.1.1.1"], :all request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "forwarded" => "host=\"[::1]:443\"", "x-forwarded-host" => "[::1]:443", "x-forwarded-port" => "443", }) request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.from_trusted_proxy?.should be_true request.host.should eq "[::1]" request.port.should eq 443 end def test_safe? : Nil AHTTP::Request.new("GET", "/").safe?.should be_true AHTTP::Request.new("HEAD", "/").safe?.should be_true AHTTP::Request.new("OPTIONS", "/").safe?.should be_true AHTTP::Request.new("TRACE", "/").safe?.should be_true AHTTP::Request.new("POST", "/").safe?.should be_false AHTTP::Request.new("PUT", "/").safe?.should be_false end def test_port_no_host_header : Nil AHTTP::Request.new("GET", "/").port.should eq 80 end @[TestWith( domain: {"test.com:90", 90}, ipv4: {"127.0.0.1:90", 90}, ipv6: {"[::1]:90", 90}, no_port: {"test.com", 80}, )] def test_port(host : String, port : Int32?) : Nil AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{"host" => host}).port.should eq port end def test_port_trusted_port : Nil AHTTP::Request.set_trusted_proxies ["1.1.1.1"], :forwarded_port request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "forwarded" => "host=localhost:8080", "x-forwarded-port" => "8080", }) request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.port.should eq 8080 request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "forwarded" => "host=localhost", "x-forwarded-port" => "80", }) request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.port.should eq 80 request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "forwarded" => "host=\"[::1]\"", "x-forwarded-proto" => "https", "x-forwarded-port" => "443", }) request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.port.should eq 443 end def test_port_trusted_does_not_default_to_0 : Nil AHTTP::Request.set_trusted_proxies ["1.1.1.1"], :forwarded_for request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "localhost", "x-forwarded-host" => "test.example.com", "x-forwarded-port" => "", }) request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.port.should eq 80 end def test_port_trusted_proxies_none_set : Nil request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "x-forwarded-proto" => "https", "x-forwarded-port" => "443", }) # Ignored without trusted proxy request.port.should eq 80 end def test_port_trusted_proxies_proto_port_set : Nil AHTTP::Request.set_trusted_proxies ["1.1.1.1"], AHTTP::Request::ProxyHeader[:forwarded_proto, :forwarded_port] request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "x-forwarded-proto" => "https", "x-forwarded-port" => "8443", }) # Falls back on scheme on untrusted connection request.port.should eq 80 request.scheme.should eq "http" # Uses proxy value if trusted request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.port.should eq 8443 request.scheme.should eq "https" end def test_port_trusted_proxies_proto_set_https : Nil AHTTP::Request.set_trusted_proxies ["1.1.1.1"], AHTTP::Request::ProxyHeader[:forwarded_proto, :forwarded_port] request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "x-forwarded-proto" => "https", }) # Falls back on scheme on untrusted connection request.port.should eq 80 # With only proto, falls back on default port for this scheme request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.port.should eq 443 end def test_port_trusted_proxies_proto_set_http : Nil AHTTP::Request.set_trusted_proxies ["1.1.1.1"], AHTTP::Request::ProxyHeader[:forwarded_proto, :forwarded_port] request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "x-forwarded-proto" => "http", }) # Falls back on scheme on untrusted connection request.port.should eq 80 # With only proto, falls back on default port for this scheme request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.port.should eq 80 end def test_port_trusted_proxies_proto_on : Nil AHTTP::Request.set_trusted_proxies ["1.1.1.1"], AHTTP::Request::ProxyHeader[:forwarded_proto, :forwarded_port] request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "x-forwarded-proto" => "On", }) # Falls back on scheme on untrusted connection request.port.should eq 80 # With only proto, falls back on default port for this scheme request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.port.should eq 443 end def test_port_trusted_proxies_proto_one : Nil AHTTP::Request.set_trusted_proxies ["1.1.1.1"], AHTTP::Request::ProxyHeader[:forwarded_proto, :forwarded_port] request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "x-forwarded-proto" => "1", }) # Falls back on scheme on untrusted connection request.port.should eq 80 # With only proto, falls back on default port for this scheme request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.port.should eq 443 end def test_port_trusted_proxies_proto_invalid : Nil AHTTP::Request.set_trusted_proxies ["1.1.1.1"], AHTTP::Request::ProxyHeader[:forwarded_proto, :forwarded_port] request = AHTTP::Request.new("GET", "/", headers: ::HTTP::Headers{ "host" => "example.com", "x-forwarded-proto" => "foo", }) request.remote_address = Socket::IPAddress.v4 1, 1, 1, 1, port: 1 request.port.should eq 80 end def test_proxy_header_header_default : Nil AHTTP::Request::ProxyHeader::FORWARDED_PROTO.header.should eq "x-forwarded-proto" end def test_proxy_header_header_override : Nil AHTTP::Request.override_trusted_header :forwarded_proto, "foo-proto" AHTTP::Request::ProxyHeader::FORWARDED_PROTO.header.should eq "foo-proto" end def test_truested_host_not_set : Nil request = AHTTP::Request.new "GET", "/", headers: ::HTTP::Headers{ "host" => "evil.com", } request.host.should eq "evil.com" end def test_truested_host_untrusted : Nil # Add trusted domain, including subdomains AHTTP::Request.set_trusted_hosts([/^([a-z]{9}\.)?trusted\.com$/]) request = AHTTP::Request.new "GET", "/", headers: ::HTTP::Headers{ "host" => "evil.com", } # Untrusted host expect_raises AHTTP::Exception::SuspiciousOperation, "Untrusted Host: 'evil.com'" do request.host end end def test_truested_host_trusted : Nil # Add trusted domain, including subdomains AHTTP::Request.set_trusted_hosts([/^([a-z]{9}\.)?trusted\.com$/]) request = AHTTP::Request.new "GET", "/" # Trusted host request.headers["host"] = "trusted.com" request.host.should eq "trusted.com" request.port.should eq 80 request.headers["host"] = "trusted.com:8080" request.host.should eq "trusted.com" request.port.should eq 8080 request.headers["host"] = "subdomain.trusted.com:8080" request.host.should eq "subdomain.trusted.com" end def test_truested_host_special_characters : Nil AHTTP::Request.set_trusted_hosts([/localhost(\.local){0,1}#,example.com/, /localhost/]) request = AHTTP::Request.new "GET", "/" request.headers["host"] = "localhost" request.host.should eq "localhost" end def test_request_data : Nil request = AHTTP::Request.new "GET", "/", body: "foo=bar&biz=baz" params = request.request_data params.should eq p = URI::Params.new({"foo" => ["bar"], "biz" => ["baz"]}) request.request_data.should eq p end end ================================================ FILE: src/components/http/spec/response_headers_spec.cr ================================================ require "./spec_helper" describe AHTTP::Response::Headers do describe "#initialize" do it "sets the date on creation" do headers = AHTTP::Response::Headers.new headers.has_key?("date").should be_true headers.date.should_not(be_nil).should be_close Time.utc, 1.second end it "uses the provided date if supplied" do time = ::HTTP.format_time Time.utc 2021, 4, 7, 12, 0, 0 headers = AHTTP::Response::Headers{"date" => time} headers["date"].should eq time end it "copies multiple headers with the same key" do headers = AHTTP::Response::Headers{"foo" => ["one", "two", "three"]} headers["foo"].should eq "one,two,three" end it "sets the proper `cache-control` header based on the provided ::HTTP::Headers object" do headers = AHTTP::Response::Headers.new ::HTTP::Headers{"expires" => "Sat, 10 Apr 2021 15:14:59 GMT"} headers["cache-control"].should eq "private, must-revalidate" headers = AHTTP::Response::Headers.new ::HTTP::Headers{"expires" => "Sat, 10 Apr 2021 15:14:59 GMT", "cache-control" => "max-age=3600"} headers["cache-control"].should eq "max-age=3600, private" end end describe "#<<" do it "with a cookie" do headers = AHTTP::Response::Headers.new headers.cookies.should be_empty headers << ::HTTP::Cookie.new "key", "value" headers.cookies["key"].should eq ::HTTP::Cookie.new "key", "value" end end describe "#[]=" do it "with string value" do headers = AHTTP::Response::Headers.new headers["cache-control"].should eq "no-cache, private" headers.has_cache_control_directive?("no-cache").should be_true headers["cache-control"] = "public" headers["cache-control"].should eq "public" headers.has_cache_control_directive?("public").should be_true headers["Cache-Control"] = "private" headers["cache-control"].should eq "private" headers.has_cache_control_directive?("private").should be_true end it "string value with set-cookie key" do headers = AHTTP::Response::Headers.new headers["set-cookie"] = "name=value; Secure" headers.cookies["name"].value.should eq "value" headers.cookies["name"].secure.should be_true end it "with an array of set-cookie values" do headers = AHTTP::Response::Headers.new headers["set-cookie"] = ["name=value; Secure", "foo=bar"] headers.cookies["name"].value.should eq "value" headers.cookies["name"].secure.should be_true headers.cookies["foo"].value.should eq "bar" headers.cookies["foo"].secure.should be_false end it "with non string value" do headers = AHTTP::Response::Headers.new headers["key"] = 10 headers["key"].should eq "10" end it "with cookie value" do headers = AHTTP::Response::Headers.new headers["name"] = ::HTTP::Cookie.new "name", "value" headers.cookies["name"].value.should eq "value" end end describe "#date" do it "with a missing key" do headers = AHTTP::Response::Headers.new headers.date("foo").should be_nil end it "with a missing key and custom default" do headers = AHTTP::Response::Headers.new time = Time.utc 2021, 4, 7, 12, 0, 0 headers.date("foo", time).should eq time end it "with an invalid datetime string" do AHTTP::Response::Headers{"date" => "foo"}.date.should be_nil end end describe "#delete" do it "deletes the provided header" do headers = AHTTP::Response::Headers{"foo" => "bar"} headers.has_key?("foo").should be_true headers.delete "foo" headers.has_key?("foo").should be_false end it "removes cache-control header" do headers = AHTTP::Response::Headers{"expires" => "Sat, 10 Apr 2021 15:14:59 GMT"} headers.has_cache_control_directive?("must-revalidate").should be_true headers.delete "cache-control" headers.has_cache_control_directive?("must-revalidate").should be_false end it "reinitializes the date if deleted" do time = ::HTTP.format_time Time.utc 2021, 4, 7, 12, 0, 0 headers = AHTTP::Response::Headers{"date" => time} headers.delete "date" headers.has_key?("date").should be_true headers["date"].should_not eq time end it "removes cookies when deleting set-cookie" do headers = AHTTP::Response::Headers.new headers.cookies << ::HTTP::Cookie.new "name", "value" headers.cookies["name"].value.should eq "value" headers.delete "set-cookie" headers.cookies.should be_empty end end it "#get_cache_control_directive" do headers = AHTTP::Response::Headers.new headers.add_cache_control_directive "private" headers.get_cache_control_directive("private").should be_true headers.get_cache_control_directive("public").should be_nil end describe "cache-control" do it "uses defaults to conservative values" do headers = AHTTP::Response::Headers.new headers["cache-control"].should eq "no-cache, private" headers.has_cache_control_directive?("no-cache").should be_true end it "uses what's provided if provided" do headers = AHTTP::Response::Headers{"cache-control" => "public"} headers["cache-control"].should eq "public" headers.has_cache_control_directive?("public").should be_true end it "does not add anything if an etag is included" do headers = AHTTP::Response::Headers{"etag" => "abc123"} headers["cache-control"].should eq "no-cache, private" headers.has_cache_control_directive?("private").should be_true headers.has_cache_control_directive?("no-cache").should be_true headers.has_cache_control_directive?("max-age").should be_false end it "includes special directive with last-modified header" do AHTTP::Response::Headers{"expires" => "Sat, 10 Apr 2021 15:14:59 GMT"}["cache-control"].should eq "private, must-revalidate" AHTTP::Response::Headers{"last-modified" => "Sat, 10 Apr 2021 15:14:59 GMT"}["cache-control"].should eq "private, must-revalidate" AHTTP::Response::Headers{"last-modified" => "Sat, 10 Apr 2021 15:14:59 GMT", "etag" => "abc123"}["cache-control"].should eq "private, must-revalidate" AHTTP::Response::Headers{"last-modified" => "Sat, 10 Apr 2021 15:14:59 GMT", "expires" => "Sat, 10 Apr 2021 15:14:59 GMT"}["cache-control"].should eq "private, must-revalidate" end it "adds 'private' to existing cache-control header that doesn't have private or public" do AHTTP::Response::Headers{"expires" => "Sat, 10 Apr 2021 15:14:59 GMT", "cache-control" => "max-age=3600"}["cache-control"].should eq "max-age=3600, private" AHTTP::Response::Headers{"cache-control" => "max-age=3600", "expires" => "Sat, 10 Apr 2021 15:14:59 GMT"}["cache-control"].should eq "max-age=3600, private" end it "does not add private or public with s-maxage" do AHTTP::Response::Headers{"cache-control" => "s-maxage=100"}["cache-control"].should eq "s-maxage=100" end it "does not alter with multiple directives" do AHTTP::Response::Headers{"cache-control" => "private, max-age=100"}["cache-control"].should eq "private, max-age=100" AHTTP::Response::Headers{"cache-control" => "public, max-age=100"}["cache-control"].should eq "public, max-age=100" end it "recacluates cache-control when new header is added after creation" do headers = AHTTP::Response::Headers.new headers["last-modified"] = "Sat, 10 Apr 2021 15:14:59 GMT" headers["cache-control"].should eq "private, must-revalidate" end it "recacluates cache-control when multiple directives are added" do headers = AHTTP::Response::Headers.new headers["cache-control"] = "public" headers.add "cache-control", "immutable" headers["cache-control"].should eq "public, immutable" end end end ================================================ FILE: src/components/http/spec/response_spec.cr ================================================ require "./spec_helper" private struct TestWriter < AHTTP::Response::Writer def write(output : IO, & : IO -> Nil) : Nil yield output output.print "EOF" end end describe AHTTP::Response do describe ".new" do it "defaults" do response = AHTTP::Response.new response.headers.has_key?("date").should be_true response.headers.has_key?("cache-control").should be_true response.content.should be_empty response.status.should eq ::HTTP::Status::OK end it "accepts an Int status" do AHTTP::Response.new(status: 201).status.should eq ::HTTP::Status::CREATED end it "accepts an ::HTTP::Status status" do AHTTP::Response.new(status: ::HTTP::Status::CREATED).status.should eq ::HTTP::Status::CREATED end it "accepts string content" do AHTTP::Response.new("FOO").content.should eq "FOO" end it "accepts nil content" do AHTTP::Response.new(nil).content.should eq "" end end describe "#content=" do it "accepts a string" do response = AHTTP::Response.new "FOO" response.content.should eq "FOO" response.content = "BAR" response.content.should eq "BAR" end end describe "#send" do it "writes the data to the provided IO" do io = IO::Memory.new response = ::HTTP::Server::Response.new(io) request = AHTTP::Request.new "GET", "/" art_response = AHTTP::Response.new("DATA", 418, ::HTTP::Headers{"FOO" => "BAR"}) art_response.headers << ::HTTP::Cookie.new "key", "value" art_response.send request, response response.status.should eq ::HTTP::Status::IM_A_TEAPOT response.headers["foo"].should eq "BAR" response.headers["content-length"].should eq "4" response.headers.has_key?("date").should be_true response.cookies["key"].should eq ::HTTP::Cookie.new "key", "value" response.closed?.should be_true io.rewind.gets_to_end.should end_with "DATA" end end describe "#status=" do it "accepts an Int" do response = AHTTP::Response.new response.status = 201 response.status.should eq ::HTTP::Status::CREATED end it "accepts an ::HTTP::Status" do response = AHTTP::Response.new response.status = ::HTTP::Status::CREATED response.status.should eq ::HTTP::Status::CREATED end end describe "#write" do it "writes the content to the given output IO" do io = IO::Memory.new AHTTP::Response.new("FOO BAR").write io io.to_s.should eq "FOO BAR" end it "supports customization via an AHTTP::Response::Writer" do io = IO::Memory.new response = AHTTP::Response.new("FOO BAR") response.writer = TestWriter.new response.write io io.to_s.should eq "FOO BAREOF" end end describe "#prepare" do it "sets content-type based on format" do request = AHTTP::Request.new "GET", "/" request.request_format = "json" response = AHTTP::Response.new "CONTENT" response.prepare request response.headers["content-type"].should eq "application/json" end it "does not override content-type if already set" do request = AHTTP::Request.new "GET", "/" request.request_format = "json" response = AHTTP::Response.new "CONTENT", headers: ::HTTP::Headers{"content-type" => "application/json; charset=utf-8"} response.prepare request response.headers["content-type"].should eq "application/json; charset=utf-8" end it "adds the charset to text based formats" do request = AHTTP::Request.new "GET", "/" request.request_format = "csv" response = AHTTP::Response.new "CONTENT" response.prepare request response.headers["content-type"].should eq "text/csv; charset=utf-8" end it "allows customizing the charset" do request = AHTTP::Request.new "GET", "/" request.request_format = "csv" response = AHTTP::Response.new "CONTENT" response.charset = "ISO-8859-1" response.prepare request response.headers["content-type"].should eq "text/csv; charset=ISO-8859-1" end it "does not override the charset if already included" do request = AHTTP::Request.new "GET", "/" request.request_format = "csv" response = AHTTP::Response.new "CONTENT", headers: ::HTTP::Headers{"content-type" => "text/csv; charset=ISO-8859-1"} response.prepare request response.headers["content-type"].should eq "text/csv; charset=ISO-8859-1" end it "removes content for informational responses & empty responses" do request = AHTTP::Request.new "GET", "/" response = AHTTP::Response.new "CONTENT" response.headers["content-length"] = "5" response.headers["content-type"] = "text/plain" response.status = 101 response.prepare request response.content.should be_empty response.headers.has_key?("content-length").should be_false response.headers.has_key?("content-type").should be_false end it "removes content for empty responses" do request = AHTTP::Request.new "GET", "/" response = AHTTP::Response.new "CONTENT" response.content = "CONTENT" response.headers["content-length"] = "5" response.headers["content-type"] = "text/plain" response.status = 204 response.prepare request response.content.should be_empty response.headers.has_key?("content-length").should be_false response.headers.has_key?("content-type").should be_false end it "removes content-length if transfer-encoding is set" do request = AHTTP::Request.new "GET", "/" response = AHTTP::Response.new "CONTENT" response.headers["content-length"] = "100" response.prepare request response.headers["content-length"].should eq "100" response.headers["transfer-encoding"] = "chunked" response.prepare request response.headers.has_key?("content-length").should be_false end it "handles multi-byte characters" do request = AHTTP::Request.new "GET", "/" response = AHTTP::Response.new str = "Añasco" # Emulate sending the data over the wire mem = IO::Memory.new mem.print str mem.rewind response.prepare request response.headers["content-length"].should eq mem.size.to_s end it "removes content and preserves content-length for head requests" do response = AHTTP::Response.new "CONTENT" request = AHTTP::Request.new "HEAD", "/" response.headers["content-length"] = "5" response.prepare request response.content.should be_empty response.headers["content-length"].should eq "5" end it "sets pragma & expires headers on HTTP/1.0 request" do request = AHTTP::Request.new "HEAD", "/", version: "HTTP/1.0" response = AHTTP::Response.new "CONTENT" response.headers.add_cache_control_directive "no-cache" response.prepare request response.content.should be_empty response.headers["pragma"]?.should eq "no-cache" response.headers["expires"]?.should eq "-1" request.version = "HTTP/1.1" response = AHTTP::Response.new "CONTENT" response.prepare request response.headers.has_key?("pragma").should be_false response.headers.has_key?("expires").should be_false end end it "#set_public" do response = AHTTP::Response.new response.set_public response.headers["cache-control"].should contain "public" response.headers["cache-control"].should_not contain "private" end describe "#set_etag" do it "sets the etag" do response = AHTTP::Response.new response.set_etag "ETAG" response.etag.should eq %("ETAG") end it "removes the etag if value is `nil`" do response = AHTTP::Response.new headers: ::HTTP::Headers{"etag" => "ETAG"} response.set_etag nil response.etag.should be_nil end it "allows setting a weak etag" do response = AHTTP::Response.new response.set_etag "ETAG", true response.etag.should eq %(W/"ETAG") end end describe "#last_modified=" do it "sets the last-modified header" do now = Time.utc response = AHTTP::Response.new response.last_modified = now response.last_modified.should eq now.at_beginning_of_second end it "removes the header if the value is `nil`" do response = AHTTP::Response.new headers: ::HTTP::Headers{"last-modified" => "TIME"} response.last_modified = nil response.last_modified.should be_nil end end describe "#redirect?" do it "valid redirection response" do [301, 302, 303, 307].each do |status| response = AHTTP::Response.new status: status response.redirect?.should be_true end end it "invalid redirection status" do [304, 200, 404].each do |status| AHTTP::Response.new(status: status).redirect?.should be_false end end it "with specific redirect location" do response = AHTTP::Response.new status: 301, headers: ::HTTP::Headers{"location" => "/good-uri"} response.redirect?.should be_true response.redirect?("/bad-uri").should be_false response.redirect?("/good-uri").should be_true end end end ================================================ FILE: src/components/http/spec/spec_helper.cr ================================================ require "spec" require "athena-spec" require "../src/athena-http" ASPEC.run_all ================================================ FILE: src/components/http/spec/streamed_response_spec.cr ================================================ require "./spec_helper" private struct TestWriter < AHTTP::Response::Writer def write(output : IO, & : IO -> Nil) : Nil yield output output.print "EOF" end end describe AHTTP::StreamedResponse do describe ".new" do it "accepts a block" do io = IO::Memory.new response = (AHTTP::StreamedResponse.new &.<<("BAZ")) response.write io io.to_s.should eq "BAZ" end it "accepts a proc" do io = IO::Memory.new proc = ->(i : IO) { i << "FOO" } response = AHTTP::StreamedResponse.new proc response.write io io.to_s.should eq "FOO" end it "allows overriding the callback" do io = IO::Memory.new response = (AHTTP::StreamedResponse.new &.<<("BAZ")) response.content = ->(i : IO) { i << "BAR" } response.write io io.to_s.should eq "BAR" end it "accepts an Int status" do (AHTTP::StreamedResponse.new(status: 201, &.<<("BAZ"))).status.should eq ::HTTP::Status::CREATED end it "accepts an ::HTTP::Status status" do (AHTTP::StreamedResponse.new(status: :created, &.<<("BAZ"))).status.should eq ::HTTP::Status::CREATED end end describe "#content=" do it "raises on not nil content" do response = (AHTTP::StreamedResponse.new &.<<("BAZ")) expect_raises AHTTP::Exception::Logic, "The content cannot be set on a StreamedResponse instance." do response.content = "FOO" end end it "allows nil" do io = IO::Memory.new response = (AHTTP::StreamedResponse.new &.<<("BAZ")) response.content = nil response.write io io.to_s.should be_empty end end describe "#write" do it "supports customization via an AHTTP::Response::Writer" do io = IO::Memory.new response = (AHTTP::StreamedResponse.new &.<<("FOO BAR")) response.writer = TestWriter.new response.write io io.to_s.should eq "FOO BAREOF" end it "does not allow writing more than once" do io = IO::Memory.new response = (AHTTP::StreamedResponse.new &.<<("FOO BAR")) response.write io response.write io io.to_s.should eq "FOO BAR" end end end ================================================ FILE: src/components/http/spec/uploaded_file_spec.cr ================================================ require "./spec_helper" struct UploadedFileTest < ASPEC::TestCase def test_initialize_non_existent_file : Nil ex = expect_raises ::AHTTP::Exception::FileNotFound, "The file does not exist." do AHTTP::UploadedFile.new "#{__DIR__}/assets/missing", "original.gif" end ex.file.should eq "#{__DIR__}/assets/missing" end def test_no_mime_type : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif" file.client_mime_type.should eq "application/octet-stream" file.mime_type.should eq "image/gif" file.status.ok?.should be_true end def test_unknown_mime_type : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/.unknownextension", "original.gif" file.client_mime_type.should eq "application/octet-stream" end def test_guess_client_extension : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif", "image/gif" file.guess_client_extension.should eq "gif" end def test_guess_client_extension_with_incorrect_mime_type : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif", "image/png" file.guess_client_extension.should eq "png" end def test_case_sensitive_mime_type : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/case-sensitive-mime-type.xlsm", "text.xlsm", "application/vnd.ms-excel.sheet.macroEnabled.12" file.guess_client_extension.should eq "xlsm" end def test_client_original_name : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif", "image/gif" file.client_original_name.should eq "original.gif" end def test_client_original_extension : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif", "image/gif" file.client_original_extension.should eq "gif" end def test_move_local_file_is_not_allowed : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif", "image/gif" expect_raises ::AHTTP::Exception::File, "The file 'original.gif' was not uploaded due to an unknown error." do file.move "#{__DIR__}/assets/directory" end end def test_move_local_file_is_allowed_in_test_mode : Nil path = "#{Dir.tempdir}/test.copy.gif" target_dir = "#{Dir.tempdir}/test" target_path = "#{target_dir}/test.copy.gif" ::File.delete? path ::File.delete? target_path ::File.copy "#{__DIR__}/assets/test.gif", path Dir.mkdir target_dir unless Dir.exists? target_dir file = AHTTP::UploadedFile.new path, "original.gif", "image/gif", test: true moved_file = file.move target_dir moved_file.should be_a AHTTP::File ::File.exists?(target_path).should be_true ::File.exists?(path).should be_false ::File.realpath(target_path).should eq moved_file.realpath FileUtils.rm_rf target_dir end def test_move_failed_too_big : Nil AHTTP::UploadedFile.max_file_size = 1024 * 5 file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif", "image/gif", :size_limit_exceeded expect_raises ::AHTTP::Exception::FileSizeLimitExceeded, "The file 'original.gif' exceeds your max_file_size configuration value (limit is 5.0kiB)." do file.move "#{__DIR__}/assets/directory" end ensure AHTTP::UploadedFile.max_file_size = 0 end def test_client_original_name_sanitize_filename : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "../../original.gif", "image/gif" file.client_original_name.should eq "original.gif" end def test_size : Nil file = AHTTP::UploadedFile.new path = "#{__DIR__}/assets/test.gif", "original.gif", "image/gif" file.size.should eq File.size path file = AHTTP::UploadedFile.new path = "#{__DIR__}/assets/test", "original.gif", "image/gif" file.size.should eq File.size path end def test_extname : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif", "image/gif" file.extname.should eq "gif" end def test_client_original_path : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif", "image/gif" file.client_original_path.should eq "original.gif" end def test_client_original_path_webkit_directory : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/webkitdirectory/test.txt", "webkitdirectory/test.txt", "text/plain" file.client_original_path.should eq "webkitdirectory/test.txt" end def test_valid : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif", "image/gif", test: true file.valid?.should be_true end def test_invalid : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif", "image/gif", :size_limit_exceeded file.valid?.should be_false end def test_invalid_non_http_upload : Nil file = AHTTP::UploadedFile.new "#{__DIR__}/assets/test.gif", "original.gif", "image/gif" file.valid?.should be_false end end ================================================ FILE: src/components/http/src/abstract_file.cr ================================================ require "file_utils" require "athena-mime" # Represents a file on the filesystem without opening a file descriptor. # This base type is needed as you can't inherit from non-abstract structs, # and it makes sense to have a generic `Athena::HTTP::File` type while also being able to share the logic with other sub-types. # # TODO: Add more methods as needed. abstract struct Athena::HTTP::AbstractFile # Returns the path to this file, which may be relative. getter path : String private getter info : ::File::Info { ::File.info @path } # Create a new instance for the file at the provided *path*. # If *check_path* is `true`, then an `AHTTP::Exception::FileNotFound` exception is raised if the file at the provided *path* does not exist. def initialize(path : String | Path, check_path : Bool = true) if check_path && !::File.file?(path) raise Athena::HTTP::Exception::FileNotFound.new "The file does not exist.", file: path end @path = path.to_s end # Returns the extension based on the MIME type of this file, or `nil` if it is unknown. # Uses the MIME type as guessed by `#mime_type` to guess the file extension. # # ``` # file = AHTTP::File.new "/path/to/foo.txt" # file.guess_extension # => "txt" # ``` def guess_extension : String? return unless mime_type = self.mime_type AMIME::Types.default.extensions(mime_type).first? end # Returns the MIME type of this file, using `AMIME::Types` under the hood. # # ``` # file = AHTTP::File.new "/path/to/foo.txt" # file.mime_type # => "text/plain" # ``` def mime_type : String? AMIME::Types.default.guess_mime_type @path end # Moves this file to the provided *directory*, optionally with the provided *name*. # If no *name* is provided, its current `#basename` will be used. def move(directory : Path | String, name : String? = nil) : self target = self.target_file directory, name FileUtils.mv @path, target.path target end # Returns the contents of this file as a string. # # ``` # file = AHTTP::File.new "/path/to/foo.txt" # file.content # => "foo" (content of foo.txt) # ``` def content : String ::File.read @path end # Returns the last component of this file's path. # If *suffix* is present at the end of the path, it is removed. # # ``` # file = AHTTP::File.new "/path/to/file.txt" # file.basename # => "file.txt" # file.basename ".txt" # => "file" # ``` def basename(suffix : String? = nil) : String suffix ? ::File.basename(@path, suffix) : ::File.basename(@path) end # Resolves the real path of this file by following symbolic links. # # ``` # file = AHTTP::File.new "./../../etc/passwd" # file.realpath # => "/etc/passwd" # ``` def realpath : String ::File.realpath @path end # Returns the size in bytes of this file. def size : Int ::File.size @path end # Returns `true` if this file is readable by user of this process, otherwise returns `false`. def readable? : Bool ::File::Info.readable? @path end # Returns the time this file was last modified. def modification_time : Time self.info.modification_time end # Returns the extension of this file, or an empty string if it does not have one. # # ``` # file = AHTTP::File.new "/path/to/file.txt" # file.extname # => "txt" # ``` def extname : String ::File.extname(@path).lchop '.' end private def target_file(directory : String | Path, name : String? = nil) : Athena::HTTP::File if !::File.directory? directory Dir.mkdir_p directory elsif !::File::Info.writable?(directory) raise ArgumentError.new "Unable to write in the '#{directory}' directory." end Athena::HTTP::File.new Path[directory, (file_name = name.presence) ? self.clean_name(file_name) : self.basename], false end private def clean_name(name : String) : String original_name = name.gsub "\\", "/" Path.new(original_name).basename end end ================================================ FILE: src/components/http/src/athena-http.cr ================================================ require "./ext/conversion_types" require "./abstract_file" require "./binary_file_response" require "./file" require "./header_utils" require "./ip_utils" require "./parameter_bag" require "./redirect_response" require "./response" require "./response_headers" require "./request" require "./request_matcher" require "./request_store" require "./streamed_response" require "./uploaded_file" require "./exception/*" require "./request_matcher/*" # Convenience alias to make referencing `Athena::HTTP` types easier. alias AHTTP = Athena::HTTP module Athena::HTTP VERSION = "0.1.0" # Both acts as a namespace for exceptions related to the `Athena::HTTP` component, as well as a way to check for exceptions from the component. module Exception; end end ================================================ FILE: src/components/http/src/binary_file_response.cr ================================================ require "./response" require "digest/sha256" require "athena-mime" # Represents a static file that should be returned the client; includes various options to enhance the response headers. See `.new` for details. # # This response supports [Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests) requests # and [Conditional](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests) requests via the # [If-None-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match), # [If-Modified-Since](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since), # and [If-Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) headers. # # See `AHTTP::HeaderUtils.make_disposition` for an example of handling dynamic files. class Athena::HTTP::BinaryFileResponse < Athena::HTTP::Response # Returns a `AHTTP::AbstractFile` instance representing the file that will be sent to the client. getter file : AHTTP::AbstractFile # Determines if the file should be deleted after being sent to the client. setter delete_file_after_send : Bool = false @offset : Int64 = 0 @max_length : Int64? = nil # Represents the possible [content-disposition](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition) header values. enum ContentDisposition # Indicates that the file should be downloaded. Attachment # Indicates that the browser should display the file inside the Web page, or as the Web page. Inline # :inherit: def to_s : String case self in .attachment? then "attachment" in .inline? then "inline" end end end # Instantiates `self` wrapping the provided *file*, optionally with the provided *status*, and *headers*. # # By default the response is `AHTTP::Response#set_public` and includes a `last-modified` header, # but these can be controlled via the *public* and *auto_last_modified* arguments respectively. # # The *content_disposition* argument can be used to set the `content-disposition` header on `self` if it should be downloadable. # # The *auto_etag* argument can be used to automatically set `ETag` header based on a `SHA256` hash of the file. def initialize( file : String | Path | AHTTP::AbstractFile | ::File, status : ::HTTP::Status | Int32 = ::HTTP::Status::OK, headers : ::HTTP::Headers | AHTTP::Response::Headers = AHTTP::Response::Headers.new, public : Bool = true, content_disposition : AHTTP::BinaryFileResponse::ContentDisposition? = nil, auto_etag : Bool = false, auto_last_modified : Bool = true, ) super nil, status, headers # This has to be here too to make compiler happy about it being defined. @file = case file in String, Path then AHTTP::File.new file.to_s in ::File then AHTTP::File.new file.path in AHTTP::AbstractFile then file end self.set_file @file, content_disposition, auto_etag, auto_last_modified self.set_public if public end # Sets the *file* that will be streamed to the client. # Includes the same optional parameters as `.new`. def set_file( file : String | Path | AHTTP::AbstractFile | ::File, content_disposition : AHTTP::BinaryFileResponse::ContentDisposition? = nil, auto_etag : Bool = false, auto_last_modified : Bool = false, ) : self file = case file in String, Path then AHTTP::File.new file.to_s in ::File then AHTTP::File.new file.path in AHTTP::AbstractFile then file end unless file.readable? raise Athena::HTTP::Exception::File.new "The file must be readable.", file: file.path end @file = file self.set_auto_etag if auto_etag self.auto_last_modified if auto_last_modified self.set_content_disposition content_disposition if content_disposition self end # CAUTION: Cannot set the response content via this method on `self`. def content=(data) : self raise AHTTP::Exception::Logic.new "The content cannot be set on a BinaryFileResponse instance." unless data.nil? self end # CAUTION: Cannot get the response content via this method on `self`. def content : String "" end # Sets the `content-disposition` header on `self` to the provided *disposition*. # *filename* defaults to the basename of `#file_path`. # # See `AHTTP::HeaderUtils.make_disposition`. def set_content_disposition(disposition : AHTTP::BinaryFileResponse::ContentDisposition, filename : String? = nil, fallback_filename : String? = nil) : self if filename.nil? filename = @file.basename end @headers["content-disposition"] = AHTTP::HeaderUtils.make_disposition disposition, filename, fallback_filename self end # Sets the `etag` header on `self` based on a `SHA256` hash of the file. def set_auto_etag : self self.set_etag Digest::SHA256.base64digest &.file(@file.path) self end # Sets the `last-modified` header on `self` based on the modification time of the file. def auto_last_modified : self self.last_modified = @file.modification_time self end # TODO: Support multiple ranges. # TODO: Support `X-Sendfile`. # # OPTIMIZE: Make this less complex. # # ameba:disable Metrics/CyclomaticComplexity protected def prepare(request : AHTTP::Request) : Nil if self.cache_request?(request) self.status = :not_modified return super end unless @headers.has_key? "content-type" @headers["content-type"] = @file.mime_type || "application/octet-stream" end file_size = @file.size @headers["content-length"] = file_size.to_s unless @headers.has_key? "accept-ranges" @headers["accept-ranges"] = request.safe? ? "bytes" : "none" end if request.headers.has_key?("range") && "GET" == request.method if !request.headers.has_key?("if-range") || self.valid_if_range_header?(request.headers["if-range"]?) if range = request.headers["range"].lchop? "bytes=" s, e = range.split '-', 2 e = e.empty? ? file_size - 1 : e.to_i64 if s.empty? s = file_size - e e = file_size - 1 else s = s.to_i64 end if s <= e e = Math.min e, file_size - 1 if s < 0 || s > e self.status = :range_not_satisfiable @headers["content-range"] = "bytes */#{file_size}" elsif e - s < file_size - 1 @max_length = e < file_size ? (e - s + 1).to_i64 : nil @offset = s.to_i64 self.status = :partial_content @headers["content-range"] = "bytes #{s}-#{e}/#{file_size}" @headers["content-length"] = "#{e - s + 1}" end end end end end end # :nodoc: def write(output : IO) : Nil unless @status.success? return super output end if @max_length.try &.zero? return end @writer.write(output) do |writer_io| ::File.open(@file.path, "rb") do |file| file.skip @offset if limit = @max_length IO.copy file, writer_io, limit else IO.copy file, writer_io end end end if @delete_file_after_send && ::File.file?(@file.path) ::File.delete @file.path end end private def cache_request?(request : AHTTP::Request) : Bool # According to RFC 7232: # A recipient must ignore If-Modified-Since if the request contains an If-None-Match header field if (if_none_match = request.if_none_match) && (etag = self.etag) match = {"*", etag} if_none_match.any? { |et| match.includes? et } elsif if_modified_since = request.headers["if-modified-since"]? header_time = ::HTTP.parse_time if_modified_since last_modified = self.last_modified || @file.modification_time # File mtime probably has a higher resolution than the header value. # An exact comparison might be slightly off, so we add 1s padding. # Static files should generally not be modified in subsecond intervals, so this is perfectly safe. !!(header_time && last_modified <= header_time + 1.second) else false end end private def valid_if_range_header?(header : String?) : Bool return true if self.etag == header return false unless last_modified = self.last_modified ::HTTP.format_time(last_modified) == header end end ================================================ FILE: src/components/http/src/exception/conflicting_headers.cr ================================================ require "./request_exception_interface" class Athena::HTTP::Exception::ConflictingHeaders < ArgumentError include Athena::HTTP::Exception::RequestExceptionInterface end ================================================ FILE: src/components/http/src/exception/file.cr ================================================ class Athena::HTTP::Exception::File < ::File::Error include Athena::HTTP::Exception end ================================================ FILE: src/components/http/src/exception/file_not_found.cr ================================================ class Athena::HTTP::Exception::FileNotFound < ::File::NotFoundError include Athena::HTTP::Exception end ================================================ FILE: src/components/http/src/exception/file_size_limit_exceeded.cr ================================================ class Athena::HTTP::Exception::FileSizeLimitExceeded < ::File::Error include Athena::HTTP::Exception end ================================================ FILE: src/components/http/src/exception/logic.cr ================================================ # Represents a code logic error that should lead directly to a fix in your code. class Athena::HTTP::Exception::Logic < ::Exception include Athena::HTTP::Exception end ================================================ FILE: src/components/http/src/exception/request_exception_interface.cr ================================================ # Exceptions that include this module should result in a 400 Bad Request response. module Athena::HTTP::Exception::RequestExceptionInterface include Athena::HTTP::Exception end ================================================ FILE: src/components/http/src/exception/suspicious_operation.cr ================================================ class Athena::HTTP::Exception::SuspiciousOperation < ArgumentError include Athena::HTTP::Exception::RequestExceptionInterface end ================================================ FILE: src/components/http/src/ext/conversion_types.cr ================================================ # :nodoc: def Object.from_parameter(value) value end def Object.from_parameter?(value) value end # :nodoc: def Array.from_parameter(value : Array) {% if T <= AHTTP::UploadedFile %} return value {% else %} value.map { |item| T.from_parameter(item).as T } {% end %} end # :nodoc: def Bool.from_parameter(value : String) : Bool case value when "true", "1", "yes", "on" then true when "false", "0", "no", "off" then false else raise ArgumentError.new "Invalid Bool: #{value}" end end # :nodoc: def Bool.from_parameter?(value : String) : Bool? case value when "true", "1", "yes", "on" then true when "false", "0", "no", "off" then false end end # :nodoc: def Enum.from_parameter(value : String) parse value end # :nodoc: def Enum.from_parameter?(value : String) parse? value end # :nodoc: def Union.from_parameter(value : String) # Process non nilable types first as they are more likely to work. {% for type in T.sort_by { |t| t.nilable? ? 1 : 0 } %} begin return {{type}}.from_parameter value rescue # Noop to allow next T to be tried. end {% end %} raise ArgumentError.new "Invalid #{self}: #{value}" end # :nodoc: def Number.from_parameter(value : String) : Number new value, whitespace: false end # :nodoc: def Number.from_parameter?(value : String) new value, whitespace: false rescue nil end # :nodoc: def Nil.from_parameter(value : String) : Nil raise ArgumentError.new "Invalid Nil: #{value}" unless value == "null" end # :nodoc: def Nil.from_parameter?(value : String) : Nil end ================================================ FILE: src/components/http/src/file.cr ================================================ require "./abstract_file" # Represents a file on the filesystem without opening a file descriptor. # See `AHTTP::AbstractFile` for the available API. struct Athena::HTTP::File < Athena::HTTP::AbstractFile end ================================================ FILE: src/components/http/src/header_utils.cr ================================================ # Includes various HTTP header utility methods. module Athena::HTTP::HeaderUtils # Combines a 2D array of *parts* into a single Hash. # # Each child array should have one or two elements, with the first representing the key and the second representing the value. # If there is no second value, `true` will be used. # The keys of the resulting hash are all downcased. # # ``` # AHTTP::HeaderUtils.combine [["foo", "abc"], ["bar"]] # => {"foo" => "abc", "bar" => true} # ``` def self.combine(parts : Enumerable) : Hash(String, String | Bool) parts.each_with_object({} of String => String | Bool) do |part, hash| # Typing gets real funky due to the nested nature of the arrays from `.split`. # Maybe there is a better way to go about it that could avoid that, but for now this seems to work :shrug:. next if part.is_a?(String) next if part.nil? key = part[0] value = part[1]? next unless key.is_a?(String) hash[key.downcase] = value.as?(String) || true end end # Generates a `HTTP` [content-disposition](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition) header value with the provided *disposition* and *filename*. # # If *filename* contains non `ASCII` characters, a sanitized version will be used as part of the `filename` directive, # while an encoded version of it will be used as the `filename*` directive. # The *fallback_filename* argument can be used to customize the `filename` directive value in this case. # # ``` # AHTTP::HeaderUtils.make_disposition :attachment, "download.txt" # => attachment; filename="download.txt" # AHTTP::HeaderUtils.make_disposition :attachment, "föö.html" # => attachment; filename="f__.html"; filename*=utf-8''f%C3%B6%C3%B6.html # AHTTP::HeaderUtils.make_disposition :attachment, "föö.html", "foo.html" # => attachment; filename="foo.html"; filename*=utf-8''f%C3%B6%C3%B6.html # ``` # # This method can be used to enable downloads of dynamically generated files. # I.e. that can't be handled via a static file event listener. # # ``` # AHTTP::Response.new( # file_contents, # headers: ::HTTP::Headers{"content-disposition" => AHTTP::HeaderUtils.make_disposition(:attachment, "foo.pdf")} # ) # ``` # # TIP: Checkout the [Getting Started](/getting_started/routing/#static-files) docs for an example of how to serve static files. def self.make_disposition(disposition : AHTTP::BinaryFileResponse::ContentDisposition, filename : String, fallback_filename : String? = nil) : String if fallback_filename.nil? && (!filename.ascii_only? || filename.includes?('%')) fallback_filename = filename.gsub { |chr| chr.ascii? ? chr : '_' } end if fallback_filename.nil? fallback_filename = filename end # The `%` character isn't valid in the fallback filename. if fallback_filename.includes? '%' raise ArgumentError.new "The fallback filename cannot contain the '%' character." end # The fallback filename may not contain path separators. if {'/', '\\'}.any? { |s| filename.includes? s } raise ArgumentError.new "The filename cannot include path separators." elsif {'/', '\\'}.any? { |s| fallback_filename.includes? s } raise ArgumentError.new "The fallback filename cannot include path separators." end params = { "filename" => fallback_filename, } if filename != fallback_filename params["filename*"] = "utf-8''#{URI.encode_path_segment filename}" end String.build do |io| disposition.to_s.downcase io io << "; " self.to_string io, params, "; " end end # Parses the directives out a the provided *header* string. # # ``` # AHTTP::HeaderUtils.parse "max-age=0, must-revalidate" # => {"max-age" => "0", "must-revalidate" => true} # ``` def self.parse(header : String) : Hash(String, String | Bool) values = Hash(String, String | Bool).new header.strip.scan /(?:[^,\"]*+(?:"[^"]*+\")?)+[^,\"]*+/ do |match| match_string = match[0].strip next if match_string.blank? if match_string.includes? '=' key, value = match_string.split '=' values[key] = value else values[match_string] = true end end values end # Splits an HTTP *header* by one or more *separators*, provided in priority order. # Returns an array with as many levels as there are *separators*. # # ``` # # First splits on `,`, then `;` as defined via the order of the separators. # AHTTP::HeaderUtils.split "da, en-gb;q=0.8", ",;" # => [["da"], ["en-gb", "q=0.8"]] # AHTTP::HeaderUtils.split "da, en-gb;q=0.8", ";," # => [["da", "en-gb"], ["q=0.8"]]] # ``` def self.split(header : String, separators : String) : Array raise ArgumentError.new "At least one separator must be specified." if separators.blank? quoted_separators = Regex.escape separators matches = header.strip.scan( / (?!\s) (?: # quoted-string "(?:[^"\\]|\\.)*(?:"|\\|$) | # token [^"#{quoted_separators}]+ )+ (?[#{quoted_separators}]) \s* /x) self.group_parts matches.map &.to_a, separators end # Joins the provided key/value *parts* into a string for use within an `HTTP` header. # # The key and value of each entry is joined with `=`, quoting the value if needed. # All entries are then joined by the provided *separator*. def self.to_string(separator : String | Char, **parts) : String self.to_string parts.to_h, separator end # Joins a key/value pair *collection* into a string for use within an `HTTP` header. # # The key and value of each entry is joined with `=`, quoting the value if needed. # All entries are then joined by the provided *separator*. # # ``` # AHTTP::HeaderUtils.to_string({"foo" => "bar", "key" => true}, ", ") # => foo=bar, key # AHTTP::HeaderUtils.to_string({"foo" => %q("foo\ bar"), "key" => true}, ", ") # => foo=\"foo\\\ bar\", key # ``` def self.to_string(collection : Hash, separator : String | Char) : String String.build do |io| self.to_string io, collection, separator end end # Joins a key/value pair *collection* for use within an `HTTP` header; writing the data to the provided *io*. # # The key and value of each entry is joined with `=`, quoting the value if needed. # All entries are then joined by the provided *separator*. def self.to_string(io : IO, collection : Hash, separator : String | Char) : Nil collection.join(io, separator) do |(k, v), join_io| if true == v join_io << k else join_io << k << '=' ::HTTP.quote_string v.to_s, join_io end end end # Decodes a quoted string. def self.unquote(string : String) : String string.gsub /\\(.)|"/, "\\1" end private def self.group_parts(matches : Array, separators : String, first : Bool = true) : Array separator = separators[0].to_s separators = separators[1..].to_s i = 0 if separators.empty? && !first parts = [""] matches.each do |match| if i.zero? && !match[1]?.nil? i = 1 parts.insert 1, "" else parts[i] += self.unquote match.not_nil![0].not_nil!.to_s end end return parts end parts = [] of String part_matches = Hash(Int32, Array(Array(String?))).new { |hash, key| hash[key] = Array(Array(String?)).new } matches.each do |match| if match[1]? == separator i += 1 else part_matches[i] << match end end part_matches.each_value.map do |m| if separators.empty? && (unquoted = self.unquote m[0][0].to_s) && !unquoted.empty? unquoted elsif grouped_parts = self.group_parts(m, separators, false) grouped_parts end end.to_a end end ================================================ FILE: src/components/http/src/ip_utils.cr ================================================ # Includes various IP address utility methods. module Athena::HTTP::IPUtils @@checked_ips = Hash(String, Bool).new # Returns `true` if the provided IPv4 or IPv6 *request_ip* is contained within the list of *ips* or subnets. def self.check(request_ip : String, ips : String | Enumerable(String)) : Bool ips = ips.is_a?(String) ? {ips} : ips is_ipv6 = request_ip.count(':') > 1 ips.any? { |ip| is_ipv6 ? self.check_ipv6(request_ip, ip) : self.check_ipv4(request_ip, ip) } end # Returns `true` if *request_ip* matches *ip*, or is within the CIDR subnet. def self.check_ipv4(request_ip : String, ip : String) : Bool cache_key = "#{request_ip}-#{ip}-v4" self.get_cached_result(cache_key).try do |result| return result end unless request_ip_bytes = Socket::IPAddress.parse_v4_fields? request_ip return self.set_cached_result cache_key, false end if ip.includes? '/' address, netmask = ip.split '/', 2 netmask = netmask.to_i if netmask.zero? return self.set_cached_result cache_key, Socket::IPAddress.valid_v4?(address) end if netmask < 0 || netmask > 32 return self.set_cached_result cache_key, false end else address = ip netmask = 32 end unless address_bytes = Socket::IPAddress.parse_v4_fields?(address) return self.set_cached_result cache_key, false end request_ip_decimal = IO::ByteFormat::BigEndian.decode(UInt32, request_ip_bytes.to_slice) address_decimal = IO::ByteFormat::BigEndian.decode(UInt32, address_bytes.to_slice) mask = UInt32::MAX << (32 - netmask) (request_ip_decimal & mask) == (address_decimal & mask) end # :ditto: def self.check_ipv6(request_ip : String, ip : String) : Bool cache_key = "#{request_ip}-#{ip}-v6" self.get_cached_result(cache_key).try do |result| return result end unless request_ip_bytes = Socket::IPAddress.parse_v6_fields? request_ip return self.set_cached_result cache_key, false end if ip.includes? '/' address, netmask = ip.split '/', 2 netmask = netmask.to_i unless address_bytes = Socket::IPAddress.parse_v6_fields? address return self.set_cached_result cache_key, false end if netmask.zero? # If it made it this far `address_bytes` is valid so it would always be a valid IP return self.set_cached_result cache_key, true end if netmask < 1 || netmask > 128 return self.set_cached_result cache_key, false end else unless address_bytes = Socket::IPAddress.parse_v6_fields? ip return self.set_cached_result cache_key, false end netmask = 128 end 0.upto(netmask // 16) do |i| left = netmask - 16 * i left = (left <= 16) ? left : 16 mask = ~(0xFFFF >> left) & 0xFFFF if ((address_bytes[i]? || 0) & mask) != ((request_ip_bytes[i]? || 0) & mask) return self.set_cached_result cache_key, false end end self.set_cached_result cache_key, true end private def self.get_cached_result(key : String) : Bool? if @@checked_ips.has_key? key # Move the item last in the cache value = @@checked_ips[key] @@checked_ips.delete key return @@checked_ips[key] = value end nil end private def self.set_cached_result(key : String, value : Bool) : Bool @@checked_ips[key] = value end end ================================================ FILE: src/components/http/src/parameter_bag.cr ================================================ # A container for storing key/value pairs. Can be used to store arbitrary data within the context of a request. # It can be accessed via `AHTTP::Request#attributes`. struct Athena::HTTP::ParameterBag private abstract struct Param abstract def value abstract def type_name : String def inspect(io : IO) : Nil io << "#" end end private record Parameter(T) < Param, value : T do def type_name : String {{ T.stringify }} end end @parameters : Hash(String, Param) = Hash(String, Param).new # Returns `true` if a parameter with the provided *name* exists, otherwise `false`. def has?(name : String) : Bool @parameters.has_key? name end # Returns `true` if a parameter with the provided *name* exists and is of the provided *type*, otherwise `false`. def has?(name : String, type : T.class) : Bool forall T @parameters[name]?.try { |p| p.value.class == T } || false end # Returns the value of the parameter with the provided *name* if it exists, otherwise `nil`. def get?(name : String) @parameters[name]?.try &.value end # Returns the value of the parameter with the provided *name* casted to the provided *type* if it exists, otherwise `nil`. def get?(name : String, type : T?.class) : T? forall T self.get?(name).as? T? end # Returns the value of the parameter with the provided *name*. # # Raises a `KeyError` if no parameter with that name exists. def get(name : String) @parameters.fetch(name) { raise KeyError.new "No parameter exists with the name '#{name}'." }.value end # Returns the value of the parameter with the provided *name*, casted to the provided *type*. # # Raises a `KeyError` if no parameter with that name exists. def get(name : String, type : T.class) : T forall T self.get(name).as T end {% for type in [Bool, String] + Number::Primitive.union_types %} # Returns the value of the parameter with the provided *name* as a `{{type}}`. def get(name : String, _type : {{type}}.class) : {{type}} {{type}}.from_parameter(self.get(name)).as {{type}} end # Returns the value of the parameter with the provided *name* as a `{{type}}`, or `nil` if it does not exist. def get?(name : String, _type : {{type}}?.class) : {{type}}? return nil unless (value = self.get? name) {{type}}.from_parameter?(value).as? {{type}}? end {% end %} def set(hash : Hash) : Nil hash.each do |key, value| self.set key, value end end # Sets a parameter with the provided *name* to *value*. def set(name : String, value : T) : Nil forall T self.set name, value, T end # Sets a parameter with the provided *name* to *value*, restricted to the given *type*. def set(name : String, value : T, type : T.class) : Nil forall T @parameters[name] = Parameter(T).new value end # Removes the parameter with the provided *name*. def remove(name : String) : Nil @parameters.delete name end end ================================================ FILE: src/components/http/src/redirect_response.cr ================================================ require "./response" # Represents an HTTP response that does a redirect. # # Can be used as an easier way to handle redirects as well as providing type safety that a route should redirect. # # ``` # require "athena" # # class RedirectController < ATH::Controller # @[ARTA::Get(path: "/go_to_crystal")] # def redirect_to_crystal : AHTTP::RedirectResponse # AHTTP::RedirectResponse.new "https://crystal-lang.org" # end # end # # ATH.run # # # GET /go_to_crystal # => (redirected to https://crystal-lang.org) # ``` class Athena::HTTP::RedirectResponse < Athena::HTTP::Response # The url that the request will be redirected to. getter url : String # Creates a response that should redirect to the provided *url* with the provided *status*, defaults to 302. # # An ArgumentError is raised if *url* is blank, or if *status* is not a valid redirection status code. def initialize(url : String | Path | URI, status : ::HTTP::Status | Int32 = ::HTTP::Status::FOUND, headers : ::HTTP::Headers | AHTTP::Response::Headers = AHTTP::Response::Headers.new) @url = url.to_s raise ArgumentError.new "Cannot redirect to an empty URL." if @url.blank? headers["location"] = @url super "", status, headers raise ArgumentError.new "'#{@status.value}' is not an HTTP redirect status code." unless @status.redirection? end end ================================================ FILE: src/components/http/src/request.cr ================================================ # Wraps an [HTTP::Request](https://crystal-lang.org/api/HTTP/Request.html) instance to provide additional functionality. # # Forwards all additional methods to the wrapped [`HTTP::Request`](https://crystal-lang.org/api/HTTP/Request.html) instance. class Athena::HTTP::Request # Represents the supported [Proxy Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Proxy_servers_and_tunneling#forwarding_client_information_through_proxies). # Can be used via `AHTTP::Request.set_trusted_proxies` to whitelist which headers are allowed. # # See the [external documentation](/guides/proxies) for more information. @[Flags] enum ProxyHeader # The [`forwarded`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded) header as defined by [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239). FORWARDED # The [`x-forwarded-for`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For) header. FORWARDED_FOR # The [`x-forwarded-host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host) header. FORWARDED_HOST # The [`x-forwarded-proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto) header. FORWARDED_PROTO # Similar to `FORWARDED_HOST`, but exclusive to the port number. FORWARDED_PORT # Returns the string header name for a given proxy header. # # ``` # AHTTP::Request::ProxyHeader::FORWARDED_PROTO.header => "x-forwarded-proto" # ``` def header : String if override = AHTTP::Request.trusted_header_overrides[self]? return override end case self when .forwarded? then "forwarded" when .forwarded_for? then "x-forwarded-for" when .forwarded_host? then "x-forwarded-host" when .forwarded_proto? then "x-forwarded-proto" when .forwarded_port? then "x-forwarded-port" else raise "BUG: requested header of unexpected proxy header type" end end # Returns the `forwarded` param related to a given proxy header. # # ``` # AHTTP::Request::ProxyHeader::FORWARDED_PROTO.forwarded_param => "proto" # ``` def forwarded_param : String? case self when .forwarded_for? then "for" when .forwarded_host? then "host" when .forwarded_proto? then "proto" when .forwarded_port? then "host" end end end # Represents the supported built in formats; mapping the format name to its valid `MIME` type(s). # # Additional formats may be registered via `.register_format`. FORMATS = { "atom" => Set{"application/atom+xml"}, "css" => Set{"text/css"}, "csv" => Set{"text/csv"}, "form" => Set{"application/x-www-form-urlencoded", "multipart/form-data"}, "hal" => Set{"application/hal+json", "application/hal+xml"}, "html" => Set{"text/html", "application/xhtml+xml"}, "js" => Set{"application/javascript", "application/x-javascript", "text/javascript"}, "json" => Set{"application/json", "application/x-json"}, "jsonapi" => Set{"application/vnd.api+json"}, "jsonld" => Set{"application/ld+json"}, "pdf" => Set{"application/pdf"}, "problem" => Set{"application/problem+json"}, "rdf" => Set{"application/rdf+xml"}, "rss" => Set{"application/rss+xml"}, "soap" => Set{"application/soap+xml"}, "txt" => Set{"text/plain"}, "wbxml" => Set{"application/vnd.wap.wbxml"}, "xml" => Set{"text/xml", "application/xml", "application/x-xml"}, "yaml" => Set{"text/yaml", "application/x-yaml"}, } # Maps a [structured syntax suffix](https://www.iana.org/assignments/media-type-structured-suffix/media-type-structured-suffix.xhtml) # (per [RFC 6839](https://datatracker.ietf.org/doc/html/rfc6839) / [RFC 7303](https://datatracker.ietf.org/doc/html/rfc7303)) # to its underlying format. # # Used by `#format` when the MIME type isn't an exact match in `FORMATS`; e.g. `application/vnd.github+json` -> `json`. private STRUCTURED_SUFFIX_FORMATS = { "json" => "json", "xml" => "xml", "xhtml" => "html", "cbor" => "cbor", "zip" => "zip", "ber" => "asn1", "der" => "asn1", "tlv" => "tlv", "wbxml" => "xml", "yaml" => "yaml", } # Returns which `AHTTP::Request::ProxyHeader`s have been whitelisted by the application as set via `.set_trusted_proxies`, defaulting to all of them. class_getter trusted_header_set : AHTTP::Request::ProxyHeader = :all # Returns the list of trusted proxy IP addresses as set via `.set_trusted_proxies`. class_getter trusted_proxies : Array(String) = [] of String # Returns the list of trusted host patterns set via `.set_trusted_hosts`. class_getter trusted_host_patterns : Array(Regex) = [] of Regex protected class_getter trusted_header_overrides : Hash(AHTTP::Request::ProxyHeader, String) = {} of AHTTP::Request::ProxyHeader => String protected class_getter trusted_hosts : Array(String) = [] of String # Allows setting a list of *host_patterns* used to whitelist the allowed hostnames of requests. # If there is at least one pattern defined, the `#host` method will raise an exception if the request's hostname does _NOT_ match any of the patterns. # # See the [external documentation](/guides/proxies) for more information if using the [full framework](/getting_started). def self.set_trusted_hosts(host_patterns : Array(Regex)) : Nil @@trusted_host_patterns = host_patterns.map! { |pattern| /#{pattern}/i } @@trusted_hosts.clear end # Allows setting a list of *trusted_proxies*, and which `AHTTP::Request::ProxyHeader` should be whitelisted. # The provided proxies are expected to be either IPv4 and/or IPv6 addresses. # The special `"REMOTE_ADDRESS"` string is also supported that will map to the current request's remote address. # # See the [external documentation](/guides/proxies) for more information. def self.set_trusted_proxies(trusted_proxies : Enumerable(String), @@trusted_header_set : AHTTP::Request::ProxyHeader) : Nil @@trusted_proxies = trusted_proxies.to_a end # Allows overriding the header name to look for off the request for a given `AHTTP::Request::ProxyHeader`. # In some cases a proxy might not use the exact `x-forwarded-*` header name. # # See the [external documentation](/guides/proxies/#custom-headers) for more information. def self.override_trusted_header(header : AHTTP::Request::ProxyHeader, name : String) : Nil @@trusted_header_overrides[header] = name end # Registers the provided *format* with the provided *mime_types*. # Can also be used to change the *mime_types* supported for an existing *format*. # # ``` # AHTTP::Request.register_format "some_format", {"some/mimetype"} # ``` def self.register_format(format : String, mime_types : Indexable(String)) : Nil FORMATS[format] = mime_types.to_set end # Returns the `MIME` types for the provided *format*. # # ``` # AHTTP::Request.mime_types "txt" # => Set{"text/plain"} # ``` def self.mime_types(format : String) : Set(String) FORMATS[format]? || Set(String).new end # See `AHTTP::ParameterBag`. getter attributes : AHTTP::ParameterBag = AHTTP::ParameterBag.new # If [enabled](/Framework/Bundle/Schema/FileUploads/), Athena will populate this hash with files from the request body of `multipart/form-data` requests. # # The keys of the hash map to the [name](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#name) attribute of the related file input control. # The value of the hash is an array in order to handle multi-file uploads using the same name. getter files : Hash(String, Array(AHTTP::UploadedFile)) = Hash(String, Array(AHTTP::UploadedFile)).new { |hash, key| hash[key] = [] of AHTTP::UploadedFile } @request_data : ::HTTP::Params? # Sets the `#request_format` to the explicitly passed format. setter request_format : String? = nil # Returns the raw wrapped `::HTTP::Request` instance. getter request : ::HTTP::Request @is_host_valid : Bool = true @is_forwarded_valid : Bool = true @trusted_values_cache = Hash(String, Array(String)).new # :nodoc: forward_missing_to @request def self.new(method : String, path : String, headers : ::HTTP::Headers? = nil, body : String | Bytes | IO | Nil = nil, version : String = "HTTP/1.1") : self new ::HTTP::Request.new method.upcase, path, headers, body, version end def self.new(request : self) : self request end def initialize(@request : ::HTTP::Request); end # Returns the first `MIME` type for the provided *format* if defined, otherwise returns `nil`. # # ``` # request.mime_type "txt" # => "text/plain" # ``` def mime_type(format : String) : String? self.class.mime_types(format).first? end # Returns the [Format](/HTTP/Request/#Athena::HTTP::Request::FORMATS) of the request based on its `content-type` header, or `nil` if the header is missing. def content_type_format : String? self.format @request.headers.fetch "content-type", "" end # Returns the format for the provided *mime_type*, or `nil` if it cannot be resolved. # # Resolution order: # 1. Exact match against `FORMATS`. # 2. Canonical MIME type match (i.e. the portion before any `;` parameters). # 3. Structured syntax suffix per [RFC 6839](https://datatracker.ietf.org/doc/html/rfc6839) / [RFC 7303](https://datatracker.ietf.org/doc/html/rfc7303); e.g. `application/vnd.github+json` -> `json`. # 4. If *subtype_fallback* is `true` and the subtype contains no `+`, returns the subtype with any `x-` prefix stripped; e.g. `application/x-foo` -> `foo`. # # ``` # request.format "text/plain" # => "txt" # request.format "application/vnd.github+json" # => "json" # request.format "application/x-foo", subtype_fallback: true # => "foo" # ``` # # ameba:disable Metrics/CyclomaticComplexity def format(mime_type : String, subtype_fallback : Bool = false) : String? canonical_mime_type = nil if mime_type.includes? ';' canonical_mime_type = mime_type.split(';', 2).first.strip end FORMATS.each do |format, mime_types| return format if mime_types.includes? mime_type return format if canonical_mime_type && mime_types.includes? canonical_mime_type end canonical_mime_type ||= mime_type if canonical_mime_type.starts_with?("application/") && (plus_idx = canonical_mime_type.rindex('+')) suffix = canonical_mime_type[(plus_idx + 1)..] if format = STRUCTURED_SUFFIX_FORMATS[suffix]? return format end end if subtype_fallback && (slash_idx = canonical_mime_type.index('/')) subtype = canonical_mime_type[(slash_idx + 1)..] subtype = subtype[2..] if subtype.starts_with?("x-") return subtype unless subtype.includes?('+') end end # Returns the host name the request originated from. # # Supports reading from `AHTTP::Request::ProxyHeader::FORWARDED_HOST`, falling back on the `"host"` header. # # See the [external documentation](/guides/proxies) for more information. def host : String? if self.from_trusted_proxy? && (host = self.get_trusted_values(ProxyHeader::FORWARDED_HOST)) && !host.empty? host = host.first elsif !(host = @request.headers["host"]?) return end # Trim and ensure there is no port number # downcase as per RFC 952/2181 host = host.strip.gsub(/:\d+$/, "").downcase # Ensure host does not contain forbidden characters as pert RFC 952/2181 if host.presence && !host.gsub(/(?:^\[)?[a-zA-Z0-9-:\]_]+\.?/, "").empty? return unless @is_host_valid @is_host_valid = false raise AHTTP::Exception::SuspiciousOperation.new "Invalid Host: '#{host}'." end unless @@trusted_host_patterns.empty? return host if @@trusted_hosts.includes? host @@trusted_host_patterns.each do |pattern| if host.matches? pattern @@trusted_hosts << host return host end end return unless @is_host_valid @is_host_valid = false raise AHTTP::Exception::SuspiciousOperation.new "Untrusted Host: '#{host}'." end host end # Returns an `::HTTP::Params` instance based on this request's form data body. def request_data @request_data ||= self.parse_request_data end # Returns the format for this request. # # First checks if a format was explicitly set via `#request_format=`. # Next, will check for the `_format` request `#attributes`, finally falling back on the provided *default*. def request_format(default : String? = "json") : String? if @request_format.nil? @request_format = self.attributes.get? "_format", String end @request_format || default end # Returns `true` if this request's `#method` is [safe](https://tools.ietf.org/html/rfc7231#section-4.2.1). # Otherwise returns `false`. def safe? : Bool @request.method.in? "GET", "HEAD", "OPTIONS", "TRACE" end # Returns the port on which the request is made. # # Supports reading from both `AHTTP::Request::ProxyHeader::FORWARDED_PORT` and `AHTTP::Request::ProxyHeader::FORWARDED_HOST`, falling back on the `"host"` header, then `#scheme`. # # See the [external documentation](/guides/proxies) for more information. # # ameba:disable Metrics/CyclomaticComplexity def port : Int32 if self.from_trusted_proxy? && (host = self.get_trusted_values(ProxyHeader::FORWARDED_PORT)) && !host.empty? host = host.first elsif self.from_trusted_proxy? && (host = self.get_trusted_values(ProxyHeader::FORWARDED_HOST)) && !host.empty? host = host.first elsif !(host = @request.headers["host"]?) return self.secure? ? 443 : 80 end pos = if host.starts_with? '[' # Assume the host will have a closing `]` if it has a beginning one host.index ':', host.index!(']') else host.index ':' end if pos && (port = host[(pos + 1)..]?) return port.to_i end self.secure? ? 443 : 80 end # Returns the scheme of this request. def scheme : String self.secure? ? "https" : "http" end # Returns `true` the request was made over HTTPS, otherwise returns `false`. # # Supports reading from `AHTTP::Request::ProxyHeader::FORWARDED_PROTO`. # # See the [external documentation](/guides/proxies) for more information. def secure? : Bool if self.from_trusted_proxy? && (proto = self.get_trusted_values(ProxyHeader::FORWARDED_PROTO)) && !proto.empty? return proto.first.downcase.in? "https", "on", "ssl", "1" end # TODO: Possibly have this be based on if server was started with `bind_tls` # or if there is eventually some way to access TLS info off `@request`. false end # Returns `true` if this request originated from a trusted proxy. # # See the [external documentation](/guides/proxies) for more information. def from_trusted_proxy? : Bool return false unless trusted_proxies = self.trusted_proxies return false unless remote_address = self.remote_address AHTTP::IPUtils.check remote_address, trusted_proxies end # ameba:disable Metrics/CyclomaticComplexity: private def get_trusted_values(type : ProxyHeader) : Array(String) cache_key = "#{type}-#{@@trusted_header_set.includes?(type) ? @request.headers[type.header]? : ""}-#{@request.headers[ProxyHeader::FORWARDED.header]?}" if result = @trusted_values_cache[cache_key]? return result end client_values = [] of String forwarded_values = [] of String if @@trusted_header_set.includes?(type) && (header_value = @request.headers[type.header]?) header_value.split(',').each do |part| client_values << "#{type.forwarded_port? ? "0.0.0.0:" : ""}#{part.strip}" end end if @@trusted_header_set.includes?(ProxyHeader::FORWARDED) && (forwarded_param = type.forwarded_param) && (forwarded = @request.headers[ProxyHeader::FORWARDED.header]?) AHTTP::HeaderUtils.split(forwarded, ",;=").each do |sub_parts| # In this particular context compiler gets confused, so lets make it happy by skipping unexpected typed parts, which should never happen. next if sub_parts.is_a?(String) next if sub_parts.nil? unless v = HeaderUtils.combine(sub_parts)[forwarded_param]?.as?(String?) next end if type.forwarded_port? last_colon_idx = v.rindex(':') if v.ends_with?(']') || last_colon_idx.nil? v = self.secure? ? ":443" : ":80" end v = "0.0.0.0#{v[last_colon_idx..-1]}" end forwarded_values << v end end if forwarded_values == client_values || client_values.empty? return @trusted_values_cache[cache_key] = forwarded_values end if forwarded_values.empty? return @trusted_values_cache[cache_key] = client_values end unless @is_forwarded_valid return @trusted_values_cache[cache_key] = [] of String end @is_forwarded_valid = false raise AHTTP::Exception::ConflictingHeaders.new "The request has both a trusted '#{ProxyHeader::FORWARDED.header}' header and a trusted '#{type.header}' header, conflicting with each other. \ You should either configure your proxy to remove one of them, or configure your project to distrust the offending one." end private def trusted_proxies : Array(String)? return if (trusted_proxies = @@trusted_proxies).empty? return unless remote_address = self.remote_address trusted_proxies.map do |proxy| "REMOTE_ADDRESS" == proxy ? remote_address : proxy end end private def remote_address : String? return unless (remote_address = @request.remote_address).is_a? Socket::IPAddress remote_address.address end private def parse_request_data : ::HTTP::Params ::HTTP::Params.parse @request.body.try(&.gets_to_end) || "" end end ================================================ FILE: src/components/http/src/request_matcher/attributes.cr ================================================ # Checks if all specified `AHTTP::Request#attributes` match the provided patterns. struct Athena::HTTP::RequestMatcher::Attributes include Interface def initialize(@regexes : Hash(String, Regex)); end # :inherit: def matches?(request : AHTTP::Request) : Bool @regexes.each do |key, regex| attribute = request.attributes.get key return false unless attribute.is_a? String return false unless attribute.matches? regex end true end end ================================================ FILE: src/components/http/src/request_matcher/header.cr ================================================ # Checks the presence of HTTP headers in an `AHTTP::Request`. struct Athena::HTTP::RequestMatcher::Header include Interface @headers : Array(String) def self.new(*headers : String) new headers.to_a end def initialize(@headers : Array(String)); end # :inherit: def matches?(request : AHTTP::Request) : Bool return true if @headers.empty? headers = request.headers @headers.each do |header| return false unless headers.has_key? header end true end end ================================================ FILE: src/components/http/src/request_matcher/hostname.cr ================================================ # Checks if the `AHTTP::Request#hostname` matches the allowed pattern. struct Athena::HTTP::RequestMatcher::Hostname include Interface def initialize(regex : Regex) @regex = Regex.new regex.source, :ignore_case end # :inherit: def matches?(request : AHTTP::Request) : Bool return false unless hostname = request.host hostname.matches? @regex end end ================================================ FILE: src/components/http/src/request_matcher/method.cr ================================================ # Checks if the `AHTTP::Request#method` is allowed. struct Athena::HTTP::RequestMatcher::Method include Interface @methods : Array(String) def self.new(*methods : String) new methods.to_a end def initialize(@methods : Array(String)) methods.map! &.upcase end # :inherit: def matches?(request : AHTTP::Request) : Bool return false if @methods.empty? @methods.includes? request.method end end ================================================ FILE: src/components/http/src/request_matcher/path.cr ================================================ # Checks if the `AHTTP::Request#path` matches the allowed pattern. struct Athena::HTTP::RequestMatcher::Path include Interface def initialize(@regex : Regex); end # :inherit: def matches?(request : AHTTP::Request) : Bool URI.decode(request.path).matches? @regex end end ================================================ FILE: src/components/http/src/request_matcher/query_parameter.cr ================================================ # Checks the presence of HTTP query parameters in an `AHTTP::Request`. struct Athena::HTTP::RequestMatcher::QueryParameter include Interface @parameters : Array(String) def self.new(*parameters : String) new parameters.to_a end def initialize(@parameters : Array(String)); end # :inherit: def matches?(request : AHTTP::Request) : Bool return true if @parameters.empty? query_params = request.query_params @parameters.each do |parameter| return false unless query_params.has_key? parameter end true end end ================================================ FILE: src/components/http/src/request_matcher.cr ================================================ # Verifies that all [checks](/HTTP/RequestMatcher/Interface/) match against an `AHTTP::Request` instance. # # ``` # matcher = AHTTP::RequestMatcher.new( # AHTTP::RequestMatcher::Path.new(%r(/admin/foo)), # AHTTP::RequestMatcher::Method.new("GET"), # ) # # matcher.matches?(AHTTP::Request.new "GET", "/admin/foo") # => true # matcher.matches?(AHTTP::Request.new "POST", "/admin/foo") # => false # ``` class Athena::HTTP::RequestMatcher # Represents a strategy that can be used to match an `AHTTP::Request`. # This interface can be used as a generic way to determine if some logic should be enabled for a given request based on the configured rules. module Interface # Decides whether the rule(s) implemented by the strategy matches the provided *request*. abstract def matches?(request : AHTTP::Request) : Bool end include Interface def self.new(*matchers : AHTTP::RequestMatcher::Interface) new matchers.map &.as(AHTTP::RequestMatcher::Interface) end def initialize(@matchers : Iterable(AHTTP::RequestMatcher::Interface)); end # :inherit: def matches?(request : AHTTP::Request) : Bool @matchers.all? &.matches? request end end ================================================ FILE: src/components/http/src/request_store.cr ================================================ # Stores a `AHTTP::Request` object. class Athena::HTTP::RequestStore property! request : AHTTP::Request # Resets the store, removing the reference to the request. # # Used internally after the response has been returned. protected def reset : Nil @request = nil end end ================================================ FILE: src/components/http/src/response.cr ================================================ # Represents an `HTTP` response that should be returned to the client. # # Contains the content, status, and headers that should be applied to the actual `HTTP::Server::Response`. # # The `#content` is written all at once to the server response's `IO`. class Athena::HTTP::Response # Determines how the content of an `AHTTP::Response` will be written to the requests' response `IO`. # # By default the content is written directly to the requests' response `IO` via `AHTTP::Response::DirectWriter`. # However, custom writers can be implemented to customize that behavior. The most common use case would be for compression. # # Writers can also be defined as services and injected into a listener if they require additional external dependencies. # # ### Example # # ``` # require "athena" # require "compress/gzip" # # # Define a custom writer to gzip the response # struct GzipWriter < AHTTP::Response::Writer # def write(output : IO, & : IO -> Nil) : Nil # Compress::Gzip::Writer.open(output) do |gzip_io| # yield gzip_io # end # end # end # # # Define a new event listener to handle applying this writer # @[ADI::Register] # struct CompressionListener # @[AEDA::AsEventListener(priority: -256)] # def on_response(event : AHK::Events::Response) : Nil # # If the request supports gzip encoding # if event.request.headers.includes_word?("accept-encoding", "gzip") # # Change the `AHTTP::Response` object's writer to be our `GzipWriter` # event.response.writer = GzipWriter.new # # # Set the encoding of the response to gzip # event.response.headers["content-encoding"] = "gzip" # end # end # end # # class ExampleController < ATH::Controller # @[ARTA::Get("/users")] # def users : Array(User) # User.all # end # end # # ATH.run # # # GET /users # => [{"id":1,...},...] (gzipped) # ``` abstract struct Writer # Accepts an *output* `IO` that the content of the response should be written to. abstract def write(output : IO, & : IO -> Nil) : Nil end # The default `AHTTP::Response::Writer` for an `AHTTP::Response`. # # Writes directly to the *output* `IO`. struct DirectWriter < Writer # :inherit: # # The *output* `IO` is yielded directly. def write(output : IO, & : IO -> Nil) : Nil yield output end end # See `AHTTP::Response::Writer`. setter writer : AHTTP::Response::Writer = AHTTP::Response::DirectWriter.new # Returns the `::HTTP::Status` of this response. getter status : ::HTTP::Status # Returns the character set this response is encoded as. property charset : String = "utf-8" # Returns the response headers of this response. getter headers : AHTTP::Response::Headers # Returns the contents of this response. getter content : String # Creates a new response with optional *content*, *status*, and *headers* arguments. def initialize(content : String? = nil, status : ::HTTP::Status | Int32 = ::HTTP::Status::OK, headers : ::HTTP::Headers | AHTTP::Response::Headers = AHTTP::Response::Headers.new) @content = content || "" @status = ::HTTP::Status.new status @headers = AHTTP::Response::Headers.new headers end # Sets the response content. def content=(content : String?) : self @content = content || "" self end # Sends `self` to the client based on the provided *context*. # # How the content gets written can be customized via an `AHTTP::Response::Writer`. def send(request : AHTTP::Request, response : ::HTTP::Server::Response) : self # Ensure the response is valid. self.prepare request # Apply the `AHTTP::Response` to the actual `::HTTP::Server::Response` object. response.headers.merge! @headers response.status = @status @headers.cookies.each do |c| response.cookies << c end # Write the response content last on purpose. # See https://github.com/crystal-lang/crystal/issues/8712 self.write response # Close the response. response.close self end # Sets the `::HTTP::Status` of this response. def status=(code : ::HTTP::Status | Int32) : self @status = ::HTTP::Status.new code self end # :nodoc: # # Do any preparation to ensure the response is RFC compliant. # # ameba:disable Metrics/CyclomaticComplexity def prepare(request : AHTTP::Request) : self # Set the content length if not already manually set @headers["content-length"] = @content.bytesize unless @headers.has_key? "content-length" if @status.informational? || @status.no_content? || @status.not_modified? self.content = nil @headers.delete "content-type" @headers.delete "content-length" else # Set `content-type` based on the request's format. unless @headers.has_key? "content-type" if (format = request.request_format nil) && (mime_type = request.mime_type format) @headers["content-type"] = mime_type end end # Add charset to `text/` based content types. charset = self.charset if (content_type = @headers["content-type"]?) && content_type.starts_with?("text/") && !content_type.includes?("charset") @headers["content-type"] = "#{content_type}; charset=#{charset}" end @headers.delete "content-length" if @headers.has_key? "transfer-encoding" if "HEAD" == request.method # See https://tools.ietf.org/html/rfc2616#section-14.13. length = @headers["content-length"]? self.content = nil @headers["content-length"] = length if length end end if "HTTP/1.0" == request.version && @headers.has_cache_control_directive?("no-cache") @headers["pragma"] = "no-cache" @headers["expires"] = "-1" end self end # Marks `self` as "public". # # Adds the `public` `cache-control` directive and removes the `private` directive. def set_public : self @headers.add_cache_control_directive "public" @headers.remove_cache_control_directive "private" self end # Returns the value of the `etag` header if set, otherwise `nil`. def etag : String? @headers["etag"]? end # Updates the `etag` header to the provided, optionally *weak*, *etag*. # Removes the header if *etag* is `nil`. def set_etag(etag : String? = nil, weak : Bool = false) : self if etag.nil? @headers.delete "etag" return self end unless etag.includes? '"' etag = %("#{etag}") end @headers["etag"] = "#{weak ? "W/" : ""}#{etag}" self end # Returns a `Time`representing the `last-modified` header if set, otherwise `nil`. def last_modified : Time? if header = @headers["last-modified"]? ::HTTP.parse_time header end end # Updates the `last-modified` header to the provided *time*. # Removes the header if *time* is `nil`. def last_modified=(time : Time? = nil) : self if time.nil? @headers.delete "last-modified" return self end @headers["last-modified"] = ::HTTP.format_time time self end # Returns `true` if this response is a redirect, optionally to the provided *location*. # Otherwise, returns `false`. def redirect?(location : String? = nil) : Bool case @status when .created?, .moved_permanently?, .found?, .see_other?, .temporary_redirect?, .permanent_redirect? # valid redirections statuses else return false end location ? location == @headers["location"] : location.nil? end # :nodoc: def write(output : IO) : Nil @writer.write(output, &.print(@content)) end end ================================================ FILE: src/components/http/src/response_headers.cr ================================================ # Wraps an [::HTTP::Headers](https://crystal-lang.org/api/HTTP/Headers.html) instance to provide additional functionality. # # Forwards all additional methods to the wrapped `::HTTP::Headers` instance. class Athena::HTTP::Response::Headers # Returns an [::HTTP::Cookies](https://crystal-lang.org/api/HTTP/Cookies.html) instance that stores cookies related to `self`. getter cookies : ::HTTP::Cookies { ::HTTP::Cookies.new } # A Hash representing the current [cache-control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header directives. @cache_control : Hash(String, String | Bool) = Hash(String, String | Bool).new @computed_cache_control : Hash(String, String | Bool) = Hash(String, String | Bool).new @headers = ::HTTP::Headers.new # :nodoc: forward_missing_to @headers # Utility constructor to allow calling `.new` with a union of `self` and `::HTTP::Headers`. # # Returns the provided *headers* object. def self.new(headers : self) : self headers end # Creates a new `self`, including the data from the provided *headers*. def initialize(headers : ::HTTP::Headers = ::HTTP::Headers.new) # Defer setting the cache-control header until other headers are set. # This is due to some other header values determining what the resulting # cache-control header value should be. E.g. expires. cache_control_header = headers.delete "cache-control" headers.each do |k, v| self.[k] = v end if cache_control_header self.["cache-control"] = cache_control_header elsif !@headers.has_key? "cache-control" self.["cache-control"] = "" end # See https://tools.ietf.org/html/rfc2616#section-14.18. unless @headers.has_key? "date" self.init_date end end # Adds the provided *cookie* to the `#cookies` container. def <<(cookie : ::HTTP::Cookie) : Nil self.cookies << cookie end # Sets a cookie with the provided *key* and *value*. # # NOTE: The *key* and cookie name must match. def []=(key : String, value : ::HTTP::Cookie) : Nil self.cookies[key] = value end # Sets a header with the provided *key* to the provided *value*. # # NOTE: This method will override the *value* of the provided *key*. def []=(key : String, value : Array(String)) : Nil if "set-cookie" == key.downcase value.each do |v| if cookie = ::HTTP::Cookie::Parser.parse_set_cookie v self.cookies << cookie else raise ArgumentError.new "Invalid cookie header: #{v}." end end return end @headers[key] = value end # :ditto: def []=(key : String, value : String) : Nil if "set-cookie" == key.downcase if cookie = ::HTTP::Cookie::Parser.parse_set_cookie value self.cookies << cookie else raise ArgumentError.new "Invalid cookie header: #{value}." end return end @headers[key] = value if "cache-control" == key.downcase @cache_control = AHTTP::HeaderUtils.parse value end if key.downcase.in?("cache-control", "etag", "last-modified", "expires") && (computed = self.compute_cache_control_value.presence) @headers["cache-control"] = computed @computed_cache_control = AHTTP::HeaderUtils.parse computed end end # :ditto: def []=(key : String, value : _) : Nil self.[key] = value.to_s end # Returns `true` if `self` is equal to the provided `::HTTP::Headers` instance. # Otherwise returns `false`. def ==(other : ::HTTP::Headers) : Bool @headers == other end # Adds the provided *value* to the the provided *key*. # # NOTE: This method will concatenate the *value* to the provided *key*. def add(key : String, value : String) : Nil @headers.add key, value if "cache-control" == key.downcase @cache_control = AHTTP::HeaderUtils.parse @headers["cache-control"] @headers["cache-control"] = self.cache_control_header end end # Adds the provided *directive*; updating the `cache-control` header. def add_cache_control_directive(directive : String, value : String | Bool = true) @cache_control[directive] = value == true ? value : value.to_s @headers["cache-control"] = self.cache_control_header end # Returns a `Time` instance by parsing the datetime string from the header with the provided *key*. # # Returns the provided *default* if no value with the provided *key* exists, or if parsing its value fails. # # ``` # time = HTTP.format_time Time.utc 2021, 4, 7, 12, 0, 0 # headers = AHTTP::Response::Headers{"date" => time} # # headers.date # => 2021-04-07 12:00:00.0 UTC # headers.date "foo" # => nil # headers.date "foo", Time.utc # => 2021-05-02 14:32:35.257505806 UTC # ``` def date(key : String = "date", default : Time? = nil) : Time? unless time = @headers[key]? return default end ::HTTP.parse_time time end # Deletes the header with the provided *key*. # # Clears the `#cookies` instance if *key* is `set-cookie`. # # Clears the `cache-control` header if *key* is `cache-control`. # # Reinitializes the `date` header if *key* is `date`. def delete(key : String) : Nil if "set-cookie" == key.downcase return self.cookies.clear end @headers.delete key if "cache-control" == key.downcase @cache_control.clear @computed_cache_control.clear end if "date" == key.downcase self.init_date end end # Returns the provided [directive](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#directives) from the `cache-control` header, or `nil` if it is not set. def get_cache_control_directive(directive : String) : String | Bool | Nil @computed_cache_control[directive]? end # Returns `true` if the current `cache-control` header has the provided [directive](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#directives). # Otherwise returns `false`. def has_cache_control_directive?(directive : String) : Bool @computed_cache_control.has_key? directive end # Removes the provided [directive](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#directives) from the `cache-control` header. def remove_cache_control_directive(directive : String) : Nil @cache_control.delete directive @headers["cache-control"] = self.cache_control_header end private def compute_cache_control_value : String if @cache_control.empty? if @headers.has_key?("last-modified") || @headers.has_key?("expires") # Allows for heuristic expiration (RFC 7234 Section 4.2.2) in the case of "last-modified" return "private, must-revalidate" end # Be Conservative by default return "no-cache, private" end header = self.cache_control_header if @cache_control.has_key?("public") || @cache_control.has_key?("private") return header end # Public if s-maxage is defined, private otherwise unless @cache_control.has_key? "s-maxage" return "#{header}, private" end header end private def cache_control_header : String AHTTP::HeaderUtils.to_string @cache_control, ", " end private def init_date : Nil @headers["date"] = ::HTTP.format_time Time.utc end end ================================================ FILE: src/components/http/src/streamed_response.cr ================================================ # Represents an `AHTTP::Response` whose content should be streamed to the client as opposed to being written all at once. # This can be useful in cases where the response content is too large to fit into memory. # # The content is stored in a proc that gets called when `self` is being written to the response IO. # How the output gets written can be customized via an `AHTTP::Response::Writer`. class Athena::HTTP::StreamedResponse < Athena::HTTP::Response @streamed : Bool = false # Creates a new response with optional *status*, and *headers* arguments. # # The block is captured and called when `self` is being written to the response's `IO`. # This can be useful to reduce memory overhead when needing to return large responses. # # ``` # require "athena" # # class ExampleController < ATH::Controller # @[ARTA::Get("/users")] # def users : AHTTP::Response # AHTTP::StreamedResponse.new headers: HTTP::Headers{"content-type" => "application/json; charset=utf-8"} do |io| # User.all.to_json io # end # end # end # # ATH.run # # # GET /users # => [{"id":1,...},...] # ``` def self.new(status : ::HTTP::Status | Int32 = ::HTTP::Status::OK, headers : ::HTTP::Headers | AHTTP::Response::Headers = AHTTP::Response::Headers.new, &block : IO -> Nil) new block, status, headers end # Creates a new response with the provided *callback* and optional *status*, and *headers* arguments. # # The proc is called when `self` is being written to the response's `IO`. def initialize(@callback : Proc(IO, Nil), status : ::HTTP::Status | Int32 = ::HTTP::Status::OK, headers : ::HTTP::Headers | AHTTP::Response::Headers = AHTTP::Response::Headers.new) # Manually add `transfer-encoding: chunked` so `ART::Response#prepare` knows how to properly handle this type of response. super nil, status, headers.merge!({"transfer-encoding" => "chunked"}) end # Updates the callback of `self`. def content=(@callback : Proc(IO, Nil)) end # :nodoc: def content=(content : String?) : Nil raise AHTTP::Exception::Logic.new "The content cannot be set on a StreamedResponse instance." unless content.nil? @streamed = true end # :nodoc: def write(output : IO) : Nil return if @streamed @streamed = true @writer.write(output) do |writer_io| @callback.call writer_io end end end ================================================ FILE: src/components/http/src/uploaded_file.cr ================================================ require "file_utils" require "./file" # Represents a file uploaded to the server. # Exposes related information from the client request. # See the [Getting Started](/getting_started/routing/#file-uploads) docs for more information. struct Athena::HTTP::UploadedFile < Athena::HTTP::AbstractFile # Represents the status of an uploaded file. # A successful upload would have a status of `OK`, # otherwise the enum member denotes the reason why the upload failed. # # TODO: Maybe add more status members? enum Status # Represents a successful upload. OK # Represents a failed upload due to the file being larger than the configured [max allowed size](/Framework/Bundle/Schema/FileUploads/#Athena::Framework::Bundle::Schema::FileUploads#max_file_size). SIZE_LIMIT_EXCEEDED end protected class_getter max_file_size : Int64 { 0_i64 } # :nodoc: # # Is expected to be set internally based on framework configuration value. def self.max_file_size=(@@max_file_size : Int64) : Nil; end # Returns the status of this uploaded file. getter status : Athena::HTTP::UploadedFile::Status @original_name : String @original_path : String protected def initialize( path : String | Path, original_name : String, mime_type : String? = nil, @status : Athena::HTTP::UploadedFile::Status = :ok, @test : Bool = false, ) super path, @status.ok? @original_name = clean_name original_name @original_path = original_name.gsub "\\", "/" @mime_type = mime_type || "application/octet-stream" end # Returns the original name of the file as determined by the client. # It should not be considered a safe value to use for a file on your server. def client_original_name : String @original_name end # Returns the original extension of the file as determined by the client. def client_original_extension : String ::File.extname(@original_name).lchop '.' end # Returns the original full file path as determined by the client. # It should not be considered a safe value to use for a file name/path on your server. # # If the file was uploaded with the `webkitdirectory` directive, this will contain the path of the file relative to the uploaded root directory. # Otherwise will be identical to `#client_original_name`. def client_original_path : String @original_path end # Returns the file's MIME type as determined by the client. # It should not be considered as a safe value. # # For a trusted MIME type, use [#mime_type][Athena::HTTP::AbstractFile#mime_type] (which guesses the MIME type based on the file's contents). def client_mime_type : String @mime_type end # Returns the extension based on the client MIME type, or `nil` if the MIME type is unknown. # This method uses `#client_mime_type`, and as such should not be trusted. # # For a trusted extension, use `#guess_extension` which guesses the extension based on the guessed MIME type for the file). def guess_client_extension : String? AMIME::Types.default.extensions(self.client_mime_type).first? end # Returns `true` if this file was successfully uploaded via HTTP, otherwise returns `true`. def valid? : Bool is_ok = @status.ok? @test ? is_ok : is_ok && self.uploaded_file? end # :inherit: def move(directory : Path | String, name : String? = nil) : Athena::HTTP::File if self.valid? return super end case @status when .size_limit_exceeded? then raise Athena::HTTP::Exception::FileSizeLimitExceeded.new self.error_message, file: @path end raise Athena::HTTP::Exception::File.new self.error_message, file: @path end # Returns an informational message as to why the upload failed. # # NOTE: Return value is only valid when the uploaded file's `#status` is _NOT_ `Status::OK`. def error_message : String original_name = self.client_original_name case @status when .size_limit_exceeded? then "The file '#{original_name}' exceeds your max_file_size configuration value (limit is #{self.class.max_file_size.humanize_bytes})." else "The file '#{original_name}' was not uploaded due to an unknown error." end end # This is an anti-pattern but I can't think of a better way to handle it # without making this DTO type depend upon a service. # # Or requiring DI in the validator component. private def uploaded_file? return false if (path = @path).empty? {% if @top_level.has_constant? "ADI" %} container = ADI.container if container.responds_to? :athena_framework_file_parser return container.athena_framework_file_parser.uploaded_file? path end {% end %} false end end ================================================ FILE: src/components/http_kernel/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/http_kernel/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/http_kernel/CHANGELOG.md ================================================ # Changelog ## [0.1.0] - 2026-04-19 _Initial release._ [0.1.0]: https://github.com/athena-framework/http-kernel/releases/tag/v0.1.0 ================================================ FILE: src/components/http_kernel/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/http_kernel/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2026 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/http_kernel/README.md ================================================ # HTTPKernel [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/workflows/CI/badge.svg)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/http-kernel.svg)](https://github.com/athena-framework/http-kernel/releases) Provides a structured process for converting a Request into a Response. ## Getting Started Checkout the [Documentation](https://athenaframework.org/HTTPKernel). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/http_kernel/docs/README.md ================================================ The `Athena::HTTPKernel` component provides a structured process for converting an [AHTTP::Request](/HTTP/Request) into an [AHTTP::Response](/HTTP/Response) by dispatching events throughout the request lifecycle. It serves as the foundation for the [Athena Framework](/getting_started), but can also be used standalone to build custom HTTP-based applications. ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-http_kernel: github: athena-framework/http-kernel version: ~> 0.1.0 ``` ## Usage The core of this component is [AHK::HTTPKernel](/HTTPKernel/HTTPKernel/), which orchestrates the request handling process by dispatching a series of events. ### Request Lifecycle When a request is handled, the following events are dispatched in order: 1. **[AHK::Events::Request](/HTTPKernel/Events/Request/)** - Dispatched before anything else. Listeners can return a response early or modify the request. 2. **[AHK::Events::Action](/HTTPKernel/Events/Action/)** - Dispatched after the action is determined but before it executes. Useful for accessing action metadata. 3. **[AHK::Events::View](/HTTPKernel/Events/View/)** - Dispatched if the action returns something other than a response. Listeners convert the return value into a response. 4. **[AHK::Events::Response](/HTTPKernel/Events/Response/)** - Dispatched after a response is created. Listeners can modify the response before it's sent. 5. **[AHK::Events::Terminate](/HTTPKernel/Events/Terminate/)** - Dispatched after the response is sent. Useful for "heavy" post-response processing. If an exception is raised at any point, [AHK::Events::Exception](/HTTPKernel/Events/Exception/) is dispatched to allow converting the exception into a response. ### Basic Example ```crystal require "athena-http_kernel" # Create the required dependencies event_dispatcher = AED::EventDispatcher.new request_store = AHTTP::RequestStore.new # Create a simple action resolver that always returns the same action. # In practice, this would come from some sort of "controller". class SimpleActionResolver include AHK::ActionResolverInterface def resolve(request : AHTTP::Request) : AHK::ActionBase? AHK::Action.new( action: Proc(typeof(Tuple.new), String).new { "Hello, World!" }, parameters: Tuple.new, _return_type: String ) end end # Create an argument resolver (no arguments needed for this simple example since it doesn't accept arguments) argument_resolver = AHK::Controller::ArgumentResolver.new([] of AHK::Controller::ValueResolvers::Interface) # Register a listener to convert the string return value into a response event_dispatcher.listener AHK::Events::View do |event| event.response = AHTTP::Response.new( status: :ok, content: event.action_result.to_s ) end # Register the error listener error_renderer = AHK::ErrorRenderer.new(debug: true) error_listener = AHK::Listeners::Error.new(error_renderer) event_dispatcher.listener error_listener # Create the kernel kernel = AHK::HTTPKernel.new( event_dispatcher, request_store, argument_resolver, SimpleActionResolver.new ) # Handle a request request = AHTTP::Request.new("GET", "/") response = kernel.handle(request) response.status # => HTTP::Status::OK response.content # => "Hello, World!" # Don't forget to terminate kernel.terminate(request, response) ``` ### HTTP Exceptions The component provides a hierarchy of HTTP exceptions under [AHK::Exception](/HTTPKernel/Exception/). These exceptions automatically set the appropriate HTTP status code and can include custom headers. ```crystal # Raise a 404 Not Found raise AHK::Exception::NotFound.new "Resource not found" # Raise a 400 Bad Request raise AHK::Exception::BadRequest.new "Invalid input" # Raise a 401 Unauthorized with WWW-Authenticate header raise AHK::Exception::Unauthorized.new "Authentication required", "Bearer" # Raise a 503 Service Unavailable with Retry-After header raise AHK::Exception::ServiceUnavailable.new "Try again later", retry_after: 300 ``` Non-HTTP exceptions are treated as `500 Internal Server Error` by default. ### Error Handling The [AHK::Listeners::Error](/HTTPKernel/Listeners/Error/) listener handles exceptions by converting them into responses via an [AHK::ErrorRendererInterface](/HTTPKernel/ErrorRendererInterface/). The default [AHK::ErrorRenderer](/HTTPKernel/ErrorRenderer/) produces JSON responses: ```json { "code": 404, "message": "Resource not found" } ``` Custom error rendering can be implemented by creating a type that includes `AHK::ErrorRendererInterface`: ```crystal class HTMLErrorRenderer include AHK::ErrorRendererInterface def render(exception : ::Exception) : AHTTP::Response status = exception.is_a?(AHK::Exception::HTTPException) ? exception.status : HTTP::Status::INTERNAL_SERVER_ERROR AHTTP::Response.new( status: status, headers: HTTP::Headers{"content-type" => "text/html"}, body: "

Error #{status.code}

#{HTML.escape(exception.message || "Unknown error")}

" ) end end ``` ### Value Resolvers Value resolvers determine how arguments are passed to controller actions. The component includes several built-in resolvers: - [AHK::Controller::ValueResolvers::Request](/HTTPKernel/Controller/ValueResolvers/Request/) - Injects the current request if the parameter type is `AHTTP::Request` - [AHK::Controller::ValueResolvers::RequestAttribute](/HTTPKernel/Controller/ValueResolvers/RequestAttribute/) - Resolves values from request attributes (e.g., route parameters) - [AHK::Controller::ValueResolvers::DefaultValue](/HTTPKernel/Controller/ValueResolvers/DefaultValue/) - Uses the parameter's default value or `nil` if nilable Custom resolvers can be created by implementing [AHK::Controller::ValueResolvers::Interface](/HTTPKernel/Controller/ValueResolvers/Interface/): ```crystal struct CurrentTimeResolver include AHK::Controller::ValueResolvers::Interface def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) : Time? return unless parameter.type == Time Time.utc end end ``` ## Integration with Athena Framework When used with the [Athena Framework](/getting_started), the HTTPKernel is automatically configured with: - Dependency injection for all components - The routing component for URL matching - Additional value resolvers for query parameters, request body deserialization, enums, UUIDs, and time parsing - View handling for content negotiation and serialization - CORS support See the [Getting Started](/getting_started) guide for full framework documentation. ## Learn More - [Middleware Architecture](/getting_started/middleware) - Detailed explanation of the event-driven request lifecycle - [Error Handling](/getting_started/error_handling) - Working with HTTP exceptions - [AHK::HTTPKernel](/HTTPKernel/HTTPKernel/) - API documentation for the kernel - [AHK::Events](/HTTPKernel/Events/) - All available lifecycle events - [AHK::Exception](/HTTPKernel/Exception/) - Available HTTP exception types ================================================ FILE: src/components/http_kernel/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: HTTPKernel site_url: https://athenaframework.org/HTTPKernel/ repo_url: https://github.com/athena-framework/http-kernel nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-contracts/src/athena-contracts.cr - ./lib/athena-event_dispatcher/src/athena-event_dispatcher.cr - ./lib/athena-http/src/athena-http.cr - ./lib/athena-http_kernel/src/athena-http_kernel.cr source_locations: lib/athena-http_kernel: https://github.com/athena-framework/http-kernel/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/http_kernel/shard.yml ================================================ name: athena-http_kernel version: 0.1.0 crystal: ~> 1.4 license: MIT repository: https://github.com/athena-framework/http-kernel documentation: https://athenaframework.org/HTTPKernel description: | Provides a structured process for converting a Request into a Response. authors: - George Dietrich dependencies: athena-http: github: athena-framework/http version: ~> 0.1.0 athena-event_dispatcher: github: athena-framework/event-dispatcher version: ~> 0.4.0 ================================================ FILE: src/components/http_kernel/spec/controller/argument_resolver_spec.cr ================================================ require "../spec_helper" private struct TrueResolver include AHK::Controller::ValueResolvers::Interface # :inherit: def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) 17 end end describe AHK::Controller::ArgumentResolver do describe "#get_arguments" do describe "when a value was able to be resolved" do it "should return an array of values" do route = new_action arguments: {new_parameter} AHK::Controller::ArgumentResolver.new([TrueResolver.new] of AHK::Controller::ValueResolvers::Interface).get_arguments(new_request, route).should eq [17] end end describe "when a value was not able to be resolved" do it "should raise a runtime error" do route = new_action arguments: {new_parameter} expect_raises(RuntimeError, "AHK::Action requires that you provide a value for the 'id' parameter. Either the parameter is nilable and no nil value has been provided, or no default value has been provided.") do AHK::Controller::ArgumentResolver.new([] of AHK::Controller::ValueResolvers::Interface).get_arguments(new_request, route) end end end end end ================================================ FILE: src/components/http_kernel/spec/controller/parameter_metadata_spec.cr ================================================ require "../spec_helper" describe AHK::Controller::ParameterMetadata do describe "#nilable?" do it "type is not nilable" do AHK::Controller::ParameterMetadata(Int32).new("foo").nilable?.should be_false AHK::Controller::ParameterMetadata(String | Bool).new("foo").nilable?.should be_false end it "type is nilable" do AHK::Controller::ParameterMetadata(Nil).new("foo").nilable?.should be_true AHK::Controller::ParameterMetadata(Int32?).new("foo").nilable?.should be_true AHK::Controller::ParameterMetadata(String | Bool | Nil).new("foo").nilable?.should be_true end end describe "#has_default?" do it true do AHK::Controller::ParameterMetadata(String).new("foo", true, "bar").has_default?.should be_true end it false do AHK::Controller::ParameterMetadata(Int32).new("foo", false, nil).has_default?.should be_false end end describe "#default_value" do it "with a default" do AHK::Controller::ParameterMetadata(String).new("foo", true, "bar").default_value.should eq "bar" end it "without a default" do expect_raises Exception, "Argument 'foo' does not have a default value." do AHK::Controller::ParameterMetadata(String).new("foo", false, nil).default_value end end end describe "#default_value?" do it "with a default" do AHK::Controller::ParameterMetadata(String).new("foo", true, "bar").default_value?.should eq "bar" end it "without a default" do AHK::Controller::ParameterMetadata(String).new("foo", false, nil).default_value?.should be_nil end end describe "#instance_of?" do it "with a scalar type" do AHK::Controller::ParameterMetadata(Int32).new("foo").instance_of?(Int32).should be_true AHK::Controller::ParameterMetadata(Int32).new("foo").instance_of?(Number).should be_true AHK::Controller::ParameterMetadata(Int32).new("foo").instance_of?(String).should be_false end it "with a union" do AHK::Controller::ParameterMetadata(String | Bool).new("foo").instance_of?(String).should be_true AHK::Controller::ParameterMetadata(Array(Bool) | Array(String)).new("foo").instance_of?(Array(String)).should be_true end it "nilable" do AHK::Controller::ParameterMetadata(String | Bool | Nil).new("foo").instance_of?(Bool).should be_true AHK::Controller::ParameterMetadata(String | Bool | Nil).new("foo").instance_of?(Int32).should be_false AHK::Controller::ParameterMetadata(Array(Bool) | Array(String) | Nil).new("foo").instance_of?(Array(String)).should be_true AHK::Controller::ParameterMetadata(Array(Bool) | Array(String) | Nil).new("foo").instance_of?(Array(Float64)).should be_false end end describe "#first_type_of" do it "with a single type var" do AHK::Controller::ParameterMetadata(Int32).new("foo").first_type_of(Int32).should eq Int32 AHK::Controller::ParameterMetadata(Array(Int32)).new("foo").first_type_of(Array).should eq Array(Int32) end it "with a union" do AHK::Controller::ParameterMetadata(String | Int32 | Bool).new("foo").first_type_of(Int32).should eq Int32 AHK::Controller::ParameterMetadata(Array(Int32) | Array(String)).new("foo").first_type_of(Array).should eq Array(Int32) end it "with a union of multiple valid type vars" do # Is Float64 because the union gets alphabetized AHK::Controller::ParameterMetadata(String | Int8 | Float64 | Int64).new("foo").first_type_of(Number).should eq Float64 end it "with no matching type var" do AHK::Controller::ParameterMetadata(String | Int32 | Bool).new("foo").first_type_of(Array).should be_nil AHK::Controller::ParameterMetadata(String | Int32 | Bool).new("foo").first_type_of(Float64).should be_nil end end end ================================================ FILE: src/components/http_kernel/spec/controller/value_resolvers/default_value_spec.cr ================================================ require "../../spec_helper" describe AHK::Controller::ValueResolvers::DefaultValue do describe "#resolve" do it "does not have a default nor is nilable" do parameter = AHK::Controller::ParameterMetadata(String).new "foo", false, nil AHK::Controller::ValueResolvers::DefaultValue.new.resolve(new_request, parameter).should be_nil end it "does not have a default but is nilable" do parameter = AHK::Controller::ParameterMetadata(String?).new "foo", false, nil AHK::Controller::ValueResolvers::DefaultValue.new.resolve(new_request, parameter).should be_nil end it "has a nil default value and is nilable" do parameter = AHK::Controller::ParameterMetadata(String?).new "foo", true, nil AHK::Controller::ValueResolvers::DefaultValue.new.resolve(new_request, parameter).should be_nil end it "with a default value" do parameter = AHK::Controller::ParameterMetadata(String).new "foo", true, "bar" AHK::Controller::ValueResolvers::DefaultValue.new.resolve(new_request, parameter).should eq "bar" end end end ================================================ FILE: src/components/http_kernel/spec/controller/value_resolvers/request_attribute_spec.cr ================================================ require "../../spec_helper" describe AHK::Controller::ValueResolvers::RequestAttribute do describe "#resolve" do it "that does not exist in the request attributes" do AHK::Controller::ValueResolvers::RequestAttribute.new.resolve(new_request, new_parameter).should be_nil end it "that exists in the request attributes" do request = new_request request.attributes.set "id", 1 AHK::Controller::ValueResolvers::RequestAttribute.new.resolve(request, new_parameter).should eq 1 end describe "that needs to be converted" do it String do parameter = AHK::Controller::ParameterMetadata(Int32).new "id" request = new_request request.attributes.set "id", "1" AHK::Controller::ValueResolvers::RequestAttribute.new.resolve(request, parameter).should eq 1 end it Bool do parameter = AHK::Controller::ParameterMetadata(Bool).new "id" request = new_request request.attributes.set "id", "false" AHK::Controller::ValueResolvers::RequestAttribute.new.resolve(request, parameter).should be_false end it "that fails conversion" do parameter = AHK::Controller::ParameterMetadata(Int32).new "id" request = new_request request.attributes.set "id", "foo" expect_raises AHK::Exception::BadRequest, "Parameter 'id' with value 'foo' could not be converted into a valid 'Int32'." do AHK::Controller::ValueResolvers::RequestAttribute.new.resolve request, parameter end end end end end ================================================ FILE: src/components/http_kernel/spec/controller/value_resolvers/request_spec.cr ================================================ require "../../spec_helper" describe AHK::Controller::ValueResolvers::Request do describe "#resolve" do it TestController do parameter = AHK::Controller::ParameterMetadata(TestController).new "foo" AHK::Controller::ValueResolvers::Request.new.resolve(new_request, parameter).should be_nil end it "with a valid value" do parameter = AHK::Controller::ParameterMetadata(AHTTP::Request).new "foo" request = new_request AHK::Controller::ValueResolvers::Request.new.resolve(request, parameter).should be request end end end ================================================ FILE: src/components/http_kernel/spec/error_renderer_spec.cr ================================================ require "./spec_helper" private class MockException < ::Exception def initialize(@first_line : String) super "ERR" end def backtrace? : Array(String) [@first_line] end end private class MockRequestException < ::Exception include AHTTP::Exception::RequestExceptionInterface end describe AHK::ErrorRenderer do it AHK::Exception::HTTPException do exception = AHK::Exception::TooManyRequests.new "cool your jets", 42 renderer = AHK::ErrorRenderer.new false response = renderer.render exception response.headers["retry-after"].should eq "42" response.headers["content-type"].should eq "application/json; charset=utf-8" response.status.should eq ::HTTP::Status::TOO_MANY_REQUESTS response.content.should eq %({"code":429,"message":"cool your jets"}) end it AHTTP::Exception::RequestExceptionInterface do exception = MockRequestException.new "ERR" renderer = AHK::ErrorRenderer.new false response = renderer.render exception response.headers["content-type"].should eq "application/json; charset=utf-8" response.headers["x-debug-exception-message"]?.should be_nil response.headers["x-debug-exception-class"]?.should be_nil response.headers["x-debug-exception-file"]?.should be_nil response.headers["x-debug-exception-code"]?.should be_nil response.status.should eq ::HTTP::Status::BAD_REQUEST response.content.should eq %({"code":400,"message":"Bad Request"}) end it ::Exception do exception = Exception.new "ERR" renderer = AHK::ErrorRenderer.new false response = renderer.render exception response.headers["content-type"].should eq "application/json; charset=utf-8" response.headers["x-debug-exception-message"]?.should be_nil response.headers["x-debug-exception-class"]?.should be_nil response.headers["x-debug-exception-file"]?.should be_nil response.headers["x-debug-exception-code"]?.should be_nil response.status.should eq ::HTTP::Status::INTERNAL_SERVER_ERROR response.content.should eq %({"code":500,"message":"Internal Server Error"}) end describe "debug mode" do it "line + column" do path = Path["src", "components", "framework", "spec", "error_renderer_spec.cr"] exception = MockException.new "#{path}:10:20" renderer = AHK::ErrorRenderer.new true response = renderer.render exception response.headers["content-type"].should eq "application/json; charset=utf-8" response.headers["x-debug-exception-message"].should eq "ERR" response.headers["x-debug-exception-class"].should eq "MockException" response.headers["x-debug-exception-file"].should match /#{URI.encode_path path.to_s}:\d+:\d+$/ response.headers["x-debug-exception-code"].should eq "500" response.status.should eq ::HTTP::Status::INTERNAL_SERVER_ERROR response.content.should eq %({"code":500,"message":"Internal Server Error"}) end it "only line" do path = Path["src", "components", "framework", "spec", "error_renderer_spec.cr"] exception = MockException.new "#{path}:10" renderer = AHK::ErrorRenderer.new true response = renderer.render exception response.headers["content-type"].should eq "application/json; charset=utf-8" response.headers["x-debug-exception-message"].should eq "ERR" response.headers["x-debug-exception-class"].should eq "MockException" response.headers["x-debug-exception-file"].should match /#{URI.encode_path path.to_s}:\d+$/ response.headers["x-debug-exception-code"].should eq "500" response.status.should eq ::HTTP::Status::INTERNAL_SERVER_ERROR response.content.should eq %({"code":500,"message":"Internal Server Error"}) end end end ================================================ FILE: src/components/http_kernel/spec/exception/bad_gateway_spec.cr ================================================ require "../spec_helper" describe AHK::Exception::BadGateway do describe "#initialize" do it "sets the message, and status" do exception = AHK::Exception::BadGateway.new "MESSAGE" exception.headers.should be_empty exception.status.should eq ::HTTP::Status::BAD_GATEWAY exception.message.should eq "MESSAGE" end end end ================================================ FILE: src/components/http_kernel/spec/exception/http_exception_spec.cr ================================================ require "../spec_helper" describe AHK::Exception::HTTPException do describe "#initialize" do it "sets the message, and status" do exception = AHK::Exception::HTTPException.new 200, "MESSAGE" exception.headers.should be_empty exception.status.should eq ::HTTP::Status::OK exception.message.should eq "MESSAGE" end end end ================================================ FILE: src/components/http_kernel/spec/exception/service_unavailable_spec.cr ================================================ require "../spec_helper" describe AHK::Exception::ServiceUnavailable do describe "#initialize" do it "sets the message, and status" do exception = AHK::Exception::ServiceUnavailable.new "MESSAGE" exception.headers.should be_empty exception.status.should eq ::HTTP::Status::SERVICE_UNAVAILABLE exception.message.should eq "MESSAGE" end it "sets the retry-after if given as a string" do exception = AHK::Exception::ServiceUnavailable.new "MESSAGE", "17" exception.headers.should eq ::HTTP::Headers{"retry-after" => "17"} end it "sets the retry-after if given" do exception = AHK::Exception::ServiceUnavailable.new "MESSAGE", 123 exception.headers.should eq ::HTTP::Headers{"retry-after" => "123"} end end end ================================================ FILE: src/components/http_kernel/spec/exception/too_many_requests_spec.cr ================================================ require "../spec_helper" describe AHK::Exception::TooManyRequests do describe "#initialize" do it "sets the message, and status" do exception = AHK::Exception::TooManyRequests.new "MESSAGE" exception.headers.should be_empty exception.status.should eq ::HTTP::Status::TOO_MANY_REQUESTS exception.message.should eq "MESSAGE" end it "sets the retry-after if given as a string" do exception = AHK::Exception::TooManyRequests.new "MESSAGE", "17" exception.headers.should eq ::HTTP::Headers{"retry-after" => "17"} end it "sets the retry-after if given" do exception = AHK::Exception::TooManyRequests.new "MESSAGE", 123 exception.headers.should eq ::HTTP::Headers{"retry-after" => "123"} end end end ================================================ FILE: src/components/http_kernel/spec/exception/unauthorized_spec.cr ================================================ require "../spec_helper" describe AHK::Exception::Unauthorized do describe "#initialize" do it "sets the message, status, and headers" do exception = AHK::Exception::Unauthorized.new "MESSAGE", "CHALLENGE" exception.headers.should eq ::HTTP::Headers{"www-authenticate" => "CHALLENGE"} exception.status.should eq ::HTTP::Status::UNAUTHORIZED exception.message.should eq "MESSAGE" end end end ================================================ FILE: src/components/http_kernel/spec/http_kernel_spec.cr ================================================ require "./spec_helper" private struct MockArgumentResolver include Athena::HTTPKernel::Controller::ArgumentResolverInterface def initialize(@exception : ::Exception? = nil); end def get_arguments(request : AHTTP::Request, action : AHK::ActionBase) : Array if ex = @exception raise ex end [] of String end end private struct MockActionResolver include Athena::HTTPKernel::ActionResolverInterface def initialize(@action : AHK::ActionBase? = nil); end def resolve(request : AHTTP::Request) : AHK::ActionBase? @action end end describe Athena::HTTPKernel::HTTPKernel do describe "#handle" do describe "request" do describe AHTTP::Response do it "should use the returned response" do dispatcher = AED::Spec::TracableEventDispatcher.new action = create_action(AHTTP::Response) do AHTTP::Response.new "TEST" end handler = AHK::HTTPKernel.new dispatcher, AHTTP::RequestStore.new, MockArgumentResolver.new, MockActionResolver.new action response = handler.handle new_request(action: action) response.status.should eq ::HTTP::Status::OK response.content.should eq "TEST" dispatcher.emitted_events.should eq [AHK::Events::Request, AHK::Events::Action, AHK::Events::Response] end it "should raise if the action is unable to be resolved" do dispatcher = AED::Spec::TracableEventDispatcher.new handler = AHK::HTTPKernel.new dispatcher, AHTTP::RequestStore.new, MockArgumentResolver.new, MockActionResolver.new expect_raises AHK::Exception::NotFound, "Unable to find the action for path '/test'." do handler.handle new_request end end end describe "view layer" do it "should resolve the returned value into a response" do dispatcher = AED::Spec::TracableEventDispatcher.new dispatcher.listener AHK::Events::View do |event| event.response = AHTTP::Response.new "TEST".to_json, 201, ::HTTP::Headers{"content-type" => "application/json"} end action = new_action handler = AHK::HTTPKernel.new dispatcher, AHTTP::RequestStore.new, MockArgumentResolver.new, MockActionResolver.new action response = handler.handle request = new_request action: action request.attributes.get("_action").should eq action response.status.should eq ::HTTP::Status::CREATED response.content.should eq %("TEST") response.headers["content-type"].should eq "application/json" dispatcher.emitted_events.should eq [AHK::Events::Request, AHK::Events::Action, AHK::Events::View, AHK::Events::Response] end it "should raise an exception if the value was not handled" do dispatcher = AED::Spec::TracableEventDispatcher.new action = create_action(String?) do nil end handler = AHK::HTTPKernel.new dispatcher, AHTTP::RequestStore.new, MockArgumentResolver.new, MockActionResolver.new action expect_raises Exception, "'TestController#test' must return an `AHTTP::Response` but it returned ''." do handler.handle new_request end dispatcher.emitted_events.should eq [AHK::Events::Request, AHK::Events::Action, AHK::Events::View, AHK::Events::Exception] end end describe "that was handled via a request listener" do it "should emit the proper events and set the proper response" do dispatcher = AED::Spec::TracableEventDispatcher.new dispatcher.listener AHK::Events::Request do |event| event.response = AHTTP::Response.new "", ::HTTP::Status::IM_A_TEAPOT, ::HTTP::Headers{"FOO" => "BAR"} end handler = AHK::HTTPKernel.new dispatcher, AHTTP::RequestStore.new, MockArgumentResolver.new, MockActionResolver.new response = handler.handle new_request response.status.should eq ::HTTP::Status::IM_A_TEAPOT response.content.should be_empty response.headers["FOO"].should eq "BAR" dispatcher.emitted_events.should eq [AHK::Events::Request, AHK::Events::Response] end end end describe "exception" do describe "that is handled" do it "should emit the proper events and set correct response" do dispatcher = AED::Spec::TracableEventDispatcher.new dispatcher.listener AHK::Events::Exception do |event| event.response = AHTTP::Response.new "HANDLED", ::HTTP::Status::BAD_REQUEST end handler = AHK::HTTPKernel.new dispatcher, AHTTP::RequestStore.new, MockArgumentResolver.new(AHK::Exception::BadRequest.new("TEST_EX")), MockActionResolver.new new_action response = handler.handle new_request response.status.should eq ::HTTP::Status::BAD_REQUEST response.content.should eq "HANDLED" dispatcher.emitted_events.should eq [AHK::Events::Request, AHK::Events::Action, AHK::Events::Exception, AHK::Events::Response] end end describe "that is not_handled" do it "should emit the proper events and set correct response" do dispatcher = AED::Spec::TracableEventDispatcher.new handler = AHK::HTTPKernel.new dispatcher, AHTTP::RequestStore.new, MockArgumentResolver.new(AHK::Exception::BadRequest.new("TEST_EX")), MockActionResolver.new new_action expect_raises AHK::Exception::BadRequest, "TEST_EX" do handler.handle new_request end dispatcher.emitted_events.should eq [AHK::Events::Request, AHK::Events::Action, AHK::Events::Exception] end end describe "when another exception is raised in the response listener" do it "should return the previous response" do dispatcher = AED::Spec::TracableEventDispatcher.new dispatcher.listener AHK::Events::Response do raise AHK::Exception::NotFound.new "NOT_FOUND" end dispatcher.listener AHK::Events::Exception do |event| event.response = AHTTP::Response.new "HANDLED", ::HTTP::Status::NOT_FOUND end action = create_action(AHTTP::Response) do AHTTP::Response.new "TEST" end handler = AHK::HTTPKernel.new dispatcher, AHTTP::RequestStore.new, MockArgumentResolver.new, MockActionResolver.new action response = handler.handle new_request action: action response.status.should eq ::HTTP::Status::NOT_FOUND response.content.should eq %(HANDLED) dispatcher.emitted_events.should eq [AHK::Events::Request, AHK::Events::Action, AHK::Events::Response, AHK::Events::Exception, AHK::Events::Response] end end end end describe "#terminate" do it "emits the terminate event" do dispatcher = AED::Spec::TracableEventDispatcher.new handler = AHK::HTTPKernel.new dispatcher, AHTTP::RequestStore.new, MockArgumentResolver.new, MockActionResolver.new handler.terminate new_request, AHTTP::Response.new dispatcher.emitted_events.should eq [AHK::Events::Terminate] end end end ================================================ FILE: src/components/http_kernel/spec/listeners/error_spec.cr ================================================ require "../spec_helper" private struct MockErrorRenderer include AHK::ErrorRendererInterface def render(exception : ::Exception) : AHTTP::Response AHTTP::Response.new "ERR", 418, ::HTTP::Headers{"FOO" => "BAR"} end end private class MockException < AHK::Exception::BadRequest end describe AHK::Listeners::Error do it "converts an exception into a response and logs the exception as warning" do event = AHK::Events::Exception.new new_request, MockException.new "Something went wrong" AHK::Listeners::Error.new(MockErrorRenderer.new).on_exception event response = event.response.should_not be_nil response.status.should eq ::HTTP::Status::IM_A_TEAPOT response.headers["FOO"].should eq "BAR" response.content.should eq "ERR" end describe "logging" do it "logs non HTTPExceptions as error" do event = AHK::Events::Exception.new new_request, Exception.new "err" Log.capture do |logs| AHK::Listeners::Error.new(MockErrorRenderer.new).on_exception event logs.check :error, /Exception:err/ end end it "logs server HTTPExceptions as error" do event = AHK::Events::Exception.new new_request, AHK::Exception::NotImplemented.new "nope" Log.capture do |logs| AHK::Listeners::Error.new(MockErrorRenderer.new).on_exception event logs.check :error, /Athena::HTTPKernel::Exception::NotImplemented:nope/ end end it "logs validation errors as notice" do event = AHK::Events::Exception.new new_request, AHK::Exception::UnprocessableEntity.new "Validation tests failed" Log.capture do |logs| AHK::Listeners::Error.new(MockErrorRenderer.new).on_exception event logs.check :notice, /Athena::HTTPKernel::Exception::UnprocessableEntity:Validation tests failed/ end end it "logs HTTPExceptions as warning" do event = AHK::Events::Exception.new new_request, MockException.new "Something went wrong" Log.capture do |logs| AHK::Listeners::Error.new(MockErrorRenderer.new).on_exception event logs.check :warn, /MockException:Something went wrong/ end end end end ================================================ FILE: src/components/http_kernel/spec/spec_helper.cr ================================================ require "spec" require "log/spec" require "athena-spec" require "../src/athena-http_kernel" require "athena-event_dispatcher/spec" ASPEC.run_all class TestController def get_test : String "TEST" end end macro create_action(return_type = String, &) AHK::Action.new( Proc(typeof(Tuple.new), {{return_type}}).new { {{yield}} }, Tuple.new, {{return_type}}, ) end def new_parameter : AHK::Controller::ParameterMetadata AHK::Controller::ParameterMetadata(Int32).new "id" end def new_action( *, arguments : Tuple = Tuple.new, ) : AHK::ActionBase AHK::Action.new( Proc(typeof(Tuple.new), String).new { test_controller = TestController.new; test_controller.get_test }, arguments, String, ) end def new_request( *, path : String = "/test", method : String = "GET", action : AHK::ActionBase = new_action, body : String | IO | Nil = nil, query : String? = nil, format : String = "json", files : Hash(String, Array(AHTTP::UploadedFile)) = {} of String => Array(AHTTP::UploadedFile), headers : ::HTTP::Headers = ::HTTP::Headers.new, ) : AHTTP::Request request = AHTTP::Request.new method, path, body: body request.files.merge! files request.attributes.set "_controller", "TestController#test", String request.attributes.set "_route", "test_controller_test", String request.attributes.set "_action", action request.query = query request.headers = ::HTTP::Headers{ "content-type" => AHTTP::Request::FORMATS[format].first, }.merge! headers request end ================================================ FILE: src/components/http_kernel/src/action.cr ================================================ # Parent type of a controller action just used for typing. # # See `AHK::Action`. abstract class Athena::HTTPKernel::ActionBase abstract def resolve_arguments(value_resolvers : Array, request : AHTTP::Request) : Array abstract def execute(arguments : Array) # :inherit: def inspect(io : IO) : Nil io << "#" end end # Represents a controller action that will handle a request. # # Includes metadata about the endpoint, such as its action parameters and return type, and the action that should be executed. class Athena::HTTPKernel::Action(ReturnType, ParameterTypeTuple, ParametersType) < Athena::HTTPKernel::ActionBase # Returns a tuple of `AHK::Controller::ParameterMetadata` representing the parameters this action expects. getter parameters : ParametersType def initialize( @action : Proc(ParameterTypeTuple, ReturnType), @parameters : ParametersType, # Don't bother making this an ivar since we just need it to set the generic type _return_type : ReturnType.class, ); end # Returns the type that this action returns. def return_type : ReturnType.class ReturnType end # Executes this action with the provided *arguments* array. def execute(arguments : Array) : ReturnType @action.call {{ParameterTypeTuple.type_vars.empty? ? "Tuple.new".id : ParameterTypeTuple}}.from arguments end # Resolves the arguments for this action for the given *request*. # # This is defined in here as opposed to `AHK::Controller::ArgumentResolver` so that the free vars are resolved correctly. # See https://forum.crystal-lang.org/t/incorrect-overload-selected-with-freevar-and-generic-inheritance/3625. def resolve_arguments(value_resolvers : Array, request : AHTTP::Request) : Array {% begin %} {% if 0 == ParametersType.size %} Tuple.new.to_a {% else %} { {% for idx in (0...ParametersType.size) %} begin %parameter = @parameters[{{idx}}] # Variadic parameters are not supported. # `nil` represents both the value `nil` and that resolver was unable to resolve a value # Each resolver can return at most one value. # First resolver to resolve a non-nil value wins, otherwise `nil` itself is used as the value, # assuming the parameter accepts nil, otherwise an error is raised. value = value_resolvers.each do |resolver| resolved_value = resolver.resolve request, %parameter break resolved_value unless resolved_value.nil? end if value.nil? && !%parameter.nilable? raise RuntimeError.new "AHK::Action requires that you provide a value for the '#{%parameter.name}' parameter. Either the parameter is nilable and no nil value has been provided, or no default value has been provided." end value end, {% end %} }.to_a {% end %} {% end %} end end ================================================ FILE: src/components/http_kernel/src/action_resolver.cr ================================================ # Default `AHK::ActionResolverInterface` implementation that looks for an `_action` key # within [AHTTP::Request#attributes](/HTTP/Request/#Athena::HTTP::Request#attributes). class Athena::HTTPKernel::ActionResolver include Athena::HTTPKernel::ActionResolverInterface # :inherit: def resolve(request : AHTTP::Request) : AHK::ActionBase? request.attributes.get? "_action", AHK::ActionBase end end ================================================ FILE: src/components/http_kernel/src/action_resolver_interface.cr ================================================ # Resolves the `AHK::ActionBase` for a given request. # # The route matched via `AHK::Listeners::Routing` (or equivalent) needs to be resolved to the `AHK::ActionBase` instance that actually represents the action (controller) of the request. module Athena::HTTPKernel::ActionResolverInterface # Resolves the `AHK::ActionBase` instance that should handle the provided *request*. abstract def resolve(request : AHTTP::Request) : AHK::ActionBase? end ================================================ FILE: src/components/http_kernel/src/athena-http_kernel.cr ================================================ require "log" require "json" require "semantic_version" require "athena-event_dispatcher" require "athena-http" require "./action" require "./action_resolver_interface" require "./action_resolver" require "./error_renderer_interface" require "./error_renderer" require "./http_kernel" require "./controller/**" require "./events/request_aware" require "./events/settable_response" require "./events/*" require "./exception/http_exception" require "./exception/*" require "./listeners/error" macro finished {% if @top_level.has_constant?("ART") %} require "./listeners/routing" {% end %} end # Convenience alias to make referencing `Athena::HTTPKernel` types easier. alias AHK = Athena::HTTPKernel module Athena::HTTPKernel VERSION = "0.1.0" Log = ::Log.for "athena.http_kernel" # This type includes all of the built-in resolvers that the HTTPKernel uses to try and resolve an argument for a particular controller action parameter. # # Custom resolvers may also be defined. # See `AHK::Controller::ValueResolvers::Interface` for more information. module Controller::ValueResolvers; end # The `ACTR::EventDispatcher::Event` that are emitted via `Athena::EventDispatcher` to handle a request during its life-cycle. # Custom events can also be defined and dispatched within a controller, listener, or some other service. module Events; end # Exception handling in Athena is similar to exception handling in any Crystal program, with the addition of a new unique exception type, `AHK::Exception::HTTPException`. # # When an exception is raised, Athena emits the `AHK::Events::Exception` event to allow an opportunity for it to be handled. # If the exception goes unhandled, i.e. no listener set an `AHTTP::Response` on the event, then the request is finished and the exception is re-raised. # Otherwise, that response is returned, setting the status and merging the headers on the exceptions if it is an `AHK::Exception::HTTPException`. # See `AHK::Listeners::Error` and `AHK::ErrorRendererInterface` for more information on how exceptions are handled by default. # # To provide the best response to the client, non `AHK::Exception::HTTPException` should be rescued and converted into a corresponding `AHK::Exception::HTTPException`. # Custom HTTP errors can also be defined by inheriting from `AHK::Exception::HTTPException` or a child type. # A use case for this could be allowing for additional data/context to be included within the exception that ultimately could be used in a `AHK::Events::Exception` listener. module Exception; end end ================================================ FILE: src/components/http_kernel/src/controller/argument_resolver.cr ================================================ require "./value_resolvers/interface" require "./argument_resolver_interface" # The default implementation of `AHK::Controller::ArgumentResolverInterface`. struct Athena::HTTPKernel::Controller::ArgumentResolver include Athena::HTTPKernel::Controller::ArgumentResolverInterface def initialize(@value_resolvers : Array(Athena::HTTPKernel::Controller::ValueResolvers::Interface)); end # :inherit: def get_arguments(request : AHTTP::Request, action : AHK::ActionBase) : Array action.resolve_arguments @value_resolvers, request end end ================================================ FILE: src/components/http_kernel/src/controller/argument_resolver_interface.cr ================================================ # Responsible for resolving the arguments that will be passed to a controller action. # # See the [Getting Started](/getting_started/middleware#argument-resolution) docs for more information. module Athena::HTTPKernel::Controller::ArgumentResolverInterface # Returns an array of arguments resolved from the provided *request* for the given *action*. abstract def get_arguments(request : AHTTP::Request, action : AHK::ActionBase) : Array end ================================================ FILE: src/components/http_kernel/src/controller/parameter_metadata.cr ================================================ # Represents a controller action parameter. Stores metadata associated with it, such as its name, type, and default value if any. struct Athena::HTTPKernel::Controller::ParameterMetadata(T) # :inherit: def inspect(io : IO) : Nil io << "#" end # Returns the name of the parameter. getter name : String # Returns `true` if this parameter has a default value set, otherwise `false`. getter? has_default : Bool # :nodoc: def initialize( @name : String, @has_default : Bool = false, @default_value : T? = nil, ); end # If `nil` is a valid value for the parameter. def nilable? : Bool {{T.nilable?}} end # Returns the default value for this parameter, raising an exception if it does not have one. def default_value : T raise AHK::Exception::Logic.new "Argument '#{@name}' does not have a default value." unless self.has_default? @default_value.not_nil! end # Returns the default value for this parameter, or `nil` if it does not have one. def default_value? : T? @default_value end # The type of the parameter, i.e. what its type restriction is. def type : T.class T end # Returns `true` if this parameter's `#type` includes the provided *klass*. # # ``` # AHK::Controller::ParameterMetadata(Int32).new("foo").instance_of?(Int32) # => true # AHK::Controller::ParameterMetadata(Int32 | Bool).new("foo").instance_of?(Bool) # => true # AHK::Controller::ParameterMetadata(Int32).new("foo").instance_of?(String) # => false # ``` def instance_of?(klass : Type.class) : Bool forall Type {{ T.union? ? T.union_types.any? { |t| t <= Type } : T <= Type }} end # Returns the metaclass of the first matching type variable that is an `#instance_of?` the provided *klass*, or `nil` if none match. # If this the `#type` is union, this would be the first viable type. # # ``` # AHK::Controller::ParameterMetadata(Int32).new("foo").first_type_of(Int32) # => Int32.class # AHK::Controller::ParameterMetadata(String | Int32 | Bool).new("foo").first_type_of(Int32) # => Int32.class # AHK::Controller::ParameterMetadata(String | Int8 | Float64 | Int64).new("foo").first_type_of(Number) # => Float64.class # AHK::Controller::ParameterMetadata(String | Int32 | Bool).new("foo").first_type_of(Float64) # => nil # ``` def first_type_of(klass : Type.class) forall Type {% if T.union? %} {% for t in T.union_types %} {% if t <= Type %} return {{t}} {% end %} {% end %} {% elsif T <= Type %} {{T}} {% end %} end end ================================================ FILE: src/components/http_kernel/src/controller/value_resolvers/default_value.cr ================================================ # Resolves the default value of a controller action parameter if no other value was provided; # using `nil` if the parameter does not have a default value, but is nilable. # # ``` # AHK::Controller::ParameterMetadata(Int32).new("id", has_default: true, default_value: 123) # # resolve would return 123 # ``` struct Athena::HTTPKernel::Controller::ValueResolvers::DefaultValue include Athena::HTTPKernel::Controller::ValueResolvers::Interface # :inherit: def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) return if !parameter.has_default? && !parameter.nilable? parameter.default_value? end end ================================================ FILE: src/components/http_kernel/src/controller/value_resolvers/interface.cr ================================================ # Value resolvers handle resolving the argument(s) to pass to a controller action based on values stored within the `AHTTP::Request`, or some other source. # # Custom resolvers can be defined by creating a type that implements this interface. # The first resolver to return a value wins and no other resolvers will be executed for that particular parameter. # The resolver should return `nil` to denote no value could be resolved, such as if the parameter is of the wrong type, does not have a specific annotation applied, or anything else that can be deduced from either parameter. # If no resolver is able to resolve a value for a specific parameter, an error is thrown and processing of the request ceases. module Athena::HTTPKernel::Controller::ValueResolvers::Interface # Returns a value resolved from the provided *request* and *parameter* if possible, otherwise returns `nil` if no parameter could be resolved. abstract def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) end ================================================ FILE: src/components/http_kernel/src/controller/value_resolvers/request.cr ================================================ # Handles resolving a value for action parameters typed as `AHTTP::Request`. # # ``` # @[ARTA::Get("/")] # def get_request_path(request : AHTTP::Request) : String # request.path # end # ``` struct Athena::HTTPKernel::Controller::ValueResolvers::Request include Athena::HTTPKernel::Controller::ValueResolvers::Interface # :inherit: def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) : AHTTP::Request? return unless parameter.instance_of? ::AHTTP::Request request end end ================================================ FILE: src/components/http_kernel/src/controller/value_resolvers/request_attribute.cr ================================================ # Handles resolving a value that is stored in the request's [AHTTP::Request#attributes](/HTTP/Request/#Athena::HTTP::Request#attributes). # This includes any path/query parameters, custom values stored via an event listener, or extra `defaults` stored within the routing annotation. # # ``` # @[ARTA::Get("/{id}")] # def get_id(id : Int32) : Int32 # id # end # ``` struct Athena::HTTPKernel::Controller::ValueResolvers::RequestAttribute include Athena::HTTPKernel::Controller::ValueResolvers::Interface # :inherit: def resolve(request : AHTTP::Request, parameter : AHK::Controller::ParameterMetadata) return unless request.attributes.has? parameter.name value = request.attributes.get parameter.name parameter.type.from_parameter value rescue ex : ArgumentError # Catch type cast errors and bubble it up as a BadRequest raise AHK::Exception::BadRequest.new "Parameter '#{parameter.name}' with value '#{value}' could not be converted into a valid '#{parameter.type}'.", cause: ex end end ================================================ FILE: src/components/http_kernel/src/error_renderer.cr ================================================ # The default `AHK::ErrorRendererInterface`, JSON serializes the exception. struct Athena::HTTPKernel::ErrorRenderer include Athena::HTTPKernel::ErrorRendererInterface def initialize(@debug : Bool); end # :inherit: def render(exception : ::Exception) : AHTTP::Response headers = ::HTTP::Headers.new content = exception.to_json if exception.is_a? AHK::Exception::HTTPException status = exception.status headers = exception.headers elsif exception.is_a?(AHTTP::Exception::RequestExceptionInterface) status = ::HTTP::Status::BAD_REQUEST content = {code: 400, message: "Bad Request"}.to_json else status = ::HTTP::Status::INTERNAL_SERVER_ERROR end headers["content-type"] = "application/json; charset=utf-8" # TODO: Use a better API to get the file/line/column info. if @debug && (backtrace = exception.backtrace?.try(&.first).to_s.presence) if (match = backtrace.match(/(.*):(\d+):(\d+)/)) || (match = backtrace.match(/(.*):(\d+)/)) headers["x-debug-exception-message"] = URI.encode_path exception.message.to_s headers["x-debug-exception-class"] = exception.class.to_s headers["x-debug-exception-code"] = status.value.to_s file = "#{URI.encode_path(match[1])}:#{match[2]}" if m3 = match[3]? file = "#{file}:#{m3}" end headers["x-debug-exception-file"] = file end end AHTTP::Response.new content, status, headers end end ================================================ FILE: src/components/http_kernel/src/error_renderer_interface.cr ================================================ # An `AHK::ErrorRendererInterface` converts an `::Exception` into an `AHTTP::Response`. # # The default implementation JSON serialize exceptions. # However, it can be overridden to allow rendering errors differently, such as via HTML. module Athena::HTTPKernel::ErrorRendererInterface # Renders the given *exception* into an `AHTTP::Response`. abstract def render(exception : ::Exception) : AHTTP::Response end ================================================ FILE: src/components/http_kernel/src/events/action_event.cr ================================================ # Emitted after `AHK::Events::Request` and the related `AHK::Action` has been resolved, but before it has been executed. # # See the [Getting Started](/getting_started/middleware#2-action-event) docs for more information. class Athena::HTTPKernel::Events::Action < ACTR::EventDispatcher::Event include Athena::HTTPKernel::Events::RequestAware # The related `AHK::Action` that will be used to handle the current request. getter action : AHK::ActionBase def initialize(request : AHTTP::Request, @action : AHK::ActionBase) super request end end ================================================ FILE: src/components/http_kernel/src/events/exception_event.cr ================================================ # Emitted when an exception occurs. See `AHK::Exception` for more information on how exception handling works in Athena. # # This event can be listened on to recover from errors or to modify the exception before it's rendered. # # See the [Getting Started](/getting_started/middleware#8-exception-handling) docs for more information. class Athena::HTTPKernel::Events::Exception < ACTR::EventDispatcher::Event include Athena::HTTPKernel::Events::SettableResponse include Athena::HTTPKernel::Events::RequestAware # The `::Exception` associated with `self`. # # Can be replaced by an `AHK::Listeners::Error`. property exception : ::Exception def initialize(request : AHTTP::Request, @exception : ::Exception) super request end end ================================================ FILE: src/components/http_kernel/src/events/request_aware.cr ================================================ # Represents an event that has access to the current request object. module Athena::HTTPKernel::Events::RequestAware # Returns the current request object. getter request : AHTTP::Request def initialize(@request : AHTTP::Request); end end ================================================ FILE: src/components/http_kernel/src/events/request_event.cr ================================================ # Emitted very early in the request's life-cycle; before the corresponding `AHK::Action` (if any) has been resolved. # # This event can be listened on in order to: # # * Add information to the request, via its [AHTTP::Request#attributes](/HTTP/Request/#Athena::HTTP::Request#attributes) # * Return a response immediately if there is enough information available # # NOTE: If your listener logic requires that the the corresponding `AHK::Action` has been resolved, use `AHK::Events::Action` instead. # # See the [Getting Started](/getting_started/middleware#1-request-event) docs for more information. class Athena::HTTPKernel::Events::Request < ACTR::EventDispatcher::Event include Athena::HTTPKernel::Events::SettableResponse include Athena::HTTPKernel::Events::RequestAware end ================================================ FILE: src/components/http_kernel/src/events/response_event.cr ================================================ # Emitted after the route's action has been executed, but before the response has been returned to the client. # # This event can be listened on to modify the response object further before it is returned; # such as adding headers/cookies, compressing the response, etc. # # See the [Getting Started](/getting_started/middleware#5-response-event) docs for more information. class Athena::HTTPKernel::Events::Response < ACTR::EventDispatcher::Event include Athena::HTTPKernel::Events::RequestAware # The response object. property response : AHTTP::Response def initialize(request : AHTTP::Request, @response : AHTTP::Response) super request end end ================================================ FILE: src/components/http_kernel/src/events/settable_response.cr ================================================ # Represents an event where an `AHTTP::Response` can be set on `self` to handle the original `AHTTP::Request`. # # WARNING: Once `#response=` is called, propagation stops; i.e. listeners with lower priority will not be executed. module Athena::HTTPKernel::Events::SettableResponse # The response object, if any. getter response : AHTTP::Response? = nil # Sets the *response* that will be returned for the current `AHTTP::Request` being handled. # # Propagation of `self` will stop once `#response=` is called. def response=(@response : AHTTP::Response) : Nil self.stop_propagation end end ================================================ FILE: src/components/http_kernel/src/events/terminate_event.cr ================================================ # Emitted very late in the request's life-cycle, after the response has been sent. # # This event can be listened on to perform tasks that are not required to finish before the response is sent; such as sending emails, or other "heavy" tasks. # # See the [Getting Started](/getting_started/middleware#7-terminate-event) docs for more information. class Athena::HTTPKernel::Events::Terminate < ACTR::EventDispatcher::Event include Athena::HTTPKernel::Events::RequestAware # The response object. getter response : AHTTP::Response def initialize(request : AHTTP::Request, @response : AHTTP::Response) super request end end ================================================ FILE: src/components/http_kernel/src/events/view_event.cr ================================================ # Emitted after the route's action has been executed, but only if it does _NOT_ return an `AHTTP::Response`. # # This event can be listened on to handle converting a non `AHTTP::Response` into an `AHTTP::Response`. # # See the [Getting Started](/getting_started/middleware#4-view-event) docs for more information. class Athena::HTTPKernel::Events::View < ACTR::EventDispatcher::Event include Athena::HTTPKernel::Events::SettableResponse include Athena::HTTPKernel::Events::RequestAware private module ContainerBase; end private record ResultContainer(T), data : T do include ContainerBase # :inherit: def inspect(io : IO) : Nil io << "#" end end @result : ContainerBase def initialize(request : AHTTP::Request, action_result : _) super request @result = ResultContainer.new action_result end # Returns the value returned from the related controller action. def action_result @result.data end # Overrides the return value of the related controller action. # # Can be used to mutate the controller action's returned value within a listener context; # such as for pagination. def action_result=(value : _) : Nil @result = ResultContainer.new value end end ================================================ FILE: src/components/http_kernel/src/exception/bad_gateway.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::BadGateway < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :bad_gateway, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/bad_request.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::BadRequest < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :bad_request, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/conflict.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::Conflict < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :conflict, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/forbidden.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::Forbidden < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :forbidden, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/gone.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::Gone < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :gone, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/http_exception.cr ================================================ # :nodoc: class ::Exception def to_json(builder : JSON::Builder) : Nil builder.object do builder.field "code", 500 builder.field "message", "Internal Server Error" end end end # Represents an HTTP error. # # Each child represents a specific HTTP error with the associated status code. # Also optionally allows adding headers to the resulting response. # # Can be used directly/inherited from to represent non-typical HTTP errors/codes. class Athena::HTTPKernel::Exception::HTTPException < ::Exception include Athena::HTTPKernel::Exception # Helper method to return the proper exception subclass for the provided *status*. # The *message*, *cause*, and *headers* are passed along as well if provided. # # ameba:disable Metrics/CyclomaticComplexity def self.from_status( status : Int32 | ::HTTP::Status, message : String = "", cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new, ) : self status = status.is_a?(::HTTP::Status) ? status : ::HTTP::Status.new(status) case status when .bad_request? then AHK::Exception::BadRequest.new(message, cause, headers) when .forbidden? then AHK::Exception::Forbidden.new(message, cause, headers) when .not_found? then AHK::Exception::NotFound.new(message, cause, headers) when .not_acceptable? then AHK::Exception::NotAcceptable.new(message, cause, headers) when .conflict? then AHK::Exception::Conflict.new(message, cause, headers) when .gone? then AHK::Exception::Gone.new(message, cause, headers) when .length_required? then AHK::Exception::LengthRequired.new(message, cause, headers) when .precondition_failed? then AHK::Exception::PreconditionFailed.new(message, cause, headers) when .unsupported_media_type? then AHK::Exception::UnsupportedMediaType.new(message, cause, headers) when .unprocessable_entity? then AHK::Exception::UnprocessableEntity.new(message, cause, headers) when .too_many_requests? then AHK::Exception::TooManyRequests.new(message, nil, cause, headers) when .service_unavailable? then AHK::Exception::ServiceUnavailable.new(message, nil, cause, headers) else new status, message, cause, headers end end # The `::HTTP::Status` associated with `self`. getter status : ::HTTP::Status # Any HTTP response headers associated with `self`. # # Some HTTP errors use response headers to give additional information about `self`. property headers : ::HTTP::Headers # Instantiates `self` with the given *status* and *message*. # # Optionally includes *cause*, and *headers*. def initialize(@status : ::HTTP::Status, message : String, cause : ::Exception? = nil, @headers : ::HTTP::Headers = ::HTTP::Headers.new) super message, cause end # Instantiates `self` with the given *status_code* and *message*. # # Optionally includes *cause*, and *headers*. def self.new(status_code : Int32, message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) new ::HTTP::Status.new(status_code), message, cause, headers end # Returns the HTTP status code of `#status`. def status_code : Int32 @status.value end # Serializes `self` to JSON in the format of `{"code":400,"message":"Exception message"}` def to_json(builder : JSON::Builder) : Nil builder.object do builder.field "code", self.status_code builder.field "message", @message end end end ================================================ FILE: src/components/http_kernel/src/exception/length_required.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::LengthRequired < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :length_required, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/logic.cr ================================================ # Represents a code logic error that should lead directly to a fix in your code. class Athena::HTTPKernel::Exception::Logic < ::Exception include Athena::HTTPKernel::Exception end ================================================ FILE: src/components/http_kernel/src/exception/method_not_allowed.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::MethodNotAllowed < Athena::HTTPKernel::Exception::HTTPException def initialize( allow : Array(String), message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new, ) headers["allow"] = allow.join ", ", &.upcase super :method_not_allowed, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/not_acceptable.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::NotAcceptable < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :not_acceptable, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/not_found.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::NotFound < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :not_found, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/not_implemented.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::NotImplemented < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :not_implemented, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/precondition_failed.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::PreconditionFailed < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :precondition_failed, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/service_unavailable.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::ServiceUnavailable < Athena::HTTPKernel::Exception::HTTPException # See `Athena::HTTPKernel::Exception::HTTPException#new`. # # If *retry_after* is provided, adds a `retry-after` header that represents the number of seconds or HTTP-date after which the request may be retried. def initialize(message : String, retry_after : Number | String | Nil = nil, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) headers["retry-after"] = retry_after.to_s if retry_after super :service_unavailable, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/stop_format_listener.cr ================================================ class Athena::HTTPKernel::Exception::StopFormatListener < ::Exception include Athena::HTTPKernel::Exception end ================================================ FILE: src/components/http_kernel/src/exception/too_many_requests.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::TooManyRequests < Athena::HTTPKernel::Exception::HTTPException # See `Athena::HTTPKernel::Exception::HTTPException#new`. # # If *retry_after* is provided, adds a `retry-after` header that represents the number of seconds or HTTP-date after which the request may be retried. def initialize(message : String, retry_after : Number | String | Nil = nil, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) headers["retry-after"] = retry_after.to_s if retry_after super :too_many_requests, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/unauthorized.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::Unauthorized < Athena::HTTPKernel::Exception::HTTPException # See `Athena::HTTPKernel::Exception::HTTPException#new`. # # Includes a `www-authenticate` header with the provided *challenge*. def initialize(message : String, challenge : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) headers["www-authenticate"] = challenge super :unauthorized, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/unprocessable_entity.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::UnprocessableEntity < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :unprocessable_entity, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/exception/unsupported_media_type.cr ================================================ require "./http_exception" class Athena::HTTPKernel::Exception::UnsupportedMediaType < Athena::HTTPKernel::Exception::HTTPException def initialize(message : String, cause : ::Exception? = nil, headers : ::HTTP::Headers = ::HTTP::Headers.new) super :unsupported_media_type, message, cause, headers end end ================================================ FILE: src/components/http_kernel/src/http_kernel.cr ================================================ # The entry-point into `Athena::HTTPKernel`. # # Emits events that handle a given request and returns the resulting `AHTTP::Response`. struct Athena::HTTPKernel::HTTPKernel def initialize( @event_dispatcher : AED::EventDispatcherInterface, @request_store : AHTTP::RequestStore, @argument_resolver : AHK::Controller::ArgumentResolverInterface, @action_resolver : AHK::ActionResolverInterface, ) end def handle(request : ::HTTP::Request) : AHTTP::Response self.handle AHTTP::Request.new request end def handle(request : AHTTP::Request) : AHTTP::Response handle_raw request rescue ex : ::Exception event = AHK::Events::Exception.new request, ex @event_dispatcher.dispatch event exception = event.exception unless response = event.response finish_request raise exception end if exception.is_a? AHK::Exception::HTTPException response.status = exception.status response.headers.merge! exception.headers end begin finish_response response, request rescue response end end # Terminates a request/response lifecycle. # # Should be called after sending the response to the client. def terminate(request : AHTTP::Request, response : AHTTP::Response) : Nil @event_dispatcher.dispatch AHK::Events::Terminate.new request, response end private def handle_raw(request : AHTTP::Request) : AHTTP::Response # Set the current request in the RequestStore. @request_store.request = request # Emit the request event. request_event = AHK::Events::Request.new request @event_dispatcher.dispatch request_event # Return the event early if the request event handled the request. if response = request_event.response return finish_response response, request end unless action = @action_resolver.resolve request raise AHK::Exception::NotFound.new "Unable to find the action for path '#{request.path}'." end # Emit the action event. @event_dispatcher.dispatch AHK::Events::Action.new request, action # Resolve the arguments for this action from the request. arguments = @argument_resolver.get_arguments request, action # Call the action and get the response. response = action.execute arguments unless response.is_a? AHTTP::Response view_event = AHK::Events::View.new request, response @event_dispatcher.dispatch view_event unless response = view_event.response raise %('#{request.attributes.get? "_controller" || "AHK::Action"}' must return an `AHTTP::Response` but it returned '#{response}'.) end end finish_response response, request end private def finish_response(response : AHTTP::Response, request : AHTTP::Request) : AHTTP::Response # Emit the response event. event = AHK::Events::Response.new request, response @event_dispatcher.dispatch event self.finish_request event.response end private def finish_request : Nil # Reset the request store. @request_store.reset end end ================================================ FILE: src/components/http_kernel/src/listeners/error.cr ================================================ # Handles an exception by converting it into an `AHTTP::Response` via an `AHK::ErrorRendererInterface`. # # This listener defines a `log_exception` protected method that determines how the exception gets logged. # Non `AHK::Exception::HTTPException`s and server errors are logged as errors. # Validation errors (`AHK::Exception::UnprocessableEntity`) are logged as notice. # Everything else is logged as a warning. # The method can be redefined if different logic is desired. # # ``` # struct AHK::Listeners::Error # # :inherit: # protected def log_exception(exception : ::Exception, & : -> String) : Nil # # Don't log anything if an exception is some specific type. # return if exception.is_a? MyException # # # Exception types could also include modules to act as interfaces to determine their level, E.g. `include NoticeException`. # if exception.is_a? NoticeException # Log.notice(exception: exception) { yield } # return # end # # # Otherwise fallback to the default implementation. # previous_def # end # end # ``` struct Athena::HTTPKernel::Listeners::Error def initialize(@error_renderer : AHK::ErrorRendererInterface); end @[AEDA::AsEventListener(priority: -50)] def on_exception(event : AHK::Events::Exception) : Nil exception = event.exception log_exception(exception) { "Uncaught exception #{exception.inspect} at #{exception.backtrace?.try &.first}" } event.response = @error_renderer.render event.exception rescue ex : ::Exception # Also log exceptions raised when handling an exception log_exception(ex) { "Exception raised when handling an exception #{ex.inspect} at #{ex.backtrace?.try &.first}" } raise ex end # Logs the provided *exception*, *yields* if the message will be logged. # # Applications can redefine this method to customize how exceptions are logged. protected def log_exception(exception : ::Exception, & : -> String) : Nil if !exception.is_a?(AHK::Exception::HTTPException) || exception.status.server_error? # Log non HTTPExceptions and server errors as errors Log.error(exception: exception) { yield } elsif exception.is_a? AHK::Exception::UnprocessableEntity # Log failed validations as notice Log.notice(exception: exception) { yield } else # Log everything else as warnings Log.warn(exception: exception) { yield } end end end ================================================ FILE: src/components/http_kernel/src/listeners/routing.cr ================================================ # Sets route parameters on the current request via an `ART::RequestMatcherInterface`. # # This listener is only functional when the `athena-routing` component is available. struct Athena::HTTPKernel::Listeners::Routing @request_context : ART::RequestContext def initialize( @matcher : ART::Matcher::URLMatcherInterface | ART::Matcher::RequestMatcherInterface, request_context : ART::RequestContext? = nil, ) @request_context = request_context || @matcher.context end @[AEDA::AsEventListener(priority: 32)] def on_request(event : AHK::Events::Request) : Nil request = event.request @request_context.apply request begin parameters = if @matcher.is_a? ART::Matcher::RequestMatcherInterface @matcher.match request else @matcher.match request.path end Log.info &.emit %(Matched route '#{matched_route = parameters["_route"]? || "n/a"}'), route: matched_route, route_parameters: parameters.to_h, request_uri: request.resource, method: request.method parameters.each { |k, v| request.attributes.set k, v } parameters.delete "_route" request.attributes.set "_route_params", parameters, ART::Parameters rescue ex : ART::Exception::ResourceNotFound message = "No route found for '#{request.method} #{request.resource}'" # This is misspelled on purpose, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer. if referrer = request.headers["referer"]? # spellchecker:disable-line message += " (from: '#{referrer}')" end message += "." raise AHK::Exception::NotFound.new message, ex rescue ex : ART::Exception::MethodNotAllowed raise AHK::Exception::MethodNotAllowed.new( ex.allowed_methods, %(No route found for '#{request.method} #{request.resource}': Method Not Allowed (Allow: #{ex.allowed_methods.join ", "}).), ex ) end end end ================================================ FILE: src/components/image_size/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/image_size/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/image_size/CHANGELOG.md ================================================ # Changelog ## [0.1.4] - 2025-01-26 _Administrative release, no functional changes_ [0.1.4]: https://github.com/athena-framework/image-size/releases/tag/v0.1.4 ## [0.1.3] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/image-size/releases/tag/v0.1.3 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.1.2] - 2023-10-09 _Administrative release, no functional changes_ [0.1.2]: https://github.com/athena-framework/image-size/releases/tag/v0.1.2 ## [0.1.1] - 2022-05-14 _First release a part of the monorepo._ ### Added - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Changed - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Fixed - Fix incorrect `description` key in `shard.yml` ([#171]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/image-size/releases/tag/v0.1.1 [#169]: https://github.com/athena-framework/athena/pull/169 [#171]: https://github.com/athena-framework/athena/pull/171 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.1.0] - 2022-02-21 _Initial release._ [0.1.0]: https://github.com/athena-framework/image-size/releases/tag/v0.1.0 ================================================ FILE: src/components/image_size/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/image_size/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2022 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/image_size/README.md ================================================ # ImageSize [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/image-size.svg)](https://github.com/athena-framework/image-size/releases) Measures the size of various image formats. ## Getting Started Checkout the [Documentation](https://athenaframework.org/ImageSize). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/image_size/docs/README.md ================================================ The `Athena::ImageSize` component allows measuring the size of various [image formats](/ImageSize/Image/Format/). ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-image_size: github: athena-framework/image-size version: ~> 0.1.0 ``` ## Usage an [AIS::Image](/ImageSize/Image/) instance can be instantiated given a path to an image file, or via an [IO](https://crystal-lang.org/api/IO.html). From there, information about the image can be accessed off of the instance. ```crystal AIS::Image.from_file_path "spec/images/jpeg/436x429_8_3.jpeg" # => # Athena::ImageSize::Image( # @bits=8, # @channels=3, # @format=JPEG, # @height=429, # @width=436) ``` ================================================ FILE: src/components/image_size/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Image Size site_url: https://athenaframework.org/ImageSize/ repo_url: https://github.com/athena-framework/image-size nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-image_size/src/athena-image_size.cr source_locations: lib/athena-image_size: https://github.com/athena-framework/image-size/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/image_size/shard.yml ================================================ name: athena-image_size version: 0.1.4 crystal: ~> 1.4 license: MIT repository: https://github.com/athena-framework/image-size documentation: https://athenaframework.org/ImageSize description: | Measures the size of various image formats. authors: - George Dietrich ================================================ FILE: src/components/image_size/spec/athena-image_size_spec.cr ================================================ require "./spec_helper" struct ImageTest < ASPEC::TestCase @[DataProvider("files")] def test_from_io(file_path : String) File.open file_path do |file| image = AIS::Image.from_io file filename = File.basename file_path # width, height, bits, channels, format /(\d+)x(\d+)_(\d+)_(\d+)\.(\w+)$/.match(filename) expected_width, expected_height, expected_bits, expected_channels, expected_format = $~[1..5] expected_bits = "0" == expected_bits ? nil : expected_bits.to_i expected_channels = "0" == expected_channels ? nil : expected_channels.to_i image.width.should eq expected_width.to_i image.height.should eq expected_height.to_i image.size.should eq({expected_width.to_i, expected_height.to_i}) image.bits.should eq expected_bits image.channels.should eq expected_channels image.format.should eq AIS::Image::Format.parse(expected_format) end end def test_from_io_unsupported_raises : Nil tempfile = File.tempfile tempfile.write Bytes.new 50, 0 tempfile.rewind expect_raises Exception, "Unsupported image format." do AIS::Image.from_io tempfile end tempfile.delete end def test_from_io_unsupported_nil : Nil tempfile = File.tempfile tempfile.write Bytes.new 50, 0 tempfile.rewind AIS::Image.from_io?(tempfile).should be_nil tempfile.delete end def test_from_io_parse_failure_nil : Nil tempfile = File.tempfile tempfile.write_byte 0x00 tempfile.write_byte 0x00 tempfile.write_byte 0x01 tempfile.write_byte 0x00 tempfile.write_bytes 50 tempfile.write_bytes 10 tempfile.write_bytes 10 tempfile.write_byte 0x00 tempfile.write_byte 0x01 # This byte is required to be `0` tempfile.rewind AIS::Image.from_io?(tempfile).should be_nil tempfile.delete end def test_from_io_parse_failure_raises : Nil tempfile = File.tempfile tempfile.write_byte 0x00 tempfile.write_byte 0x00 tempfile.write_byte 0x01 tempfile.write_byte 0x00 tempfile.write_bytes 50 tempfile.write_bytes 10 tempfile.write_bytes 10 tempfile.write_byte 0x00 tempfile.write_byte 0x01 # This byte is required to be `0` tempfile.rewind expect_raises Exception, "Failed to parse image." do AIS::Image.from_io tempfile end tempfile.delete end @[DataProvider("files")] def test_from_file_path(file_path : String) image = AIS::Image.from_file_path file_path filename = File.basename file_path # width, height, bits, channels, format /(\d+)x(\d+)_(\d+)_(\d+)\.(\w+)$/.match(filename) expected_width, expected_height, expected_bits, expected_channels, expected_format = $~[1..5] expected_bits = "0" == expected_bits ? nil : expected_bits.to_i expected_channels = "0" == expected_channels ? nil : expected_channels.to_i image.width.should eq expected_width.to_i image.height.should eq expected_height.to_i image.bits.should eq expected_bits image.channels.should eq expected_channels image.format.should eq AIS::Image::Format.parse(expected_format) end def test_from_file_path_unsupported_raises : Nil tempfile = File.tempfile tempfile.write Bytes.new 50, 0 tempfile.rewind expect_raises Exception, "Unsupported image format." do AIS::Image.from_file_path tempfile.path end tempfile.delete end def test_from_file_path_unsupported_nil : Nil tempfile = File.tempfile tempfile.write Bytes.new 50, 0 tempfile.rewind AIS::Image.from_file_path?(tempfile.path).should be_nil tempfile.delete end def test_from_file_path_parse_failure_nil : Nil tempfile = File.tempfile tempfile.write_byte 0x00 tempfile.write_byte 0x00 tempfile.write_byte 0x01 tempfile.write_byte 0x00 tempfile.write_bytes 50 tempfile.write_bytes 10 tempfile.write_bytes 10 tempfile.write_byte 0x00 tempfile.write_byte 0x01 # This byte is required to be `0` tempfile.rewind AIS::Image.from_file_path?(tempfile.path).should be_nil tempfile.delete end def test_from_file_path_parse_failure_raises : Nil tempfile = File.tempfile tempfile.write_byte 0x00 tempfile.write_byte 0x00 tempfile.write_byte 0x01 tempfile.write_byte 0x00 tempfile.write_bytes 50 tempfile.write_bytes 10 tempfile.write_bytes 10 tempfile.write_byte 0x00 tempfile.write_byte 0x01 # This byte is required to be `0` tempfile.rewind expect_raises Exception, "Failed to parse image." do AIS::Image.from_file_path tempfile.path end tempfile.delete end def files : Hash Dir.glob("#{__DIR__}/images/*/*").each_with_object(Hash(String, Tuple(String)).new) do |name, hash| hash[name] = {name} end end end ================================================ FILE: src/components/image_size/spec/spec_helper.cr ================================================ require "spec" require "athena-spec" require "../src/athena-image_size" ASPEC.run_all ================================================ FILE: src/components/image_size/src/athena-image_size.cr ================================================ require "./image_format" require "./extractors/*" require "./image" # Convenience alias to make referencing `Athena::ImageSize` types easier. alias AIS = Athena::ImageSize # Allows measuring the size of various [image formats][Athena::ImageSize::Image::Format]. module Athena::ImageSize VERSION = "0.1.4" # Represents the [DPI (Dots Per Inch)](https://en.wikipedia.org/wiki/Dots_per_inch) used to calculate dimensions of `AIS::Image::Format::SVG` images, defaulting to `72.0`. class_property dpi : Float64 = 72.0 # :nodoc: module Extractors; end end ================================================ FILE: src/components/image_size/src/extractors/abstract_ico.cr ================================================ require "./extractor" abstract struct Athena::ImageSize::Extractors::AbstractICO < Athena::ImageSize::Extractors::Extractor def self.extract(io : IO) : AIS::Image? num_icons = io.read_bytes UInt16 width = 0 height = 0 bits = 0 return if num_icons < 1 || num_icons > 255 num_icons.times do icon_width = io.read_bytes UInt8 icon_height = io.read_bytes UInt8 # Skip color count io.skip 1 # This bit must be `0` return unless io.read_bytes(UInt8).zero? # Skip color planes io.skip 2 if (icon_bits = io.read_bytes UInt16) >= bits width, height, bits = icon_width, icon_height, icon_bits end end Image.new width.zero? ? 256 : width, height.zero? ? 256 : height, self.format, bits end end ================================================ FILE: src/components/image_size/src/extractors/abstract_png.cr ================================================ require "./extractor" abstract struct Athena::ImageSize::Extractors::AbstractPNG < Athena::ImageSize::Extractors::Extractor private SIGNATURE = Bytes[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, read_only: true] # Based on https://github.com/php/php-src/blob/95da6e807a948039d3a42defbd849c4fed6cbe35/ext/standard/image.c#L299. def self.extract(io : IO) : AIS::Image? io.skip 4 # Skip data length and type return if "IHDR" != io.read_string(4) width = io.read_bytes UInt32, IO::ByteFormat::BigEndian height = io.read_bytes UInt32, IO::ByteFormat::BigEndian bits = io.read_bytes UInt8, IO::ByteFormat::BigEndian io.skip 8 # Skip rest of chunk data, and CRC format = Image::Format::PNG # Determine if the PNG is an actual PNG or an APNG loop do data_chunk_length = io.read_bytes UInt32, IO::ByteFormat::BigEndian chunk_type = io.read_string 4 break if chunk_type.in? "IDAT", "IEND", nil if "acTL" == chunk_type format = Image::Format::APNG break end io.skip data_chunk_length + 4 # Skips data and CRC chunk end Image.new width, height, format, bits end def self.matches?(io : IO, bytes : Bytes) : Bool return false unless bytes[0, 3] == SIGNATURE[0, 3] eight_bytes = Bytes.new 8 io.pos -= 3 io.read_fully eight_bytes eight_bytes == SIGNATURE end end ================================================ FILE: src/components/image_size/src/extractors/abstract_tiff.cr ================================================ abstract struct Athena::ImageSize::Extractors::AbstractTIFF < Athena::ImageSize::Extractors::Extractor private enum Tag ImageWidth = 0x0100 ImageLength = 0x0101 BitsPerSample = 0x0102 SamplesPerPixel = 0x0115 end private enum DataType : UInt16 BYTE = 1 # 8-bit unsigned integer STRING = 2 # 8-bit, NULL-terminated string USHORT = 3 # 16-bit unsigned integer ULONG = 4 # 32-bit unsigned integer URATIONAL = 5 # Two 32-bit unsigned integers SBYTE = 6 # 8-bit signed integer UNDEFINED = 7 # 8-bit byte SSHORT = 8 # 16-bit signed integer SLONG = 9 # 32-bit signed integer SRATIONAL = 10 # Two 32-bit signed integers FLOAT = 11 # 4-byte single-precision IEEE floating-point value DOUBLE = 12 # 8-byte double-precision IEEE floating-point value end # ameba:disable Metrics/CyclomaticComplexity def self.extract(io : IO) : AIS::Image? offset = io.read_bytes UInt32, self.byte_format io.skip offset - 8 # Account for already read bytes num_dirent = io.read_bytes UInt16, self.byte_format num_dirent = (io.pos + 2) + (num_dirent * 12) width = height = bits = channels = nil until width && height && bits && channels return if io.pos > num_dirent ifd = Bytes.new 12 io.read_fully ifd ifd = IO::Memory.new ifd, false unless tag = Tag.from_value? ifd.read_bytes UInt16, self.byte_format next end data_type = DataType.new ifd.read_bytes UInt16, self.byte_format ifd.skip 4 entry_value = case data_type when .byte?, .sbyte? then ifd.read_bytes(Int8, self.byte_format) when .ushort? then ifd.read_bytes(UInt16, self.byte_format) when .sshort? then ifd.read_bytes(Int16, self.byte_format) when .ulong? then ifd.read_bytes(UInt32, self.byte_format) when .slong? then ifd.read_bytes(Int32, self.byte_format) else next end case tag in .image_width? then width = entry_value in .image_length? then height = entry_value in .bits_per_sample? then bits = entry_value in .samples_per_pixel? then channels = entry_value end end Image.new width, height, :tiff, bits, channels end end ================================================ FILE: src/components/image_size/src/extractors/apng.cr ================================================ struct Athena::ImageSize::Extractors::APNG < Athena::ImageSize::Extractors::AbstractPNG end ================================================ FILE: src/components/image_size/src/extractors/bmp.cr ================================================ require "./extractor" struct Athena::ImageSize::Extractors::BMP < Athena::ImageSize::Extractors::Extractor private SIGNATURE = Bytes['B'.ord, 'M'.ord, read_only: true] def self.extract(io : IO) : AIS::Image? io.skip 11 # Skip rest of Header chunk info_header_length = io.read_bytes UInt32 if 12 == info_header_length # BITMAPCOREHEADER width = io.read_bytes Int16 height = io.read_bytes Int16 io.skip 3 bits = io.read_bytes UInt8 elsif 40 == info_header_length # BITMAPINFOHEADER width = io.read_bytes Int32 height = io.read_bytes(Int32).abs io.skip 2 bits = io.read_bytes UInt16 else return end Image.new width, height, :bmp, bits.zero? ? nil : bits end def self.matches?(io : IO, bytes : Bytes) : Bool bytes[0, 2] == SIGNATURE end end ================================================ FILE: src/components/image_size/src/extractors/cur.cr ================================================ struct Athena::ImageSize::Extractors::CUR < Athena::ImageSize::Extractors::AbstractICO private SIGNATURE = Bytes[0x00, 0x00, 0x02, 0x00, read_only: true] def self.matches?(io : IO, bytes : Bytes) : Bool bytes[0, 4] == SIGNATURE end protected def self.format : AIS::Image::Format Image::Format::CUR end end ================================================ FILE: src/components/image_size/src/extractors/extractor.cr ================================================ # :nodoc: abstract struct Athena::ImageSize::Extractors::Extractor # ameba:disable Metrics/CyclomaticComplexity def self.from_io(io : IO) bytes = Bytes.new 3 io.read_fully bytes return PNG if PNG.matches? io, bytes return JPEG if JPEG.matches? io, bytes return GIF if GIF.matches? io, bytes return BMP if BMP.matches? io, bytes return APNG if APNG.matches? io, bytes return SWF if SWF.matches? io, bytes # Read in an additionl bytes to determine the format. bytes = Bytes.new 4 io.pos -= 3 io.read_fully bytes return MMTIFF if MMTIFF.matches? io, bytes return IITIFF if IITIFF.matches? io, bytes return ICO if ICO.matches? io, bytes return CUR if CUR.matches? io, bytes return PSD if PSD.matches? io, bytes # Read in an additionl bytes to determine the format. bytes = Bytes.new 8 io.pos -= 4 io.read_fully bytes return MNG if MNG.matches? io, bytes # Read in an additionl bytes to determine the format. bytes = Bytes.new 12 io.pos -= 8 io.read_fully bytes return WebP if WebP.matches? io, bytes # Read in an additionl bytes to determine the format. # These are text based formats so will need to instantiate a string for the logic to work. # Being sure to rewind the IO. bytes = Bytes.new 4096 io.pos -= 12 io.read_fully? bytes io.rewind return SVG if SVG.matches? io, bytes nil end end ================================================ FILE: src/components/image_size/src/extractors/gif.cr ================================================ struct Athena::ImageSize::Extractors::GIF < Athena::ImageSize::Extractors::Extractor private SIGNATURE = Bytes['G'.ord, 'I'.ord, 'F'.ord, read_only: true] # Based on https://github.com/php/php-src/blob/95da6e807a948039d3a42defbd849c4fed6cbe35/ext/standard/image.c#L100. def self.extract(io : IO) : AIS::Image? io.skip 3 # Skip the version string width = io.read_bytes(UInt16) height = io.read_bytes(UInt16) packed_bit = io.read_byte.not_nil! # Not 100% sure what this is doing, probably parsing something from the packed field. bits = !(packed_bit & 0x80).zero? ? ((packed_bit & 0x07) + 1) : 0 Image.new width, height, :gif, bits, 3 end def self.matches?(io : IO, bytes : Bytes) : Bool bytes[0, 3] == SIGNATURE end end ================================================ FILE: src/components/image_size/src/extractors/ico.cr ================================================ struct Athena::ImageSize::Extractors::ICO < Athena::ImageSize::Extractors::AbstractICO private SIGNATURE = Bytes[0x00, 0x00, 0x01, 0x00, read_only: true] def self.matches?(io : IO, bytes : Bytes) : Bool bytes[0, 4] == SIGNATURE end protected def self.format : AIS::Image::Format Image::Format::ICO end end ================================================ FILE: src/components/image_size/src/extractors/ii_tiff.cr ================================================ struct Athena::ImageSize::Extractors::IITIFF < Athena::ImageSize::Extractors::AbstractTIFF private SIGNATURE = Bytes['I'.ord, 'I'.ord, 0x2A, 0x00, read_only: true] def self.matches?(io : IO, bytes : Bytes) : Bool bytes[0, 4] == SIGNATURE end protected def self.byte_format : IO::ByteFormat IO::ByteFormat::LittleEndian end end ================================================ FILE: src/components/image_size/src/extractors/jpeg.cr ================================================ struct Athena::ImageSize::Extractors::JPEG < Athena::ImageSize::Extractors::Extractor private SIGNATURE = Bytes[0xff, 0xd8, 0xff, read_only: true] private enum Block : UInt8 M_SOF0 = 0xC0 # Start Of Frame N M_SOF1 = 0xC1 # N indicates which compression process M_SOF2 = 0xC2 # Only SOF0-SOF2 are now in common use M_SOF3 = 0xC3 M_SOF5 = 0xC5 # NB: codes C4 and CC are NOT SOF markers M_SOF6 = 0xC6 M_SOF7 = 0xC7 M_SOF9 = 0xC9 M_SOF10 = 0xCA M_SOF11 = 0xCB M_SOF13 = 0xCD M_SOF14 = 0xCE M_SOF15 = 0xCF M_SOI = 0xD8 M_EOI = 0xD9 # End Of Image (end of datastream) M_SOS = 0xDA # Start Of Scan (begins compressed data) M_APP0 = 0xe0 M_APP1 = 0xe1 M_APP2 = 0xe2 M_APP3 = 0xe3 M_APP4 = 0xe4 M_APP5 = 0xe5 M_APP6 = 0xe6 M_APP7 = 0xe7 M_APP8 = 0xe8 M_APP9 = 0xe9 M_APP10 = 0xea M_APP11 = 0xeb M_APP12 = 0xec M_APP13 = 0xed M_APP14 = 0xee M_APP15 = 0xef M_COM = 0xFE # COMment end def self.extract(io : IO) : AIS::Image? ff_read = true image = nil loop do marker = self.next_marker io, ff_read ff_read = false case marker when Block::M_SOF0, Block::M_SOF1, Block::M_SOF2, Block::M_SOF3, Block::M_SOF5, Block::M_SOF6, Block::M_SOF7, Block::M_SOF9, Block::M_SOF10, Block::M_SOF11, Block::M_SOF13, Block::M_SOF14, Block::M_SOF15 if image.nil? io.read_bytes UInt16, IO::ByteFormat::BigEndian bits = io.read_byte.not_nil! height = io.read_bytes UInt16, IO::ByteFormat::BigEndian width = io.read_bytes UInt16, IO::ByteFormat::BigEndian channels = io.read_byte.not_nil! return Image.new width, height, :jpeg, bits, channels elsif !self.skip_variable(io) return image.unsafe_as(Image) end next when Block::M_APP0, Block::M_APP1, Block::M_APP2, Block::M_APP3, Block::M_APP4, Block::M_APP5, Block::M_APP6, Block::M_APP7, Block::M_APP8, Block::M_APP9, Block::M_APP10, Block::M_APP11, Block::M_APP12, Block::M_APP13, Block::M_APP14, Block::M_APP15 if !self.skip_variable(io) return image.unsafe_as(Image) end next when Block::M_SOS, Block::M_EOI then return image.unsafe_as(Image) else if !self.skip_variable(io) return image.unsafe_as(Image) end end end image.unsafe_as(Image) end def self.matches?(io : IO, bytes : Bytes) : Bool bytes[0, 3] == SIGNATURE end private def self.next_marker(io : IO, ff_read : Bool) : Block if !ff_read extraneous = 0 while (marker = io.read_byte) != 0xff return Block::M_EOI if marker.nil? extraneous += 1 end end a = 1 marker = nil loop do marker = io.read_byte return Block::M_EOI if marker.nil? a += 1 break if marker != 0xff end if a < 2 return Block::M_EOI end Block.new marker.not_nil! end private def self.skip_variable(io : IO) : Bool length = io.read_bytes UInt16, IO::ByteFormat::BigEndian if length < 2 return false end length -= 2 io.pos += length true end end ================================================ FILE: src/components/image_size/src/extractors/mm_tiff.cr ================================================ struct Athena::ImageSize::Extractors::MMTIFF < Athena::ImageSize::Extractors::AbstractTIFF private SIGNATURE = Bytes['M'.ord, 'M'.ord, 0x00, 0x2A, read_only: true] def self.matches?(io : IO, bytes : Bytes) : Bool bytes[0, 4] == SIGNATURE end protected def self.byte_format : IO::ByteFormat IO::ByteFormat::BigEndian end end ================================================ FILE: src/components/image_size/src/extractors/mng.cr ================================================ struct Athena::ImageSize::Extractors::MNG < Athena::ImageSize::Extractors::Extractor private SIGNATURE = Bytes[0x8a, 0x4d, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, read_only: true] def self.extract(io : IO) : AIS::Image? io.skip 4 # Skip the version string return if "MHDR" != io.read_string(4) width = io.read_bytes UInt32, IO::ByteFormat::BigEndian height = io.read_bytes UInt32, IO::ByteFormat::BigEndian Image.new width, height, :mng end def self.matches?(io : IO, bytes : Bytes) : Bool bytes[0, 8] == SIGNATURE end end ================================================ FILE: src/components/image_size/src/extractors/png.cr ================================================ struct Athena::ImageSize::Extractors::PNG < Athena::ImageSize::Extractors::AbstractPNG end ================================================ FILE: src/components/image_size/src/extractors/psd.cr ================================================ struct Athena::ImageSize::Extractors::PSD < Athena::ImageSize::Extractors::Extractor private SIGNATURE = Bytes['8'.ord, 'B'.ord, 'P'.ord, 'S'.ord, read_only: true] def self.extract(io : IO) : AIS::Image? io.skip 8 # Skip version and reversed section channels = io.read_bytes UInt16, IO::ByteFormat::BigEndian height = io.read_bytes UInt32, IO::ByteFormat::BigEndian width = io.read_bytes UInt32, IO::ByteFormat::BigEndian bits = io.read_bytes UInt16, IO::ByteFormat::BigEndian Image.new width, height, :psd, bits, channels end def self.matches?(io : IO, bytes : Bytes) : Bool bytes[0, 4] == SIGNATURE end end ================================================ FILE: src/components/image_size/src/extractors/svg.cr ================================================ struct Athena::ImageSize::Extractors::SVG < Athena::ImageSize::Extractors::Extractor private SVG_FORMAT = /]*)>/ private XML_FORMAT = /<\?xml||HTTP POST| H P2 -->|HTTP POST| H %% Flows from hub to subscribers H -->|SSE| S1 H -->|SSE| S2 H -->|SSE| S3 ``` Ultimately this makes the interactions/usage of it simpler since the majority of the complex parts are abstracted away. ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-mercure: github: athena-framework/mercure version: ~> 0.1.0 ``` ### Setup Because the Mercure Hub is a separate process from the Athena HTTP server, it does mean you have to [install](https://mercure.rocks/docs/hub/install) a Mercure hub by yourself. For production usages, an official and open source (AGPL) hub based on the Caddy web server can be downloaded as a static binary from [Mercure.rocks](https://mercure.rocks). A Docker image, a Helm chart for Kubernetes and a managed, High Availability Hub are also provided. Locally, it's easiest to run the Hub via [docker compose](https://docs.docker.com/compose). A minimal development compose file would look like: ```yaml services: mercure: image: dunglas/mercure restart: unless-stopped environment: SERVER_NAME: ':80' # Disable HTTPS for local dev MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_EXTRA_DIRECTIVES: | cors_origins http://localhost:3000 # Allow Athena Server command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile # Enable dev mode ports: - '80:80' volumes: - mercure_data:/data - mercure_config:/config volumes: mercure_data: mercure_config: ``` ## Usage Now that the Mercure hub is running, we can use it to publish updates, and subscribe to receive those updates on the client side. ### Publishing In order to publish an update, a [AMC::Hub](/Mercure/Hub/) instance is required. This type expects to be provided a URL to the Mercure Hub that updates should be sent to, and an [AMC::TokenProvider::Interface](/Mercure/TokenProvider/Interface/) instance. The token provider is responsible for returning a JWT token used to authenticate the request sent to the Mercure Hub. Most commonly this'll be generated using a static secret key via the [Crystal JWT](https://github.com/crystal-community/jwt) shard. An [AMC::Update](/Mercure/Update/) instance should then be instantiated that represents the update to publish, and provided to the `#publish` method of the hub instance. A complete example of this flow is as follows: ```crystal token_factory = AMC::TokenFactory::JWT.new ENV["MERCURE_JWT_SECRET"] # Use `*` to give the created JWT access to all topics. token_provider = AMC::TokenProvider::Factory.new token_factory, publish: ["*"] hub = AMC::Hub.new ENV["MERCURE_URL"], token_provider, token_factory update = AMC::Update.new( "https://example.com/my-topic", {message: "Hello world @ #{Time.local}!"}.to_json ) hub.publish update # => urn:uuid:e1ee88e2-532a-4d6f-ba70-f0f8bd584022 ``` Multiple hubs can be used and accessed by name via a [AMC::Hub::Registry](/Mercure/Hub/Registry/). ### Subscribing Updates can be subscribed to on any platform that supports [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). For example via JS: ```html

Mercure Testing

``` This code would log each received update to the console. Be sure to call `eventSource.close()` when no longer needed to avoid a resource leak. ### Authorization Mercure allows dispatching updates to only authorized clients. To do so, mark an [AMC::Update](/Mercure/Update/) as `private` via the third constructor argument, the `private` named argument: ```crystal AMC::Update.new( "https://example.com/books/1", {status: "OutOfStock"}.to_json, private: true ) ``` To subscribe to private updates, subscribers must provide to the Hub a JWT containing a topic selector matching by the topic of the update. The preferred way of providing the JWT in a browser context is via a cookie. WARNING: To use the cookies, the Athena app and the Mercure Hub must be served from the same domain (can be different sub-domains). The Mercure component provides [AMC::Authorization](/Mercure/Authorization/) that can handle generating/setting the cookie given a request/response. Cookies set by this helper class are automatically passed by the browser to the Mercure hub if the `withCredentials` attribute of `EventSource` is set to `true`: ```js const eventSource = new EventSource(url, { withCredentials: true }); ``` ### Discovery Mercure comes with the ability to automatically discover the hub via a [Link](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Link) header. ```mermaid sequenceDiagram participant C as Client participant A as Athena API participant H as Mercure Hub C->>A: GET resource A-->>C: 200 OK resource A-->>C: Link header rel mercure Note over C: Discover hub URL Note over C: Add topic parameter C->>H: Open SSE connection H-->>C: Updates for topic ``` The header may be added using the [AMC::Discovery](/Mercure/Discovery/) type. The client would then be able to extract the hub URL from the `Link` header to be able to subscribe to updates related to that resource: ```js // Fetch the original resource served by the Athena web API fetch('/books/1') // Has Link: ; rel="mercure" .then(response => { // Extract the hub URL from the Link header const hubUrl = response.headers.get('Link').match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1]; // Append the topic(s) to subscribe as query parameter const hub = new URL(hubUrl, window.origin); hub.searchParams.append('topic', 'https://example.com/books/{id}'); // Subscribe to updates const eventSource = new EventSource(hub); eventSource.onmessage = event => console.log(event.data); }); ``` ### Testing The Mercure component comes with some helper types for testing code that publishes updates, without actually sending the update. See [AMC::Spec](/Mercure/Spec/) for more information. ```crystal require "athena-mercure/spec" hub = AMC::Spec::MockHub.new("https://foo.com", AMC::TokenProvider::Static.new("JWT")) { "id" } # ... ``` ================================================ FILE: src/components/mercure/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Mercure site_url: https://athenaframework.org/Mercure/ repo_url: https://github.com/athena-framework/mercure nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-mercure/src/athena-mercure.cr - ./lib/athena-mercure/src/spec.cr source_locations: lib/athena-mercure: https://github.com/athena-framework/mercure/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/mercure/shard.yml ================================================ name: athena-mercure version: 0.1.0 crystal: ~> 1.13 license: MIT repository: https://github.com/athena-framework/mercure documentation: https://athenaframework.org/Mercure description: | Allows easily pushing updates to web browsers and other HTTP clients using the Mercure protocol. authors: - George Dietrich dependencies: jwt: github: crystal-community/jwt version: ~> 1.7 ================================================ FILE: src/components/mercure/spec/authorization_spec.cr ================================================ require "./spec_helper" struct AuthorizationTest < ASPEC::TestCase def test_jwt_lifetime : Nil registry = AMC::Hub::Registry.new(AMC::Spec::MockHub.new( "https://example.com/.well-known/mercure", AMC::TokenProvider::Static.new("JWT"), token_factory: AMC::TokenFactory::JWT.new("looooooooooooongenoughtestsecret", jwt_lifetime: 4000) ) { "ID" }) authorization = AMC::Authorization.new registry cookie = authorization.create_cookie ::HTTP::Request.new("GET", "https://example.com", headers: ::HTTP::Headers{"host" => "example.com"}) payload, _ = JWT.decode(cookie.value, verify: false, validate: false) payload["exp"].as_i?.should be_a Int32 end def test_set_cookie_zero_expiration : Nil token_factory = AMC::Spec::AssertingTokenFactory.new( "JWT", ["foo"], ["bar"], {"x-foo" => "baz"}, ) registry = AMC::Hub::Registry.new(AMC::Spec::MockHub.new( "https://example.com/.well-known/mercure", AMC::TokenProvider::Static.new("JWT"), token_factory: token_factory ) { "ID" }) request = ::HTTP::Request.new("GET", "https://example.com", headers: ::HTTP::Headers{"host" => "example.com"}) response = ::HTTP::Server::Response.new IO::Memory.new authorization = AMC::Authorization.new registry, Time::Span.zero, :lax authorization.set_cookie request, response, ["foo"], ["bar"], {"x-foo" => "baz"} token_factory.called?.should be_true cookie = response.cookies.first cookie.max_age.should eq Time::Span.zero cookie.value.should_not be_empty cookie.samesite.try &.lax?.should be_true end def test_set_cookie_default_expiration : Nil token_factory = AMC::Spec::AssertingTokenFactory.new( "JWT", ["foo"], ["bar"], {"x-foo" => "baz"}, ) registry = AMC::Hub::Registry.new(AMC::Spec::MockHub.new( "https://example.com/.well-known/mercure", AMC::TokenProvider::Static.new("JWT"), token_factory: token_factory ) { "ID" }) request = ::HTTP::Request.new("GET", "https://example.com", headers: ::HTTP::Headers{"host" => "example.com"}) response = ::HTTP::Server::Response.new IO::Memory.new authorization = AMC::Authorization.new registry, cookie_samesite: :lax authorization.set_cookie request, response, ["foo"], ["bar"], {"x-foo" => "baz"} token_factory.called?.should be_true cookie = response.cookies.first cookie.max_age.should eq 1.hour cookie.value.should_not be_nil cookie.samesite.try &.lax?.should be_true end def test_clear_cookie : Nil token_factory = AMC::Spec::AssertingTokenFactory.new("JWT") registry = AMC::Hub::Registry.new(AMC::Spec::MockHub.new( "https://example.com/.well-known/mercure", AMC::TokenProvider::Static.new("JWT"), token_factory: token_factory ) { "ID" }) request = ::HTTP::Request.new("GET", "https://example.com", headers: ::HTTP::Headers{"host" => "example.com"}) response = ::HTTP::Server::Response.new IO::Memory.new authorization = AMC::Authorization.new registry authorization.clear_cookie request, response cookie = response.cookies.first cookie.value.should be_empty cookie.max_age.should eq 1.second end @[DataProvider("applicable_cookie_domains")] def test_applicable_cookie_domains(expected : String?, hub_url : String, request_url : String) : Nil registry = AMC::Hub::Registry.new(AMC::Spec::MockHub.new( hub_url, AMC::TokenProvider::Static.new("JWT"), token_factory: AMC::TokenFactory::JWT.new("looooooooooooongenoughtestsecret", jwt_lifetime: 4000) ) { "ID" }) uri = URI.parse request_url request = ::HTTP::Request.new("GET", uri.path, headers: ::HTTP::Headers{"host" => uri.hostname || ""}) authorization = AMC::Authorization.new registry cookie = authorization.create_cookie request cookie.domain.should eq expected end def applicable_cookie_domains : Tuple { {".example.com", "https://foo.bar.baz.example.com", "https://foo.bar.baz.qux.example.com"}, {".foo.bar.baz.example.com", "https://mercure.foo.bar.baz.example.com", "https://app.foo.bar.baz.example.com"}, {"example.com", "https://demo.example.com", "https://example.com"}, {".example.com", "https://mercure.example.com", "https://app.example.com"}, {".example.com", "https://example.com/.well-known/mercure", "https://app.example.com"}, {nil, "https://example.com/.well-known/mercure", "https://example.com"}, } end @[DataProvider("nonapplicable_cookie_domains")] def test_nonapplicable_cookie_domains(hub_url : String, request_url : String) : Nil registry = AMC::Hub::Registry.new(AMC::Spec::MockHub.new( hub_url, AMC::TokenProvider::Static.new("JWT"), token_factory: AMC::TokenFactory::JWT.new("looooooooooooongenoughtestsecret", jwt_lifetime: 4000) ) { "ID" }) uri = URI.parse request_url request = ::HTTP::Request.new("GET", uri.path, headers: ::HTTP::Headers{"host" => uri.hostname || ""}) authorization = AMC::Authorization.new registry expect_raises AMC::Exception::InvalidArgument, "Unable to create authorization cookie for a hub on the different second-level domain" do authorization.create_cookie request end end def nonapplicable_cookie_domains : Tuple { {"https://demo.mercure.com", "https://example.com"}, {"https://mercure.internal.com", "https://external.com"}, } end def test_set_multiple_cookies : Nil registry = AMC::Hub::Registry.new(AMC::Spec::MockHub.new( "https://example.com/.well-known/mercure", AMC::TokenProvider::Static.new("JWT"), token_factory: AMC::TokenFactory::JWT.new("looooooooooooongenoughtestsecret", jwt_lifetime: 4000) ) { "ID" }) request = ::HTTP::Request.new("GET", "https://example.com", headers: ::HTTP::Headers{"host" => "example.com"}) response = ::HTTP::Server::Response.new IO::Memory.new authorization = AMC::Authorization.new registry expect_raises AMC::Exception::Runtime, "The 'mercureAuthorization' cookie for the 'default hub' has already been set. You cannot set it two times during the same request." do authorization.set_cookie request, response authorization.clear_cookie request, response end end def test_nil_cookie_topics : Nil token_factory = AMC::Spec::AssertingTokenFactory.new( "JWT", nil, nil, {"x-foo" => "baz"}, ) registry = AMC::Hub::Registry.new(AMC::Spec::MockHub.new( "https://example.com/.well-known/mercure", AMC::TokenProvider::Static.new("JWT"), token_factory: token_factory ) { "ID" }) request = ::HTTP::Request.new("GET", "https://example.com", headers: ::HTTP::Headers{"host" => "example.com"}) response = ::HTTP::Server::Response.new IO::Memory.new authorization = AMC::Authorization.new registry authorization.set_cookie request, response, nil, nil, {"x-foo" => "baz"} cookie = response.cookies.first cookie.value.should_not be_empty end end ================================================ FILE: src/components/mercure/spec/discovery_spec.cr ================================================ require "./spec_helper" describe AMC::Discovery do it "preflight request" do registry = AMC::Hub::Registry.new(AMC::Spec::MockHub.new( "https://example.com/.well-known/mercure", AMC::TokenProvider::Static.new("JWT"), token_factory: AMC::TokenFactory::JWT.new("looooooooooooongenoughtestsecret", jwt_lifetime: 4000) ) { "ID" }) request = ::HTTP::Request.new("OPTIONS", "/", headers: ::HTTP::Headers{"access-control-request-method" => "GET"}) response = ::HTTP::Server::Response.new IO::Memory.new discovery = AMC::Discovery.new registry discovery.add_link request, response response.headers["link"]?.should be_nil end it "non-preflight request" do registry = AMC::Hub::Registry.new(AMC::Spec::MockHub.new( "https://example.com/.well-known/mercure", AMC::TokenProvider::Static.new("JWT"), token_factory: AMC::TokenFactory::JWT.new("looooooooooooongenoughtestsecret", jwt_lifetime: 4000) ) { "ID" }) request = ::HTTP::Request.new("POST", "/") response = ::HTTP::Server::Response.new IO::Memory.new discovery = AMC::Discovery.new registry discovery.add_link request, response response.headers.get("link").should eq ["; rel=\"mercure\""] end end ================================================ FILE: src/components/mercure/spec/hub/hub_spec.cr ================================================ require "../spec_helper" private URL = "https://demo.mercure.rocks/.well-known/mercure" private class MockHTTPClient < ::HTTP::Client setter exception : ::Exception? = nil def post(path, headers : ::HTTP::Headers? = nil, *, form : String | IO) : ::HTTP::Client::Response if ex = @exception raise ex end path.should eq "/.well-known/mercure" headers.should eq ::HTTP::Headers{"authorization" => "Bearer FOO"} form.should eq "topic=https%3A%2F%2Fdemo.mercure.rocks%2Fdemo%2Fbooks%2F1.jsonld&data=Hi+from+Athena%21&private=on&id=id&retry=3" ::HTTP::Client::Response.new :ok, "ID" end end describe Athena::Mercure::Hub do describe "#publish" do it "happy path" do provider = AMC::TokenProvider::Static.new "FOO" hub = AMC::Hub.new URL, provider, http_client: MockHTTPClient.new URI.parse URL hub.publish(AMC::Update.new( "https://demo.mercure.rocks/demo/books/1.jsonld", "Hi from Athena!", true, "id", nil, 3 )).should eq "ID" end it "network issue" do provider = AMC::TokenProvider::Static.new "FOO" http_client = MockHTTPClient.new URI.parse URL http_client.exception = ::Exception.new "Oh noes" hub = AMC::Hub.new URL, provider, http_client: http_client expect_raises AMC::Exception::Runtime, "Failed to send an update." do hub.publish(AMC::Update.new( "https://demo.mercure.rocks/demo/books/1.jsonld", "Hi from Athena!", true, "id", nil, 3 )) end end end it "#url" do AMC::Hub .new(URL, AMC::TokenProvider::Static.new("FOO"), http_client: MockHTTPClient.new URI.parse URL) .url.should eq URL end it "#publish" do foo_hub = AMC::Spec::MockHub.new("https://foo.com", AMC::TokenProvider::Static.new("FOO")) { "foo" } foo_hub.publish(AMC::Update.new("https://demo.mercure.rocks/demo/books/1.jsonld")).should eq "foo" end end ================================================ FILE: src/components/mercure/spec/hub/registry_spec.cr ================================================ require "../spec_helper" describe AMC::Hub::Interface do describe "#hub" do it "explicit name" do foo_hub = AMC::Spec::MockHub.new("https://foo.com", AMC::TokenProvider::Static.new("FOO")) { "foo" } bar_hub = AMC::Spec::MockHub.new("https://bar.com", AMC::TokenProvider::Static.new("BAR")) { "bar" } registry = AMC::Hub::Registry.new foo_hub, {"foo" => foo_hub, "bar" => bar_hub} of String => AMC::Hub::Interface registry.hub("bar").should eq bar_hub end it "default hub" do foo_hub = AMC::Spec::MockHub.new("https://foo.com", AMC::TokenProvider::Static.new("FOO")) { "foo" } bar_hub = AMC::Spec::MockHub.new("https://bar.com", AMC::TokenProvider::Static.new("BAR")) { "bar" } registry = AMC::Hub::Registry.new foo_hub, {"foo" => foo_hub, "bar" => bar_hub} of String => AMC::Hub::Interface registry.hub.should eq foo_hub end it "missing hub" do foo_hub = AMC::Spec::MockHub.new("https://foo.com", AMC::TokenProvider::Static.new("FOO")) { "foo" } registry = AMC::Hub::Registry.new foo_hub, {"foo" => foo_hub} of String => AMC::Hub::Interface expect_raises AMC::Exception::InvalidArgument, "No hub named 'baz' is available." do registry.hub "baz" end end end it "#hubs" do foo_hub = AMC::Spec::MockHub.new("https://foo.com", AMC::TokenProvider::Static.new("FOO")) { "foo" } bar_hub = AMC::Spec::MockHub.new("https://bar.com", AMC::TokenProvider::Static.new("BAR")) { "bar" } registry = AMC::Hub::Registry.new foo_hub, hubs = {"foo" => foo_hub, "bar" => bar_hub} of String => AMC::Hub::Interface registry.hubs.should eq hubs end end ================================================ FILE: src/components/mercure/spec/spec_helper.cr ================================================ require "spec" require "athena-spec" require "../src/athena-mercure" require "../src/spec" ASPEC.run_all ================================================ FILE: src/components/mercure/spec/token_factory/jwt_spec.cr ================================================ require "../spec_helper" struct JWTTest < ASPEC::TestCase @[DataProvider("create_data")] def test_create( secret : String, algorithm : JWT::Algorithm, subscribe : Array(String)?, publish : Array(String)?, additional_claims : Hash?, expected_jwt : String, ) : Nil AMC::TokenFactory::JWT .new(secret, algorithm, jwt_lifetime: nil) .create(subscribe, publish, additional_claims).should eq expected_jwt end def create_data : Tuple { { "looooooooooooongenoughtestsecret", JWT::Algorithm::HS256, nil, nil, nil, "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7fX0.Nl9FuNHooqvulVq4efunVwwUBE_VUNr4JC0ivPoZvFM", }, { "looooooooooooongenoughtestsecret", JWT::Algorithm::HS256, Array(String).new, ["*"], nil, "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdLCJzdWJzY3JpYmUiOltdfX0.ZTK3JhEKO1338LAgRMw6j0lkGRMoaZtU4EtGiAylAns", # spellchecker:disable-line }, { "looooooooooooooooooooooooooooongenoughtestsecret", JWT::Algorithm::HS384, Array(String).new, ["*"], nil, "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzM4NCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdLCJzdWJzY3JpYmUiOltdfX0.ERwjuquA1VXjCx_Q05zHHIVWU40maCOLsu493IKD4osTk0l0bTs9t9S8_tgM32Ih", }, { "looooooooooooongenoughtestsecret", JWT::Algorithm::HS256, Array(String).new, ["*"], { "mercure" => { "publish" => ["overridden"], "subscribe" => ["overridden"], "payload" => {"foo" => "bar"}, }, }, "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdLCJzdWJzY3JpYmUiOltdfSwibWVyY3VyZSI6eyJwdWJsaXNoIjpbIm92ZXJyaWRkZW4iXSwic3Vic2NyaWJlIjpbIm92ZXJyaWRkZW4iXSwicGF5bG9hZCI6eyJmb28iOiJiYXIifX19.X9IUAOq-12pRpO5oNnwnQsdZPAQQfan83DpJI32IxlI", }, } end end ================================================ FILE: src/components/mercure/spec/token_provider/callable_spec.cr ================================================ require "../spec_helper" describe AMC::TokenProvider::Callable do it "block overload" do provider = AMC::TokenProvider::Callable.new do "FOO" end provider.jwt.should eq "FOO" end it "proc overload" do AMC::TokenProvider::Callable.new(-> { "BAR" }).jwt.should eq "BAR" end end ================================================ FILE: src/components/mercure/spec/token_provider/factory_spec.cr ================================================ require "../spec_helper" describe AMC::TokenProvider::Factory do it "returns the token" do AMC::TokenProvider::Factory .new( AMC::TokenFactory::JWT.new("looooooooooooongenoughtestsecret", jwt_lifetime: nil), [] of String, ["*"] ) .jwt.should eq "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdLCJzdWJzY3JpYmUiOltdfX0.ZTK3JhEKO1338LAgRMw6j0lkGRMoaZtU4EtGiAylAns" # spellchecker:disable-line end end ================================================ FILE: src/components/mercure/spec/token_provider/static_spec.cr ================================================ require "../spec_helper" describe AMC::TokenProvider::Static do it "returns the token" do AMC::TokenProvider::Static.new("FOO").jwt.should eq "FOO" end end ================================================ FILE: src/components/mercure/src/athena-mercure.cr ================================================ require "jwt" require "http/client" require "http/headers" require "./authorization" require "./discovery" require "./update" require "./exception/*" require "./hub/*" require "./token_provider/*" require "./token_factory/*" # Convenience alias to make referencing `Athena::Mercure` types easier. alias AMC = Athena::Mercure # The `Athena::Mercure` component allows easily pushing updates to web browsers and other HTTP clients using the [Mercure protocol](https://mercure.rocks/docs/mercure). # Because it is built on top of [Server-Sent Events (SSE)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events), Mercure is supported out of the box in modern browsers. # # Mercure comes with an authorization mechanism, automatic reconnection in case of network issues with retrieving of lost updates, a presence API, "connection-less" push for smartphones and auto-discoverability (a supported client can automatically discover and subscribe to updates of a given resource thanks to a specific HTTP header). module Athena::Mercure VERSION = "0.1.0" # See `AMC::TokenFactory::Interface` module TokenFactory; end # See `AMC::TokenProvider::Interface` module TokenProvider; end # Both acts as a namespace for exceptions related to the `Athena::Mercure` component, as well as a way to check for exceptions from the component. module Exception; end end ================================================ FILE: src/components/mercure/src/authorization.cr ================================================ # Helper class for adding the Mercure authorization cookie to an HTTP response in order to enable private updates. # See [Authorization](/Mercure/#authorization) for more information. class Athena::Mercure::Authorization private COOKIE_NAME = "mercureAuthorization" def initialize( @hub_registry : AMC::Hub::Registry, @cookie_lifetime : Time::Span = 1.hour, @cookie_samesite : ::HTTP::Cookie::SameSite = :strict, ); end # Sets the `mercureAuthorization` cookie on the provided *response* given the provided *request*, optionally for the provided *hub_name*. # The JWT cookie value by default does not have access to publish or subscribe to any topic. # Be sure to set the *subscribe* and *publish* arrays to the topics you want it to be able to interact with, or `["*"]` to handle all topics. # *additional_claims* may also be used to define additional claims to the JWT if needed. def set_cookie( request : ::HTTP::Request, response : ::HTTP::Server::Response, subscribe : Array(String)? = [] of String, publish : Array(String)? = [] of String, additional_claims : Hash? = nil, hub_name : String? = nil, ) : Nil self.update_cookies request, response, hub_name, self.create_cookie(request, subscribe, publish, additional_claims, hub_name) end # Clears the Mercure cookie from the provided *response*, optionally for the provided *hub_name*. def clear_cookie( request : ::HTTP::Request, response : ::HTTP::Server::Response, hub_name : String? = nil, ) : Nil self.update_cookies request, response, hub_name, self.create_clear_cookie(request, hub_name) end # Returns a Mercure auth cookie given the provided *request* and optionally for the provided *hub_name*. # # The JWT cookie value by default does not have access to publish or subscribe to any topic. # Be sure to set the *subscribe* and *publish* arrays to the topics you want it to be able to interact with, or `["*"]` to handle all topics. # *additional_claims* may also be used to define additional claims to the JWT if needed. def create_cookie( request : ::HTTP::Request, subscribe : Array(String)? = [] of String, publish : Array(String)? = [] of String, additional_claims : Hash? = nil, hub_name : String? = nil, ) : ::HTTP::Cookie hub = @hub_registry.hub hub_name unless token_factory = hub.token_factory raise AMC::Exception::InvalidArgument.new "The hub '#{hub_name}' does not contain a token factory." end cookie_lifetime = @cookie_lifetime if additional_claims && (cl = additional_claims["exp"]?) cookie_lifetime = case cl when String then cl.to_i.seconds when Number then cl.seconds else @cookie_lifetime end end token = token_factory.create subscribe, publish, additional_claims uri = URI.parse hub.public_url ::HTTP::Cookie.new( COOKIE_NAME, token, uri.path || "/", domain: self.cookie_domain(request, uri), secure: true, http_only: true, samesite: @cookie_samesite, max_age: cookie_lifetime ) end private def create_clear_cookie(request : ::HTTP::Request, hub_name : String? = nil) : ::HTTP::Cookie hub = @hub_registry.hub hub_name uri = URI.parse hub.public_url ::HTTP::Cookie.new( COOKIE_NAME, "", uri.path || "/", domain: self.cookie_domain(request, uri), secure: true, http_only: true, samesite: @cookie_samesite, max_age: 1.second ) end private def cookie_domain(request : ::HTTP::Request, uri : URI) : String? return unless uri_host = uri.host cookie_domain = uri_host.downcase host = request.hostname || "" return if cookie_domain == host if cookie_domain.ends_with? ".#{host}" return host end host_segments = host.split '.' host_segments[0..-2].each_with_index do |_, idx| current_domain = host_segments[idx..].join '.' target = ".#{current_domain}" if current_domain == cookie_domain || cookie_domain.ends_with? target return target end end raise AMC::Exception::InvalidArgument.new "Unable to create authorization cookie for a hub on the different second-level domain '#{cookie_domain}'." end private def update_cookies( request : ::HTTP::Request, response : ::HTTP::Server::Response, hub_name : String?, cookie : ::HTTP::Cookie, ) : Nil unless response.cookies[COOKIE_NAME]?.nil? raise AMC::Exception::Runtime.new "The 'mercureAuthorization' cookie for the '#{hub_name ? "#{hub_name} hub" : "default hub"}' has already been set. You cannot set it two times during the same request." end response.cookies << cookie end end ================================================ FILE: src/components/mercure/src/discovery.cr ================================================ # Allows for automatically discovering the Mercure hub via a [Link](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Link) header. # E.g. can be included with the response for a resource to allow clients to then extract the URL from the rel `mercure` header to subscribe to future updates for that resource. # # See [Discovery](/Mercure/#discovery) for more information. class Athena::Mercure::Discovery def initialize( @hub_registry : AMC::Hub::Registry, ); end # Adds the mercure relation `link` header to the provided *response*, optionally for the provided *hub_name*. def add_link(request : ::HTTP::Request, response : ::HTTP::Server::Response, hub_name : String? = nil) : Nil return if self.preflight_request? request hub = @hub_registry.hub hub_name # TODO: Create WebLink component? response.headers.add "link", self.generate_link(hub.public_url) end private def generate_link(url : String) : String %(<#{url}>; rel="mercure") end private def preflight_request?(request : ::HTTP::Request) : Bool "options" == request.method.downcase && request.headers.has_key? "access-control-request-method" end end ================================================ FILE: src/components/mercure/src/exception/invalid_argument.cr ================================================ class Athena::Mercure::Exception::InvalidArgument < ArgumentError include Athena::Mercure::Exception end ================================================ FILE: src/components/mercure/src/exception/runtime.cr ================================================ class Athena::Mercure::Exception::Runtime < RuntimeError include Athena::Mercure::Exception end ================================================ FILE: src/components/mercure/src/hub/hub.cr ================================================ require "./interface" # Default implementation of `AMC::Hub::Interface`. class Athena::Mercure::Hub include Athena::Mercure::Hub::Interface # :inherit: getter token_provider : AMC::TokenProvider::Interface # :inherit: getter token_factory : AMC::TokenFactory::Interface? @uri : URI @public_url : String? @http_client : ::HTTP::Client def initialize( url : String, @token_provider : AMC::TokenProvider::Interface, @token_factory : AMC::TokenFactory::Interface? = nil, @public_url : String? = nil, http_client : ::HTTP::Client? = nil, ) @uri = URI.parse url @http_client = http_client || ::HTTP::Client.new @uri end # :inherit: def url : String @uri.to_s end # :inherit: def public_url : String @public_url || @uri.to_s end # :inherit: def publish(update : AMC::Update) : String @http_client.post( @uri.path, headers: ::HTTP::Headers{"authorization" => "Bearer #{@token_provider.jwt}"}, form: URI::Params.build { |form| self.encode form, update } ).body rescue ex : ::Exception raise AMC::Exception::Runtime.new "Failed to send an update.", cause: ex end private def encode(form : URI::Params::Builder, update : AMC::Update) : Nil form.add "topic", update.topics form.add "data", update.data if update.private? form.add "private", "on" end if id = update.id form.add "id", id end if type = update.type form.add "type", type end if retry = update.retry form.add "retry", retry.to_s end end end ================================================ FILE: src/components/mercure/src/hub/interface.cr ================================================ class Athena::Mercure::Hub; end # Represents the API that a Mercure hub instance must implement. module Athena::Mercure::Hub::Interface # Returns the internal URL of this hub used to publish updates. abstract def url : String # Returns the public URL of this hub used to subscribe. abstract def public_url : String # Returns the `AMC::TokenProvider::Interface` associated with this hub. abstract def token_provider : AMC::TokenProvider::Interface? # Returns the `AMC::TokenFactory::Interface` associated with this hub. abstract def token_factory : AMC::TokenFactory::Interface? # Publishes the provided *update* to this hub. abstract def publish(update : AMC::Update) : String end ================================================ FILE: src/components/mercure/src/hub/registry.cr ================================================ # The `AMC::Hub::Registry` can be used to store multiple `AMC::Hub` instances, accessing them by unique names. # # ``` # foo_hub = hub = AMC::Hub.new ENV["FOO_HUB_MERCURE_URL"], foo_token_provider, foo_token_factory # bar_hub = hub = AMC::Hub.new ENV["BAR_HUB_MERCURE_URL"], bar_token_provider, bar_token_factory # # registry = AMC::Hub::Registry.new( # foo_hub, # { # "foo" => foo_hub, # "bar" => bar_hub, # } of String => AMC::Hub::Interface # ) # # registry.hub # => (default foo_hub) # registry.hub "bar" # => (bar_hub) # ``` class Athena::Mercure::Hub::Registry # Returns the mapping of hub names to their related instance. getter hubs : Hash(String, AMC::Hub::Interface) def initialize( @default_hub : AMC::Hub::Interface, @hubs : Hash(String, AMC::Hub::Interface) = {} of String => AMC::Hub::Interface, ); end # Returns the hub with the provided *name*, or the default one if no name was provided. def hub(name : String? = nil) : AMC::Hub::Interface return @default_hub if name.nil? raise AMC::Exception::InvalidArgument.new "No hub named '#{name}' is available." unless @hubs.has_key? name @hubs[name] end end ================================================ FILE: src/components/mercure/src/spec.cr ================================================ # Provides helper types for testing `Athena::Mercure` related logic. module Athena::Mercure::Spec # Similar to `AMC::Hub` but does not make any requests to a real Mercure hub. # Instead, it accepts a block that can be used to make assertions against the related `AMC::Update`, and is expected to return the id of the related update. struct MockHub include Athena::Mercure::Hub::Interface # :inherit: getter url : String # :inherit: getter token_provider : AMC::TokenProvider::Interface # :inherit: getter token_factory : AMC::TokenFactory::Interface? @publisher : Proc(AMC::Update, String) def initialize( @url : String, @token_provider : AMC::TokenProvider::Interface, @public_url : String? = nil, @token_factory : AMC::TokenFactory::Interface? = nil, &@publisher : AMC::Update -> String ) end # :inherit: def public_url : String @public_url || @url end # :inherit: def publish(update : AMC::Update) : String @publisher.call update end end # An `AMC::TokenFactory::Interface` implementation that will assert it was called with the expected arguments to `#create`. class AssertingTokenFactory include AMC::TokenFactory::Interface getter? called : Bool = false def initialize( @token : String, @subscribe : Array(String)? = [] of String, @publish : Array(String)? = [] of String, @additional_claims : Hash(String, String) = {} of String => String, ) end def create(subscribe : Array(String) | ::Nil = [] of String, publish : Array(String) | ::Nil = [] of String, additional_claims : Hash | ::Nil = nil) : String subscribe.should eq @subscribe publish.should eq @publish additional_claims.should eq @additional_claims @token ensure @called = true end end end ================================================ FILE: src/components/mercure/src/token_factory/interface.cr ================================================ # A token factory is responsible for creating the token used to authenticate requests to the Mercure hub. module Athena::Mercure::TokenFactory::Interface # Returns a JWT token that has access to *subscribe* and *publish* to the provided topics. # Optionally, *additional_claims* may be added to the JWT. abstract def create( subscribe : Array(String)? = [] of String, publish : Array(String)? = [] of String, additional_claims : Hash? = nil, ) : String end ================================================ FILE: src/components/mercure/src/token_factory/jwt.cr ================================================ # A token factory implementation based on the [Crystal JWT](https://github.com/crystal-community/jwt) shard. struct Athena::Mercure::TokenFactory::JWT include Athena::Mercure::TokenFactory::Interface # These make it easier to build the JSON in a type safe way vs dealing with merging hashes and such :shrug: private record MercurePayload, subscribe : Array(String)?, publish : Array(String)? do def to_json(builder : JSON::Builder) : Nil builder.object do if publish = @publish builder.field "publish", publish end if subscribe = @subscribe builder.field "subscribe", subscribe end end end end private record Payload(T), mercure : MercurePayload, jwt_lifetime : Time::Span?, additional_claims : T do def to_json(builder : JSON::Builder) : Nil builder.object do builder.field "mercure", @mercure if (lifetime = @jwt_lifetime) && !@additional_claims.try &.has_key? "exp" builder.field "exp", (Time.utc + lifetime).to_unix end additional_claims.try &.each do |k, v| builder.field k, v end end end end @jwt_lifetime : Time::Span? def initialize( @jwt_secret : String, @algorithm : ::JWT::Algorithm = :hs256, jwt_lifetime : Int32 | Time::Span | Nil = 3600, @passphrase : String = "", ) @jwt_lifetime = jwt_lifetime.is_a?(Int32) ? jwt_lifetime.seconds : jwt_lifetime end # :inherit: def create( subscribe : Array(String)? = [] of String, publish : Array(String)? = [] of String, additional_claims : Hash? = nil, ) : String ::JWT.encode( Payload.new(MercurePayload.new(subscribe, publish), @jwt_lifetime, additional_claims), @jwt_secret, @algorithm ) end end ================================================ FILE: src/components/mercure/src/token_provider/callable.cr ================================================ require "./interface" # A token provider implementation that provides the JWT via the return value of a callback block. struct Athena::Mercure::TokenProvider::Callable include Athena::Mercure::TokenProvider::Interface def self.new(&block : -> String) : self new block end def initialize(@callback : Proc(String)); end # :inherit: def jwt : String @callback.call end end ================================================ FILE: src/components/mercure/src/token_provider/factory.cr ================================================ require "./interface" # A token provider implementation that provides the JWT via an `AMC::TokenFactory::Interface` instance. struct Athena::Mercure::TokenProvider::Factory include Athena::Mercure::TokenProvider::Interface def initialize( @factory : AMC::TokenFactory::Interface, @subscribe : Array(String) = [] of String, @publish : Array(String) = [] of String, ); end # :inherit: def jwt : String @factory.create @subscribe, @publish end end ================================================ FILE: src/components/mercure/src/token_provider/interface.cr ================================================ # A token provider is responsible for providing the token used to authenticate requests to the Mercure hub. module Athena::Mercure::TokenProvider::Interface # Returns the JWT token used to authenticate requests to the Mercure hub. abstract def jwt : String end ================================================ FILE: src/components/mercure/src/token_provider/static.cr ================================================ # A token provider implementation that provides the JWT as a static value from the constructor. struct Athena::Mercure::TokenProvider::Static include Athena::Mercure::TokenProvider::Interface def initialize(@token : String); end # :inherit: def jwt : String @token end end ================================================ FILE: src/components/mercure/src/update.cr ================================================ # Represents an update to publish. # # ``` # AMC::Update.new( # "https://example.com/books/1", # {status: "OutOfStock"}.to_json # ) # ``` # # The topic may be any string, but is recommended it be an [IRI (Internationalized Resource Identifier)](https://datatracker.ietf.org/doc/html/rfc3987) that uniquely identifies the resource the update related to. # The data may also be any string, but will most commonly be JSON. # # ### Private Updates # # By default, an update would be sent to all subscribers listening on that topic. # However, if `private: true` is defined on the update, then it'll only be sent to subscribers who are authorized to receive it. # See [Authorization](/Mercure/#authorization) for more information. struct Athena::Mercure::Update # Returns the identifiers this update is associated with. getter topics : Array(String) # Returns the string content of the update. getter data : String # If `true`, the update will not be sent to subscribers who are not authorized to receive it. getter? private : Bool # Maps to the SSE's [id](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#id) property. getter id : String? # Maps to the SSE's [event](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event) property getter type : String? # Maps to the SSE's [retry](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#retry) property getter retry : Int32? def initialize( topics : String | Array(String), @data : String = "", @private : Bool = false, @id : String? = nil, @type : String? = nil, @retry : Int32? = nil, ) @topics = topics.is_a?(String) ? [topics] : topics end end ================================================ FILE: src/components/mercure-bundle/README.md ================================================ The source code for the Mercure Bundle is located in [src/bundles/mercure](../../bundles/mercure). The documentation is located here due to limitations with the [Projects](https://squidfunk.github.io/mkdocs-material/plugins/projects/) MkDocs Material plugin. This inconsistency will hopefully be resolved via Zensical's [Subprojects](https://zensical.org/about/roadmap/#subprojects) feature at some point in the future. ================================================ FILE: src/components/mercure-bundle/docs/README.md ================================================ The `Athena::MercureBundle` integrates the `Athena::Mercure` component into the Athena framework; abstracting away the setup down to just a couple configuration values. ## Installation First, install the bundle by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-mercure_bundle: github: athena-framework/mercure-bundle version: ~> 0.1.0 ``` Then, require it: ```crystal require "athena-mercure_bundle" ``` This automatically registers the bundle with the framework. Finally, configure it with at least one hub and required configuration: ```crystal ADI.configure({ mercure: { hubs: { default: { url: ENV["MERCURE_URL"], jwt: { secret: ENV["MERCURE_JWT_SECRET"], publish: ["*"], subscribe: ["*"], }, }, }, }, }) ``` See the bundle [Schema](/MercureBundle/Schema) for the full set of possible configuration options. If multiple hubs are configured, they may be injected using the hub name as a constructor parameter typed to [AMC::Hub::Interface](/Mercure/Hub/Interface/). For example `some_hub : AMC::Hub::Interface` assuming the key used in the `hubs` named tuple was `some_hub`. ## Usage The Mercure bundle brings [AMC::Hub](/Mercure/Hub/), [AMC::Authorization](/Mercure/Authorization/), and [AMC::Discovery](/Mercure/Discovery/) into the framework as injectable services, with event listeners that handle response headers and cookies automatically. ### Publishing Inject [AMC::Hub::Interface](/Mercure/Hub/Interface/) to publish updates from a controller: ```crystal @[ARTA::Route(path: "/broadcast")] class BroadcastController < ATH::Controller def initialize(@hub : AMC::Hub::Interface); end @[ARTA::Post("/")] def broadcast : Nil @hub.publish AMC::Update.new( "https://example.com/books/1", {status: "OutOfStock"}.to_json ) end end ``` ### Authorization Inject [ABM::Authorization](/MercureBundle/Authorization/) to set the `mercureAuthorization` cookie for [private updates](/Mercure/#authorization). The [SetCookie](/MercureBundle/Listeners/SetCookie/) listener automatically adds the cookie to the response — there is no need to modify the response directly: ```crystal @[ARTA::Route(path: "/auth")] class AuthController < ATH::Controller def initialize(@authorization : ABM::Authorization); end @[ARTA::Get("/subscribe")] def subscribe(request : AHTTP::Request) : Nil @authorization.set_cookie( request, subscribe: ["https://example.com/books/{id}"], ) end end ``` See [Authorization](/Mercure/#authorization) in the Mercure component docs for more on private updates and cookie-based auth. ### Discovery Inject [ABM::Discovery](/MercureBundle/Discovery/) to add the Mercure hub `Link` header to a response. The [AddLinkHeader](/MercureBundle/Listeners/AddLinkHeader/) listener handles adding the header — there is no need to modify the response directly: ```crystal @[ARTA::Route(path: "/books")] class BookController < ATH::Controller def initialize(@discovery : ABM::Discovery); end @[ARTA::Get("/{id}")] def show(request : AHTTP::Request, id : Int32) : {id: Int32, title : String} @discovery.add_link request {id: id, title: "Hello World!"} end end ``` The response will include a `Link: ; rel="mercure"` header. A client can then extract the hub URL to subscribe to updates for this resource: ```js fetch('/books/1') .then(response => { const hubUrl = response.headers.get('Link').match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1]; const hub = new URL(hubUrl, window.origin); hub.searchParams.append('topic', 'https://example.com/books/{id}'); const eventSource = new EventSource(hub); eventSource.onmessage = event => console.log(event.data); }); ``` See [Discovery](/Mercure/#discovery) in the Mercure component docs for more on the discovery protocol. ================================================ FILE: src/components/mercure-bundle/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Mercure Bundle site_url: https://athenaframework.org/MercureBundle/ repo_url: https://github.com/athena-framework/mercure-bundle nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-dependency_injection/src/athena-dependency_injection.cr - ./lib/athena-http/src/athena-http.cr - ./lib/athena-contracts/src/athena-contracts.cr - ./lib/athena-event_dispatcher/src/athena-event_dispatcher.cr - ./lib/athena-http_kernel/src/athena-http_kernel.cr - ./lib/athena-mercure/src/athena-mercure.cr - ./lib/athena-mercure_bundle/src/athena-mercure_bundle.cr source_locations: lib/athena-mercure_bundle: https://github.com/athena-framework/mercure-bundle/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/mime/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/mime/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/mime/CHANGELOG.md ================================================ # Changelog ## [0.2.1] - 2025-09-04 ### Added - Add fallback MIME types guesser based on stdlib `MIME` module ([#546]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/mime/releases/tag/v0.2.1 [#546]: https://github.com/athena-framework/athena/pull/546 ## [0.2.0] - 2025-05-14 ### Added - **Breaking:** Add `AMIME::Types` to more robustly handles MIME type/file extension/guessing ([#534]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/mime/releases/tag/v0.2.0 [#534]: https://github.com/athena-framework/athena/pull/534 ## [0.1.0] - 2025-01-26 _Initial release._ [0.1.0]: https://github.com/athena-framework/mime/releases/tag/v0.1.0 ================================================ FILE: src/components/mime/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/mime/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2024 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/mime/README.md ================================================ # MIME [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/mime.svg)](https://github.com/athena-framework/mime/releases) Allows manipulating MIME messages. ## Getting Started Checkout the [Documentation](https://athenaframework.org/MIME). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/mime/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.2.0 ### New system dependency If using the component on a Unix or MSYS2 system, you will need to ensure you have the `libmagic` development package installed. Refer to your system's package manager for the exact package name/installation instructions. ================================================ FILE: src/components/mime/docs/README.md ================================================ The `Athena::MIME` component allows manipulating the MIME messages used to send emails and provides utilities related to MIME types. Additionally it also exposes MIME guessing and MIME Type <=> file extension translations via the [AMIME::Types](/MIME/Types/) type. [MIME](https://en.wikipedia.org/wiki/MIME) (Multipurpose Internet Mail Extensions) is an Internet standard that extends the original basic format of emails to support features like: * Headers and text contents using non-ASCII characters; * Message bodies with multiple parts (e.g. HTML and plain text contents); * Non-text attachments: audio, video, images, PDF, etc. The entire MIME standard is complex and huge, but this component abstracts all that complexity to provide two ways of creating MIME messages: * A high-level API based on the [AMIME::Email](/MIME/Email/) class to quickly create email messages with all the common features * A low-level API based on the [AMIME::Message](/MIME/Message/) class to have absolute control over every single part of the email message ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-mime: github: athena-framework/mime version: ~> 0.2.0 ``` ## Usage The [AMIME::Email](/MIME/Email/) class provides fluent setters to allow constructing an email with the desired information: ```crystal email = AMIME::Email .new .from("me@example.com") .to("you@example.com") .cc("them@example.com") .bcc("other@example.com") .reply_to("me@example.com") .priority(:high) .subject("Important Notification") .text("Lorem ipsum...") .html("

Lorem ipsum

...

") .attach_from_path("/path/to/file.pdf", "my-attachment.pdf") .embed_from_path("/path/to/logo.png") ``` See the API docs for that type for more information. This component only handles creating the email messages. From here you would need to pass it along to another shard/component to actually send it. ### Creating Raw Email Messages For most use cases, the `AMIME::Email` type would work just fine. However some applications may require total control over every part of the email. Consider a message that includes some HTMl and textual content, a single PNG embedded image, and a PDF file attachment. The MIME standard allows constructing this message in different ways, but most commonly would be like: ```txt multipart/mixed ├── multipart/related │ ├── multipart/alternative │ │ ├── text/plain │ │ └── text/html │ └── image/png └── application/pdf ``` This is the purpose of each MIME message part: * `multipart/alternative`: used when two or more parts are alternatives of the same (or very similar) content. The preferred format must be added last. * `multipart/mixed`: used to send different content types in the same message, such as when attaching files. * `multipart/related`: used to indicate that each message part is a component of an aggregate whole. The most common usage is to display images embedded in the message contents. You must keep all of the above in mind when using the low-level `AMIME::Message` class to construct an email. ```crystal headers = AMIME::Header::Collection .new .add_mailbox_list_header("from", {"me@example.com"}) .add_mailbox_list_header("to", {"you@example.com"}) .add_text_header("subject", "Important Notification") text_content = AMIME::Part::Text.new "text content" html_content = AMIME::Part::Text.new "html content", sub_type: "html" body = AMIME::Part::Multipart::Alternative.new text_content, html_content email = AMIME::Message.new headers, body ``` Embedding images and attaching files is possible by creating the appropriate email multi parts: ```crystal headers = AMIME::Header::Collection .new .add_mailbox_list_header("from", {"me@example.com"}) .add_mailbox_list_header("to", {"you@example.com"}) .add_text_header("subject", "Important Notification") embedded_image = AMIME::Part::Data.from_path "#{__DIR__}/../spec/fixtures/mimetypes/test.gif", content_type: "image/png" image_cid = embedded_image.content_id attached_file = AMIME::Part::Data.from_path "#{__DIR__}/../spec/fixtures/mimetypes/abc.csv", content_type: "image/png" text_content = AMIME::Part::Text.new "text content" html_content = AMIME::Part::Text.new %(

Lorem ipsum

...

), nil, "html" body_content = AMIME::Part::Multipart::Alternative.new text_content, html_content body = AMIME::Part::Multipart::Related.new body_content, {embedded_image} message_parts = AMIME::Part::Multipart::Mixed.new body, attached_file email = AMIME::Message.new headers, message_parts ``` ================================================ FILE: src/components/mime/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: MIME site_url: https://athenaframework.org/MIME/ repo_url: https://github.com/athena-framework/mime nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-mime/src/athena-mime.cr source_locations: lib/athena-mime: https://github.com/athena-framework/mime/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/mime/shard.yml ================================================ name: athena-mime version: 0.2.1 crystal: ~> 1.4 license: MIT repository: https://github.com/athena-framework/mime documentation: https://athenaframework.org/MIME description: | Allows manipulating MIME messages. authors: - George Dietrich libraries: libmagic: '*' ================================================ FILE: src/components/mime/spec/abstract_types_guesser_test_case.cr ================================================ require "./spec_helper" abstract struct AbstractTypesGuesserTestCase < ASPEC::TestCase protected abstract def guesser : AMIME::TypesGuesserInterface def test_guess_directory : Nil expect_raises AMIME::Exception::InvalidArgument, "The file '#{__DIR__}/fixtures/mimetypes/directory' does not exist or is not readable." do self.guesser.guess_mime_type "#{__DIR__}/fixtures/mimetypes/directory" end end def test_guess_incorrect_path : Nil expect_raises AMIME::Exception::InvalidArgument, "The file '#{__DIR__}/fixtures/mimetypes/not_here' does not exist or is not readable." do self.guesser.guess_mime_type "#{__DIR__}/fixtures/mimetypes/not_here" end end end ================================================ FILE: src/components/mime/spec/address_spec.cr ================================================ require "./spec_helper" struct AddressTest < ASPEC::TestCase def test_address_only : Nil a = AMIME::Address.new "contact@athenï.org" a.address.should eq "contact@athenï.org" a.to_s.should eq "contact@xn--athen-gta.org" a.encoded_address.should eq "contact@xn--athen-gta.org" end def test_address_and_name : Nil a = AMIME::Address.new "contact@athenï.org", "George" a.address.should eq "contact@athenï.org" a.name.should eq "George" a.to_s.should eq %("George" ) a.encoded_address.should eq "contact@xn--athen-gta.org" end def test_create : Nil a = AMIME::Address.new "contact@athenaframework.org" b = AMIME::Address.new "george@athenaframework.org", "George" AMIME::Address.create(a).should eq a AMIME::Address.create(b).should eq b end def test_create_invalid : Nil expect_raises AMIME::Exception::InvalidArgument, "Could not parse '", "", "example@example.com"}, {"Jane Doe ", "Jane Doe", "example@example.com"}, {"Jane Doe", "Jane Doe", "example@example.com"}, {"'Jane Doe' ", "Jane Doe", "example@example.com"}, {"\"Jane Doe\" ", "Jane Doe", "example@example.com"}, {"Jane Doe <\"ex", "Jane Doe", "\"exle\"@example.com>", "Jane Doe", "\"exle\"@example.com"}, {"Jane Doe > <\"exle\"@example.com>", "Jane Doe >", "\"exle\"@example.com"}, {"Jane Doe discarded", "Jane Doe", "example@example.com"}, )] def test_create_from_string(string : String, display_name : String, addr_spec : String) : Nil address = AMIME::Address.create string address.address.should eq addr_spec address.name.should eq display_name from_string_address = AMIME::Address.create address.to_s from_string_address.address.should eq addr_spec from_string_address.name.should eq display_name end @[TestWith( {""}, {" "}, {" \r\n "}, )] def test_empty_name(name : String) : Nil mail = "mail@example.com" AMIME::Address.new(mail, name).to_s.should eq mail end def test_encode_name_if_contains_commas : Nil AMIME::Address.new("foo@example.com", "Foo, \"Bar").to_s.should eq %("Foo, "Bar" ) end end ================================================ FILE: src/components/mime/spec/draft_email_spec.cr ================================================ require "./spec_helper" struct DraftEmailTest < ASPEC::TestCase def test_can_have_just_body : Nil email = AMIME::DraftEmail.new.text("text content").to_s email.should contain "text content" email.should contain "mime-version: 1.0" email.should contain "x-unsent: 1" end def test_removes_bcc : Nil email = AMIME::DraftEmail.new.text("text content").bcc("foo@example.com").to_s email.should_not contain "foo@example.com" end def test_must_have_body : Nil expect_raises AMIME::Exception::Logic, "A message must have a text or an HTML part or attachments." do AMIME::DraftEmail.new.to_s end end def test_ensure_validity_always_fails : Nil expect_raises AMIME::Exception::Logic, "Cannot send messages marked as 'draft'." do AMIME::DraftEmail.new.text("text content").to("you@example.com").from("me@example.com").ensure_validity! end end end ================================================ FILE: src/components/mime/spec/email_spec.cr ================================================ require "./spec_helper" struct EmailTest < ASPEC::TestCase def test_subject : Nil e = AMIME::Email.new e.subject "Subject" e.subject.should eq "Subject" end def test_date : Nil e = AMIME::Email.new e.date now = Time.utc e.date.should eq now end def test_return_path : Nil e = AMIME::Email.new e.return_path "foo@example.com" e.return_path.should eq AMIME::Address.new("foo@example.com") end def test_sender : Nil e = AMIME::Email.new e.sender "foo@example.com" e.sender.should eq AMIME::Address.new "foo@example.com" e.sender s = AMIME::Address.new("bar@example.com") e.sender.should eq s end def test_from : Nil e = AMIME::Email.new helene = AMIME::Address.new "helene@example.com" thomas = AMIME::Address.new "thomas@example.com" caramel = AMIME::Address.new "caramel@example.com" e.from.should be_empty e.from "fred@example.com", helene, thomas v = e.from v.size.should eq 3 v[0].should eq AMIME::Address.new "fred@example.com" v[1].should eq helene v[2].should eq thomas e.add_from "lucas@example.com", caramel v = e.from v.size.should eq 5 v[0].should eq AMIME::Address.new "fred@example.com" v[1].should eq helene v[2].should eq thomas v[3].should eq AMIME::Address.new "lucas@example.com" v[4].should eq caramel e = AMIME::Email.new e.add_from "lucas@example.com", caramel v = e.from v.size.should eq 2 v[0].should eq AMIME::Address.new "lucas@example.com" v[1].should eq caramel e = AMIME::Email.new e.from "lucas@example.com" e.from caramel v = e.from v.size.should eq 1 v[0].should eq caramel end def test_reply_to : Nil e = AMIME::Email.new helene = AMIME::Address.new "helene@example.com" thomas = AMIME::Address.new "thomas@example.com" caramel = AMIME::Address.new "caramel@example.com" e.reply_to.should be_empty e.reply_to "fred@example.com", helene, thomas v = e.reply_to v.size.should eq 3 v[0].should eq AMIME::Address.new "fred@example.com" v[1].should eq helene v[2].should eq thomas e.add_reply_to "lucas@example.com", caramel v = e.reply_to v.size.should eq 5 v[0].should eq AMIME::Address.new "fred@example.com" v[1].should eq helene v[2].should eq thomas v[3].should eq AMIME::Address.new "lucas@example.com" v[4].should eq caramel e = AMIME::Email.new e.add_reply_to "lucas@example.com", caramel v = e.reply_to v.size.should eq 2 v[0].should eq AMIME::Address.new "lucas@example.com" v[1].should eq caramel e = AMIME::Email.new e.reply_to "lucas@example.com" e.reply_to caramel v = e.reply_to v.size.should eq 1 v[0].should eq caramel end def test_to : Nil e = AMIME::Email.new helene = AMIME::Address.new "helene@example.com" thomas = AMIME::Address.new "thomas@example.com" caramel = AMIME::Address.new "caramel@example.com" e.to.should be_empty e.to "fred@example.com", helene, thomas v = e.to v.size.should eq 3 v[0].should eq AMIME::Address.new "fred@example.com" v[1].should eq helene v[2].should eq thomas e.add_to "lucas@example.com", caramel v = e.to v.size.should eq 5 v[0].should eq AMIME::Address.new "fred@example.com" v[1].should eq helene v[2].should eq thomas v[3].should eq AMIME::Address.new "lucas@example.com" v[4].should eq caramel e = AMIME::Email.new e.add_to "lucas@example.com", caramel v = e.to v.size.should eq 2 v[0].should eq AMIME::Address.new "lucas@example.com" v[1].should eq caramel e = AMIME::Email.new e.to "lucas@example.com" e.to caramel v = e.to v.size.should eq 1 v[0].should eq caramel end def test_cc : Nil e = AMIME::Email.new helene = AMIME::Address.new "helene@example.com" thomas = AMIME::Address.new "thomas@example.com" caramel = AMIME::Address.new "caramel@example.com" e.cc.should be_empty e.cc "fred@example.com", helene, thomas v = e.cc v.size.should eq 3 v[0].should eq AMIME::Address.new "fred@example.com" v[1].should eq helene v[2].should eq thomas e.add_cc "lucas@example.com", caramel v = e.cc v.size.should eq 5 v[0].should eq AMIME::Address.new "fred@example.com" v[1].should eq helene v[2].should eq thomas v[3].should eq AMIME::Address.new "lucas@example.com" v[4].should eq caramel e = AMIME::Email.new e.add_cc "lucas@example.com", caramel v = e.cc v.size.should eq 2 v[0].should eq AMIME::Address.new "lucas@example.com" v[1].should eq caramel e = AMIME::Email.new e.cc "lucas@example.com" e.cc caramel v = e.cc v.size.should eq 1 v[0].should eq caramel end def test_bcc : Nil e = AMIME::Email.new helene = AMIME::Address.new "helene@example.com" thomas = AMIME::Address.new "thomas@example.com" caramel = AMIME::Address.new "caramel@example.com" e.bcc.should be_empty e.bcc "fred@example.com", helene, thomas v = e.bcc v.size.should eq 3 v[0].should eq AMIME::Address.new "fred@example.com" v[1].should eq helene v[2].should eq thomas e.add_bcc "lucas@example.com", caramel v = e.bcc v.size.should eq 5 v[0].should eq AMIME::Address.new "fred@example.com" v[1].should eq helene v[2].should eq thomas v[3].should eq AMIME::Address.new "lucas@example.com" v[4].should eq caramel e = AMIME::Email.new e.add_bcc "lucas@example.com", caramel v = e.bcc v.size.should eq 2 v[0].should eq AMIME::Address.new "lucas@example.com" v[1].should eq caramel e = AMIME::Email.new e.bcc "lucas@example.com" e.bcc caramel v = e.bcc v.size.should eq 1 v[0].should eq caramel end def test_priority : Nil e = AMIME::Email.new e.priority.should eq AMIME::Email::Priority::NORMAL e.priority :high e.priority.should eq AMIME::Email::Priority::HIGH e.priority AMIME::Email::Priority.new(123) e.priority.should eq AMIME::Email::Priority::NORMAL end def test_raises_when_body_is_empty : Nil expect_raises AMIME::Exception::Logic, "A message must have a text or an HTML part or attachments." do AMIME::Email.new.body end end def test_body : Nil e = AMIME::Email.new e.body = text = AMIME::Part::Text.new "content" e.body.should eq text end def test_generate_body_with_text_only : Nil text = AMIME::Part::Text.new "text content" e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.text "text content" e.body.should eq text e.text_body.should eq "text content" end def test_generate_body_with_html_only : Nil text = AMIME::Part::Text.new "html content", sub_type: "html" e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.html "html content" e.body.should eq text e.html_body.should eq "html content" end def test_generate_body_with_text_and_html : Nil text = AMIME::Part::Text.new "text content" html = AMIME::Part::Text.new "html content", sub_type: "html" e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.text "text content" e.html "html content" e.body.should eq AMIME::Part::Multipart::Alternative.new(text, html) end def test_generate_body_with_text_and_html_non_utf8 : Nil e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.text "text content", "iso-8859-1" e.html "html content", "iso-8859-1" e.text_charset.should eq "iso-8859-1" e.html_charset.should eq "iso-8859-1" e.body.should eq AMIME::Part::Multipart::Alternative.new( AMIME::Part::Text.new("text content", "iso-8859-1"), AMIME::Part::Text.new("html content", "iso-8859-1", "html"), ) end def test_generate_body_with_text_content_and_attachment : Nil text, _, file_part, file, _, _ = self.generate_some_parts e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.add_part AMIME::Part::Data.new(file) e.text "text content" e.body.should eq AMIME::Part::Multipart::Mixed.new text, file_part end def test_generate_body_with_html_content_and_attachment : Nil _, html, file_part, file, _, _ = self.generate_some_parts e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.add_part AMIME::Part::Data.new(file) e.html "html content" e.body.should eq AMIME::Part::Multipart::Mixed.new html, file_part end def test_generate_body_with_html_content_and_inlined_image_not_reference : Nil _, html, _, _, _, _ = self.generate_some_parts image_part = AMIME::Part::Data.new image = ::File.open("#{__DIR__}/fixtures/mimetypes/test.gif", "r") image_part.as_inline e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.add_part AMIME::Part::Data.new(image).as_inline e.html "html content" e.body.should eq AMIME::Part::Multipart::Mixed.new(html, image_part) end def test_generate_body_attached_file_only : Nil _, _, file_part, file, _, _ = self.generate_some_parts e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.add_part AMIME::Part::Data.new file e.body.should eq AMIME::Part::Multipart::Mixed.new file_part end def test_generate_body_inline_image_only : Nil image_part = AMIME::Part::Data.new image = ::File.open("#{__DIR__}/fixtures/mimetypes/test.gif", "r") image_part.as_inline e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.add_part AMIME::Part::Data.new(image).as_inline e.body.should eq AMIME::Part::Multipart::Mixed.new image_part end def test_generate_body_with_text_and_html_content_and_attachment : Nil text, html, file_part, file, _, _ = self.generate_some_parts e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.text "text content" e.html "html content" e.add_part AMIME::Part::Data.new file e.body.should eq AMIME::Part::Multipart::Mixed.new(AMIME::Part::Multipart::Alternative.new(text, html), file_part) end def test_generate_body_with_text_and_html_content_and_attachment_and_attached_image_not_referenced : Nil text, html, file_part, file, image_part, image = self.generate_some_parts e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.text "text content" e.html "html content" e.add_part AMIME::Part::Data.new(file) e.add_part AMIME::Part::Data.new(image, "test.gif") e.body.should eq AMIME::Part::Multipart::Mixed.new(AMIME::Part::Multipart::Alternative.new(text, html), file_part, image_part) end def test_generate_body_with_text_and_attached_file_and_attached_image_not_referenced : Nil text, _, file_part, file, image_part, image = self.generate_some_parts e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.text "text content" e.add_part AMIME::Part::Data.new(file) e.add_part AMIME::Part::Data.new(image, "test.gif") e.body.should eq AMIME::Part::Multipart::Mixed.new(text, file_part, image_part) end def test_generate_body_with_text_and_html_and_attached_file_and_attached_image_not_referenced_via_cid : Nil text, _, file_part, file, image_part, image = self.generate_some_parts e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.html content = %(html content ) e.text "text content" e.add_part AMIME::Part::Data.new(file) e.add_part AMIME::Part::Data.new(image, "test.gif") full_html = AMIME::Part::Text.new content, sub_type: "html" e.body.should eq AMIME::Part::Multipart::Mixed.new(AMIME::Part::Multipart::Alternative.new(text, full_html), file_part, image_part) end def test_generate_body_with_text_and_html_and_attached_file_and_attached_image_referenced_via_cid : Nil _, _, file_part, file, _, image = self.generate_some_parts e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.html %(html content ) e.text "text content" e.add_part AMIME::Part::Data.new(file) e.add_part AMIME::Part::Data.new(image, "test.gif") body = e.body.should be_a AMIME::Part::Multipart::Mixed (related = body.parts).size.should eq 2 related_part = related[0].should be_a AMIME::Part::Multipart::Related related[1].should eq file_part (parts = related_part.parts).size.should eq 2 alt_part = parts[0].should be_a AMIME::Part::Multipart::Alternative generated_html = alt_part.parts[1].should be_a AMIME::Part::Text data_part = parts[1].should be_a AMIME::Part::Data generated_html.body.should contain "cid:#{data_part.content_id}" end def test_generate_body_with_text_and_html_and_attached_file_and_attached_image_referenced_via_cid_and_content_id : Nil _, _, file_part, file, _, image = self.generate_some_parts e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.text "text content" e.add_part AMIME::Part::Data.new file img = AMIME::Part::Data.new image, "test.gif" e.add_part img e.html %(html content ) body = e.body.should be_a AMIME::Part::Multipart::Mixed (related_parts = body.parts).size.should eq 2 related_part = related_parts[0].should be_a AMIME::Part::Multipart::Related related_parts[1].should eq file_part (parts = related_part.parts).size.should eq 2 parts[0].should be_a AMIME::Part::Multipart::Alternative end def test_generate_body_with_html_and_inlined_image_twice_referenced_via_cid : Nil # Inline image (twice) referenced in the HTML content content = IO::Memory.new %(html content ) e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.html content # Embedding the same image twice results in one image only in the email image = ::File.open "#{__DIR__}/fixtures/mimetypes/test.gif", "r" e.add_part AMIME::Part::Data.new(image, "test.gif").as_inline e.add_part AMIME::Part::Data.new(image, "test.gif").as_inline body = e.body.should be_a AMIME::Part::Multipart::Related # 2 parts only, not 3 (text + 1 embedded image) (parts = body.parts).size.should eq 2 parts[0].body_to_s.should match /html content / e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.html %(
) e.add_part AMIME::Part::Data.new(image, "test.gif").as_inline body = e.body.should be_a AMIME::Part::Multipart::Related (parts = body.parts).size.should eq 2 parts[0].body_to_s.should match /
<\/div>/ end def test_attachments : Nil # Inline part contents = ::File.read path = "#{__DIR__}/fixtures/mimetypes/test" data_part = AMIME::Part::Data.new file = ::File.open(path), "test" inline = AMIME::Part::Data.new(contents, "test").as_inline e = AMIME::Email.new e.add_part AMIME::Part::Data.new file, "test" e.add_part AMIME::Part::Data.new(contents, "test").as_inline e.attachments.should eq [data_part, inline] # Inline part from path data_part = AMIME::Part::Data.from_path path, "test" inline = AMIME::Part::Data.from_path(path, "test").as_inline e = AMIME::Email.new e.add_part AMIME::Part::Data.new AMIME::Part::File.new(path) e.add_part AMIME::Part::Data.new(AMIME::Part::File.new(path)).as_inline e.attachments.map(&.body_to_s).should eq [data_part.body_to_s, inline.body_to_s] e.attachments.map(&.prepared_headers).should eq [data_part.prepared_headers, inline.prepared_headers] end def test_attachments_attach_helper_methods : Nil # Inline part contents = ::File.read path = "#{__DIR__}/fixtures/mimetypes/test" data_part = AMIME::Part::Data.new file = ::File.open(path), "test" inline = AMIME::Part::Data.new(contents, "test").as_inline e = AMIME::Email.new e.attach file, "test" e.embed contents, "test" e.attachments.should eq [data_part, inline] # Inline part from path data_part = AMIME::Part::Data.from_path path, "test" inline = AMIME::Part::Data.from_path(path, "test").as_inline e = AMIME::Email.new e.attach_from_path path, "test" e.embed_from_path path, "test" e.attachments.map(&.body_to_s).should eq [data_part.body_to_s, inline.body_to_s] e.attachments.map(&.prepared_headers).should eq [data_part.prepared_headers, inline.prepared_headers] end def test_body_cache_same : Nil e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.text "text content" body1 = e.body body2 = e.body # Must be the same instance so that DKIM sig is the same body1.should be body2 end def test_body_cache_different : Nil e = AMIME::Email.new.from("me@example.com").to("you@example.com") e.text "text content" body1 = e.body e.html "bar" body2 = e.body # Must not be the same due to the content changing body1.should_not be body2 end def test_ensure_validity : Nil AMIME::Email.new .from("me@example.com") .to("you@example.com") .text("content") .ensure_validity! end private def generate_some_parts : {AMIME::Part::Text, AMIME::Part::Text, AMIME::Part::Data, ::File, AMIME::Part::Data, ::File} text = AMIME::Part::Text.new "text content" html = AMIME::Part::Text.new "html content", sub_type: "html" file_part = AMIME::Part::Data.new file = ::File.open "#{__DIR__}/fixtures/mimetypes/test", "r" image_part = AMIME::Part::Data.new (image = ::File.open("#{__DIR__}/fixtures/mimetypes/test.gif", "r")), "test.gif" {text, html, file_part, file, image_part, image} end end ================================================ FILE: src/components/mime/spec/encoder/base64_content_spec.cr ================================================ require "../spec_helper" struct Base64ContentEncoderTest < ASPEC::TestCase def test_name : Nil AMIME::Encoder::Base64Content.new.name.should eq "base64" end def test_encodes_string : Nil AMIME::Encoder::Base64Content.new.encode("123").should eq "MTIz\n" # spellchecker:disable-line AMIME::Encoder::Base64Content.new.encode("123456").should eq "MTIzNDU2\n" # spellchecker:disable-line AMIME::Encoder::Base64Content.new.encode("123456789").should eq "MTIzNDU2Nzg5\n" # spellchecker:disable-line end def test_encodes_io : Nil AMIME::Encoder::Base64Content.new.encode(IO::Memory.new "123").should eq "MTIz\n" # spellchecker:disable-line AMIME::Encoder::Base64Content.new.encode(IO::Memory.new "123456").should eq "MTIzNDU2\n" # spellchecker:disable-line AMIME::Encoder::Base64Content.new.encode(IO::Memory.new "123456789").should eq "MTIzNDU2Nzg5\n" # spellchecker:disable-line end def test_pad_length : Nil encoder = AMIME::Encoder::Base64Content.new 30.times do input = String.build do |io| io.write_byte rand 255_u8 end # Two bytes of padding for a single byte encoder.encode(input).should match /^[a-zA-Z0-9\/+]{2}==$/ end 30.times do input = String.build do |io| io.write_byte rand 255_u8 io.write_byte rand 255_u8 end # Two bytes has 1 byte of padding encoder.encode(input).should match /^[a-zA-Z0-9\/+]{3}=$/ end 30.times do input = String.build do |io| io.write_byte rand 255_u8 io.write_byte rand 255_u8 io.write_byte rand 255_u8 end # Three bytes has no padding encoder.encode(input).should match /^[a-zA-Z0-9\/+]{4}$/ end end def test_max_line_length : Nil input = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" AMIME::Encoder::Base64Content .new .encode(input) .lines(chomp: false) # Use lines here to allow ignoring the typos .should eq([ "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJT\n", "VFVWV1hZWjEyMzQ1Njc4OTBhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFC\n", # spellchecker:disable-line "Q0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMTIzNDU2Nzg5MEFCQ0RFRkdISUpL\n", # spellchecker:disable-line "TE1OT1BRUlNUVVZXWFla\n", # spellchecker:disable-line ]) end end ================================================ FILE: src/components/mime/spec/encoder/eight_bit_content_spec.cr ================================================ require "../spec_helper" struct EightBitContentEncoderTest < ASPEC::TestCase def test_name : Nil AMIME::Encoder::EightBitContent.new.name.should eq "8bit" end def test_encodes_string : Nil AMIME::Encoder::EightBitContent.new.encode("123").should eq "123" AMIME::Encoder::EightBitContent.new.encode("123456").should eq "123456" AMIME::Encoder::EightBitContent.new.encode("123456789").should eq "123456789" end def test_encodes_io : Nil AMIME::Encoder::EightBitContent.new.encode(IO::Memory.new "123").should eq "123" AMIME::Encoder::EightBitContent.new.encode(IO::Memory.new "123456").should eq "123456" AMIME::Encoder::EightBitContent.new.encode(IO::Memory.new "123456789").should eq "123456789" end end ================================================ FILE: src/components/mime/spec/encoder/idn_address_spec.cr ================================================ require "../spec_helper" struct IDNAddressEncoderTest < ASPEC::TestCase def test_encodes_string : Nil AMIME::Encoder::IDNAddress.new.encode("test@fußball.test").should eq "test@xn--fuball-cta.test" end end ================================================ FILE: src/components/mime/spec/encoder/quoted_printable_content_spec.cr ================================================ require "../spec_helper" struct QuotedPrintableEncoderTest < ASPEC::TestCase def test_quoted_printable_encode : Nil AMIME::Encoder::QuotedPrintableContent.quoted_printable_encode("test").should eq "test" AMIME::Encoder::QuotedPrintableContent.quoted_printable_encode("this is a foo").should eq "this is a foo" AMIME::Encoder::QuotedPrintableContent.quoted_printable_encode("This is a sample string with special characters: ä, ö, ü, and ß.").should eq <<-TXT This is a sample string with special characters: =C3=A4, =C3=B6, =C3=BC, an=\r d =C3=9F. TXT AMIME::Encoder::QuotedPrintableContent.quoted_printable_encode("Iñtërnâtiônàlizætiøn☃💩").should eq <<-TXT I=C3=B1t=C3=ABrn=C3=A2ti=C3=B4n=C3=A0liz=C3=A6ti=C3=B8n=E2=98=83=\r =F0=9F=92=A9 TXT end def test_quoted_printable_encode_encodes_nul_values : Nil AMIME::Encoder::QuotedPrintableContent .quoted_printable_encode("\0" * 200).should eq <<-TXT =00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=\r =00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=\r =00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=\r =00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=\r =00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=\r =00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=\r =00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=\r =00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00=00 TXT end def test_quoted_printable_encode_encodes_non_ascii AMIME::Encoder::QuotedPrintableContent .quoted_printable_encode("строка в юникоде" * 50).should eq <<-TXT =D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=\r =BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=\r =D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=\r =B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=\r =D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=\r =82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=\r =D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=\r =BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=\r =D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =\r =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=\r =BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=\r =D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=\r =B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=\r =D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=\r =8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=\r =B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=\r =D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=\r =81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=\r =D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=\r =B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =\r =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=\r =D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=\r =80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=\r =D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=\r =BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=\r =D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=\r =B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=\r =D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=\r =82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=\r =D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=\r =BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=\r =D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =\r =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=\r =BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=\r =D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=\r =B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=\r =D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=\r =8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=\r =B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=\r =D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=\r =81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=\r =D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=\r =B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =\r =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=\r =D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=\r =80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=\r =D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=\r =BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=\r =D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=\r =B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=\r =D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=\r =82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=\r =D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=\r =BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=\r =D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =\r =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=\r =BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5=D1=81=\r =D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=\r =B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=8E=D0=BD=D0=B8=\r =D0=BA=D0=BE=D0=B4=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0 =D0=B2 =D1=\r =8E=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5 TXT end def test_quoted_printable_encode_does_not_split_multibyte_chars_by_soft_break : Nil AMIME::Encoder::QuotedPrintableContent .quoted_printable_encode("\xc4\x85" * 77).should eq <<-TXT =C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=\r =C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=\r =C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=\r =C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=\r =C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=\r =C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=C4=85=\r =C4=85=C4=85=C4=85=C4=85=C4=85 TXT end def test_quoted_printable_encode_permitted_characters_are_not_encoded : Nil ((33..60).to_a + (62..126).to_a).each do |ord| char = ord.chr.to_s AMIME::Encoder::QuotedPrintableContent.quoted_printable_encode(char).should eq char end end def test_quoted_printable_encode_crlf_is_left_alone : Nil string = "a\r\nb\r\nc\r\n" AMIME::Encoder::QuotedPrintableContent.quoted_printable_encode(string).should eq string end def test_quoted_printable_encode_always_encodes_tabs : Nil AMIME::Encoder::QuotedPrintableContent .quoted_printable_encode("a\t\t\r\nb") .should eq "a=09=09\r\nb" end def test_quoted_printable_encode_encodes_space_before_newline : Nil AMIME::Encoder::QuotedPrintableContent .quoted_printable_encode("a \r\nb") .should eq "a =20\r\nb" end def test_quoted_printable_encode_lines_longer_than_76_characters_are_soft_broken : Nil AMIME::Encoder::QuotedPrintableContent.quoted_printable_encode("a" * 140).should eq <<-TXT aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=\r\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa TXT end def test_quoted_printable_encode_bytes_below_permitted_range_are_encoded : Nil (0..31).each do |byte| char = byte.chr.to_s AMIME::Encoder::QuotedPrintableContent .quoted_printable_encode(char) .should eq sprintf("=%02X", byte) end # Allows spaces AMIME::Encoder::QuotedPrintableContent.quoted_printable_encode(" ").should eq " " end def test_name : Nil AMIME::Encoder::QuotedPrintableContent.new.name.should eq "quoted-printable" end def test_encode : Nil AMIME::Encoder::QuotedPrintableContent.new.encode("test").should eq "test" AMIME::Encoder::QuotedPrintableContent.new.encode("this is a foo").should eq "this is a foo" AMIME::Encoder::QuotedPrintableContent.new.encode("This is a sample string with special characters: ä, ö, ü, and ß.").should eq <<-TXT This is a sample string with special characters: =C3=A4, =C3=B6, =C3=BC, an=\r d =C3=9F. TXT AMIME::Encoder::QuotedPrintableContent.new.encode("Iñtërnâtiônàlizætiøn☃💩").should eq <<-TXT I=C3=B1t=C3=ABrn=C3=A2ti=C3=B4n=C3=A0liz=C3=A6ti=C3=B8n=E2=98=83=\r =F0=9F=92=A9 TXT end def test_quoted_printable_encode_io : Nil AMIME::Encoder::QuotedPrintableContent.new.encode(IO::Memory.new "test").should eq "test" AMIME::Encoder::QuotedPrintableContent.new.encode(IO::Memory.new "this is a foo").should eq "this is a foo" AMIME::Encoder::QuotedPrintableContent.new.encode(IO::Memory.new "This is a sample string with special characters: ä, ö, ü, and ß.").should eq <<-TXT This is a sample string with special characters: =C3=A4, =C3=B6, =C3=BC, an=\r d =C3=9F. TXT AMIME::Encoder::QuotedPrintableContent.new.encode("Iñtërnâtiônàlizætiøn☃💩").should eq <<-TXT I=C3=B1t=C3=ABrn=C3=A2ti=C3=B4n=C3=A0liz=C3=A6ti=C3=B8n=E2=98=83=\r =F0=9F=92=A9 TXT end end ================================================ FILE: src/components/mime/spec/encoder/quoted_printable_mime_header_spec.cr ================================================ require "../spec_helper" struct QuotedPrintableMIMEHeaderTest < ASPEC::TestCase def test_name_is_q : Nil AMIME::Encoder::QuotedPrintableMIMEHeader.new.name.should eq "Q" end def test_space_and_tab_never_appear : Nil AMIME::Encoder::QuotedPrintableMIMEHeader .new .encode("a \t b") .should_not match /[ \t]/ end def test_space_is_represented_by_underscore : Nil AMIME::Encoder::QuotedPrintableMIMEHeader .new .encode("a b") .should eq "a_b" end def test_equals_and_question_underscore_are_encoded : Nil AMIME::Encoder::QuotedPrintableMIMEHeader .new .encode("=?_") .should eq "=3D=3F=5F" end def test_parans_and_quotes_are_encoded : Nil AMIME::Encoder::QuotedPrintableMIMEHeader .new .encode("(\")") .should eq "=28=22=29" end def test_only_chars_allowed_in_phrases_are_used : Nil encoder = AMIME::Encoder::QuotedPrintableMIMEHeader.new allowed_bytes = [] of Int32 allowed_bytes.concat ('a'..'z').map(&.ord) allowed_bytes.concat ('A'..'Z').map(&.ord) allowed_bytes.concat ('0'..'9').map(&.ord) allowed_bytes.concat ['!'.ord, '*'.ord, '+'.ord, '-'.ord, '/'.ord] (0x00_u8..0xFF_u8).each do |byte| io = IO::Memory.new io.write_byte byte input = io.to_s encoded = encoder.encode input if allowed_bytes.includes? byte encoded.should eq input elsif ' '.ord == byte # Special case encoded.should eq "_" else encoded.should eq "=#{byte.to_s base: 16, upcase: true, precision: 2}" end end end def test_equals_never_appears_at_end_of_line : Nil AMIME::Encoder::QuotedPrintableMIMEHeader .new .encode("a" * 140) .should eq <<-TXT aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa TXT end end ================================================ FILE: src/components/mime/spec/encoder/rfc2231_spec.cr ================================================ require "../spec_helper" struct RFC2231EncoderTest < ASPEC::TestCase private RFC2245_TOKEN = Regex.new "^[\x21\x23-\x27\x2A\x2B\x2D\x2E\x30-\x39\x41-\x5A\x5E-\x7E]+$", options: :dollar_endonly def test_encoding_ascii_characters_produces_valid_token : Nil string = String.build do |io| (0x00_u8..0x7F_u8).each do |byte| io.write_byte byte end end encoded = AMIME::Encoder::RFC2231 .new .encode(string) encoded.split("\r\n").each do |line| line.should match RFC2245_TOKEN end end def test_encoding_non_ascii_characters_produces_valid_token : Nil string = String.build do |io| (0x80_u8..0xFF_u8).each do |byte| io.write_byte byte end end encoded = AMIME::Encoder::RFC2231 .new .encode(string) encoded.split("\r\n").each do |line| line.should match RFC2245_TOKEN end end def test_max_line_length_can_be_set : Nil AMIME::Encoder::RFC2231 .new .encode("a" * 200, max_line_length: 75) .should eq <<-TXT aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa TXT end def test_first_line_can_have_shorter_length : Nil AMIME::Encoder::RFC2231 .new .encode("a" * 200, first_line_offset: 24, max_line_length: 72) .should eq <<-TXT aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r aaaaaaaa TXT end @[TestWith( {"iso-2022-jp", "one.txt"}, {"iso-8859-1", "one.txt"}, {"utf-8", "one.txt"}, {"utf-8", "two.txt"}, {"utf-8", "three.txt"}, )] def test_encoding_and_decoding_samples(encoding : String, file : String) : Nil encoder = AMIME::Encoder::RFC2231.new text = File.read "#{__DIR__}/../fixtures/samples/charsets/#{encoding}/#{file}" encoded_text = encoder.encode text, encoding # Encoded string should decode back to original string URI.decode(encoded_text.split("\r\n").join).should eq text end end ================================================ FILE: src/components/mime/spec/fixtures/content.txt ================================================ content ================================================ FILE: src/components/mime/spec/fixtures/mimetypes/.unknownextension ================================================ f ================================================ FILE: src/components/mime/spec/fixtures/mimetypes/abc.csv ================================================ a,b,c d,e,f g,h,i ================================================ FILE: src/components/mime/spec/fixtures/mimetypes/directory/.empty ================================================ ================================================ FILE: src/components/mime/spec/fixtures/mimetypes/other-file.example ================================================ ================================================ FILE: src/components/mime/spec/fixtures/samples/charsets/iso-2022-jp/one.txt ================================================ ISO-2022-JPは、インターネット上(特に電子メール)などで使われる日本の文字用の文字符号化方式。ISO/IEC 2022のエスケープシーケンスを利用して文字集合を切り替える7ビットのコードであることを特徴とする (アナウンス機能のエスケープシーケンスは省略される)。俗に「JISコード」と呼ばれることもある。 概要 日本語表記への利用が想定されている文字コードであり、日本語の利用されるネットワークにおいて、日本の規格を応用したものである。また文字集合としては、日本語で用いられる漢字、ひらがな、カタカナはもちろん、ラテン文字、ギリシア文字、キリル文字なども含んでおり、学術や産業の分野での利用も考慮たものとなっている。規格名に、ISOの日本語の言語コードであるjaではなく、国・地域名コードのJPが示されているゆえんである。 文字集合としてJIS X 0201のC0集合(制御文字)、JIS X 0201のラテン文字集合、ISO 646の国際基準版図形文字、JIS X 0208の1978年版(JIS C 6226-1978)と1983年および1990年版が利用できる。JIS X 0201の片仮名文字集合は利用できない。1986年以降、日本の電子メールで用いられてきたJUNETコードを、村井純・Mark Crispin・Erik van der Poelが1993年にRFC化したもの(RFC 1468)。後にJIS X 0208:1997の附属書2としてJISに規定された。MIMEにおける文字符号化方式の識別用の名前として IANA に登録されている。 なお、符号化の仕様についてはISO/IEC 2022#ISO-2022-JPも参照。 ISO-2022-JPと非標準的拡張使用 「JISコード」(または「ISO-2022-JP」)というコード名の規定下では、その仕様通りの使用が求められる。しかし、Windows OS上では、実際にはCP932コード (MicrosoftによるShift JISを拡張した亜種。ISO-2022-JP規定外文字が追加されている。)による独自拡張(の文字)を断りなく使うアプリケーションが多い。この例としてInternet ExplorerやOutlook Expressがある。また、EmEditor、秀丸エディタやThunderbirdのようなMicrosoft社以外のWindowsアプリケーションでも同様の場合がある。この場合、ISO-2022-JPの範囲外の文字を使ってしまうと、異なる製品間では未定義不明文字として認識されるか、もしくは文字化けを起こす原因となる。そのため、Windows用の電子メールクライアントであっても独自拡張の文字を使用すると警告を出したり、あえて使えないように制限しているものも存在する。さらにはISO-2022-JPの範囲内であってもCP932は非標準文字(FULLWIDTH TILDE等)を持つので文字化けの原因になり得る。 また、符号化方式名をISO-2022-JPとしているのに、文字集合としてはJIS X 0212 (いわゆる補助漢字) やJIS X 0201の片仮名文字集合 (いわゆる半角カナ) をも符号化している例があるが、ISO-2022-JPではこれらの文字を許容していない。これらの符号化は独自拡張の実装であり、中にはISO/IEC 2022の仕様に準拠すらしていないものもある[2]。従って受信側の電子メールクライアントがこれらの独自拡張に対応していない場合、その文字あるいはその文字を含む行、時にはテキスト全体が文字化けすることがある。 ================================================ FILE: src/components/mime/spec/fixtures/samples/charsets/iso-8859-1/one.txt ================================================ Op mat eraus hinnen beschte, rou zënne schaddreg ké. Ké sin Eisen Kaffi prächteg, den haut esou Fielse wa, Well zielen d'Welt am dir. Aus grousse rëschten d'Stroos do, as dat Kléder gewëss d'Kàchen. Schied gehéiert d'Vioule net hu, rou ke zënter Säiten d'Hierz. Ze eise Fletschen mat, gei as gréng d'Lëtzebuerger. Wäit räich no mat. Säiten d'Liewen aus en. Un gëtt bléit lossen wee, da wéi alle weisen Kolrettchen. Et deser d'Pan d'Kirmes vun, en wuel Benn rëschten méi. En get drem ménger beschte, da wär Stad welle. Nun Dach d'Pied do, mä gét ruffen gehéiert. Ze onser ugedon fir, d'Liewen Plett'len ech no, si Räis wielen bereet wat. Iwer spilt fir jo. An hin däischter Margréitchen, eng ke Frot brommt, vu den Räis néierens. Da hir Hunn Frot nozegon, rout Fläiß Himmel zum si, net gutt Kaffi Gesträich fu. Vill lait Gaart sou wa, Land Mamm Schuebersonndeg rei do. Gei geet Minutt en, gei d'Leit beschte Kolrettchen et, Mamm fergiess un hun. Et gutt Heck kommen oft, Lann rëscht rei um, Hunn rëscht schéinste ke der. En lait zielen schnéiwäiss hir, fu rou botze éiweg Minutt, rem fest gudden schaddreg en. Noper bereet Margréitchen mat op, dem denkt d'Leit d'Vioule no, oft ké Himmel Hämmel. En denkt blénken Fréijor net, Gart Schiet d'Natur no wou. No hin Ierd Frot d'Kirmes. Hire aremt un rou, ké den éiweg wielen Milliounen. Mir si Hunn Blénkeg. Ké get ston derfir d'Kàchen. Haut d'Pan fu ons, dé frou löschteg d'Meereische rei. Sou op wuel Léift. Stret schlon grousse gin hu. Mä denkt d'Leit hinnen net, ké gét haut fort rëscht. Koum d'Pan hannendrun ass ké, ké den brét Kaffi geplot. Schéi Hären d'Pied fu gét, do d'Mier néierens bei. Rëm päift Hämmel am, wee Engel beschéngt mä. Brommt klinzecht der ke, wa rout jeitzt dén. Get Zalot d'Vioule däischter da, jo fir Bänk päift duerch, bei d'Beem schéinen Plett'len jo. Den haut Faarwen ze, eng en Biereg Kirmesdag, um sin alles Faarwen d'Vioule. Eng Hunn Schied et, wat wa Frot fest gebotzt. Bei jo bleiwe ruffen Klarinett. Un Feld klinzecht gét, rifft Margréitchen rem ke. Mir dé Noper duurch gewëss, ston sech kille sin en. Gei Stret d'Wise um, Haus Gart wee as. Monn ménger an blo, wat da Gart gefällt Hämmelsbrot. Brommt geplot och ze, dat wa Räis Well Kaffi. Do get spilt prächteg, as wär kille bleiwe gewalteg. Onser frësch Margréitchen rem ke, blo en huet ugedon. Onser Hemecht wär de, hu eraus d'Sonn dat, eise deser hannendrun da och. As durch Himmel hun, no fest iw'rem schéinste mir, Hunn séngt Hierz ke zum. Séngt iw'rem d'Natur zum an. Ke wär gutt Grénge. Kënnt gudden prächteg mä rei. Dé dir Blénkeg Klarinett Kolrettchen, da fort muerges d'Kanner wou, main Feld ruffen vu wéi. Da gin esou Zalot gewalteg, gét vill Hemecht blénken dé. Haut gréng nun et, nei vu Bass gréng d'Gaassen. Fest d'Beem uechter si gin. Oft vu sinn wellen kréien. Et ass lait Zalot schéinen. ================================================ FILE: src/components/mime/spec/fixtures/samples/charsets/utf-8/one.txt ================================================ Код одно гринспана руководишь на. Его вы знания движение. Ты две начать одиночку, сказать основатель удовольствием но миф. Бы какие система тем. Полностью использует три мы, человек клоунов те нас, бы давать творческую эзотерическая шеф. Мог не помнить никакого сэкономленного, две либо какие пишите бы. Должен компанию кто те, этот заключалась проектировщик не ты. Глупые периоды ты для. Вам который хороший он. Те любых кремния концентрируются мог, собирать принадлежите без вы. Джоэла меньше хорошего вы миф, за тем году разработки. Даже управляющим руководители был не. Три коде выпускать заботиться ну. То его система удовольствием безостановочно, или ты главной процессорах. Мы без джоэл знания получат, статьи остальные мы ещё. Них русском касается поскольку по, образование должником систематизированный ну мои. Прийти кандидата университет но нас, для бы должны никакого, биг многие причин интервьюирования за. Тем до плиту почему. Вот учёт такие одного бы, об биг разным внешних промежуток. Вас до какому возможностей безответственный, были погодите бы его, по них глупые долгий количества. ================================================ FILE: src/components/mime/spec/fixtures/samples/charsets/utf-8/three.txt ================================================ Αν ήδη διάβασε γλιτώσει μεταγλωτίσει, αυτήν θυμάμαι μου μα. Την κατάσταση χρησιμοποίησέ να! Τα διαφορά φαινόμενο διολισθήσεις πες, υψηλότερη προκαλείς περισσότερες όχι κι. Με ελέγχου γίνεται σας, μικρής δημιουργούν τη του. Τις τα γράψει εικόνες απαράδεκτη? Να ότι πρώτοι απαραίτητο. Άμεση πετάνε κακόκεφος τον ώς, να χώρου πιθανότητες του. Το μέχρι ορίστε λιγότερους σας. Πω ναί φυσικά εικόνες. Μου οι κώδικα αποκλειστικούς, λες το μάλλον συνεχώς. Νέου σημεία απίστευτα σας μα. Χρόνου μεταγλωτιστής σε νέα, τη τις πιάνει μπορούσες προγραμματιστές. Των κάνε βγαίνει εντυπωσιακό τα? Κρατάει τεσσαρών δυστυχώς της κι, ήδη υψηλότερη εξακολουθεί τα? Ώρα πετάνε μπορούσε λιγότερους αν, τα απαράδεκτη συγχωνευτεί ροή. Τη έγραψες συνηθίζουν σαν. Όλα με υλικό στήλες χειρότερα. Ανώδυνη δουλέψει επί ως, αν διαδίκτυο εσωτερικών παράγοντες από. Κεντρικό επιτυχία πες το. Πω ναι λέει τελειώσει, έξι ως έργων τελειώσει. Με αρχεία βουτήξουν ανταγωνιστής ώρα, πολύ γραφικά σελίδων τα στη. Όρο οέλεγχος δημιουργούν δε, ας θέλεις ελέγχου συντακτικό όρο! Της θυμάμαι επιδιόρθωση τα. Για μπορούσε περισσότερο αν, μέγιστη σημαίνει αποφάσισε τα του, άτομο αποτελέσει τι στα. Τι στην αφήσεις διοίκηση στη. Τα εσφαλμένη δημιουργια επιχείριση έξι! Βήμα μαγικά εκτελέσει ανά τη. Όλη αφήσεις συνεχώς εμπορικά αν, το λες κόλπα επιτυχία. Ότι οι ζώνη κειμένων. Όρο κι ρωτάει γραμμής πελάτες, τελειώσει διολισθήσεις καθυστερούσε αν εγώ? Τι πετούν διοίκηση προβλήματα ήδη. Τη γλιτώσει αποθηκευτικού μια. Πω έξι δημιουργια πιθανότητες, ως πέντε ελέγχους εκτελείται λες. Πως ερωτήσεις διοικητικό συγκεντρωμένοι οι, ας συνεχώς διοικητικό αποστηθίσει σαν. Δε πρώτες συνεχώς διολισθήσεις έχω, από τι κανένας βουτήξουν, γειτονιάς προσεκτικά ανταγωνιστής κι σαν. Δημιουργια συνηθίζουν κλπ τι? Όχι ποσοστό διακοπής κι. Κλπ φακέλους δεδομένη εξοργιστικά θα? Υποψήφιο καθορίζουν με όλη, στα πήρε προσοχή εταιρείες πω, ώς τον συνάδελφος διοικητικό δημιουργήσεις! Δούλευε επιτίθενται σας θα, με ένας παραγωγικής ένα, να ναι σημεία μέγιστη απαράδεκτη? Σας τεσσαρών συνεντεύξης τη, αρπάζεις σίγουρος μη για', επί τοπικές εντολές ακούσει θα? Ως δυστυχής μεταγλωτιστής όλη, να την είχαν σφάλμα απαραίτητο! Μην ώς άτομο διορθώσει χρησιμοποιούνταν. Δεν τα κόλπα πετάξαμε, μη που άγχος υόρκη άμεση, αφού δυστυχώς διακόψουμε όρο αν! Όλη μαγικά πετάνε επιδιορθώσεις δε, ροή φυσικά αποτελέσει πω. Άπειρα παραπάνω φαινόμενο πω ώρα, σαν πόρτες κρατήσουν συνηθίζουν ως. Κι ώρα τρέξει είχαμε εφαρμογή. Απλό σχεδιαστής μεταγλωτιστής ας επί, τις τα όταν έγραψες γραμμής? Όλα κάνεις συνάδελφος εργαζόμενοι θα, χαρτιού χαμηλός τα ροή. Ως ναι όροφο έρθει, μην πελάτες αποφάσισε μεταφραστής με, να βιαστικά εκδόσεις αναζήτησης λες. Των φταίει εκθέσεις προσπαθήσεις οι, σπίτι αποστηθίσει ας λες? Ώς που υπηρεσία απαραίτητο δημιουργείς. Μη άρα χαρά καθώς νύχτας, πω ματ μπουν είχαν. Άμεση δημιουργείς ώς ροή, γράψει γραμμής σίγουρος στα τι! Αν αφού πρώτοι εργαζόμενων ναί. Άμεση διορθώσεις με δύο? Έχουν παράδειγμα των θα, μου έρθει θυμάμαι περισσότερο το. Ότι θα αφού χρειάζονται περισσότερες. Σαν συνεχώς περίπου οι. Ώς πρώτης πετάξαμε λες, όρο κι πρώτες ζητήσεις δυστυχής. Ανά χρόνου διακοπή επιχειρηματίες ας, ώς μόλις άτομο χειρότερα όρο, κρατάει σχεδιαστής προσπαθήσεις νέο το. Πουλάς προσθέσει όλη πω, τύπου χαρακτηριστικό εγώ σε, πω πιο δούλευε αναζήτησης? Αναφορά δίνοντας σαν μη, μάθε δεδομένη εσωτερικών με ναι, αναφέρονται περιβάλλοντος ώρα αν. Και λέει απόλαυσε τα, που το όροφο προσπαθούν? Πάντα χρόνου χρήματα ναι το, σαν σωστά θυμάμαι σκεφτείς τα. Μα αποτελέσει ανεπιθύμητη την, πιο το τέτοιο ατόμου, τη των τρόπο εργαλείων επιδιόρθωσης. Περιβάλλον παραγωγικής σου κι, κλπ οι τύπου κακόκεφους αποστηθίσει, δε των πλέον τρόποι. Πιθανότητες χαρακτηριστικών σας κι, γραφικά δημιουργήσεις μια οι, πω πολλοί εξαρτάται προσεκτικά εδώ. Σταματάς παράγοντες για' ώς, στις ρωτάει το ναι! Καρέκλα ζητήσεις συνδυασμούς τη ήδη! Για μαγικά συνεχώς ακούσει το. Σταματάς προϊόντα βουτήξουν ώς ροή. Είχαν πρώτες οι ναι, μα λες αποστηθίσει ανακαλύπτεις. Όροφο άλγεβρα παραπάνω εδώ τη, πρόσληψη λαμβάνουν καταλάθος ήδη ας? Ως και εισαγωγή κρατήσουν, ένας κακόκεφους κι μας, όχι κώδικάς παίξουν πω. Πω νέα κρατάει εκφράσουν, τότε τελικών τη όχι, ας της τρέξει αλλάζοντας αποκλειστικούς. Ένας βιβλίο σε άρα, ναι ως γράψει ταξινομεί διορθώσεις! Εδώ να γεγονός συγγραφείς, ώς ήδη διακόψουμε επιχειρηματίες? Ότι πακέτων εσφαλμένη κι, θα όρο κόλπα παραγωγικής? Αν έχω κεντρικό υψηλότερη, κι δεν ίδιο πετάνε παρατηρούμενη! Που λοιπόν σημαντικό μα, προκαλείς χειροκροτήματα ως όλα, μα επί κόλπα άγχος γραμμές! Δε σου κάνεις βουτήξουν, μη έργων επενδυτής χρησιμοποίησέ στα, ως του πρώτες διάσημα σημαντικό. Βιβλίο τεράστιο προκύπτουν σαν το, σαν τρόπο επιδιόρθωση ας. Είχαν προσοχή προσπάθεια κι ματ, εδώ ως έτσι σελίδων συζήτηση. Και στην βγαίνει εσφαλμένη με, δυστυχής παράδειγμα δε μας, από σε υόρκη επιδιόρθωσης. Νέα πω νέου πιθανό, στήλες συγγραφείς μπαίνοντας μα για', το ρωτήσει κακόκεφους της? Μου σε αρέσει συγγραφής συγχωνευτεί, μη μου υόρκη ξέχασε διακοπής! Ώς επί αποφάσισε αποκλειστικούς χρησιμοποιώντας, χρήματα σελίδων ταξινομεί ναι με. Μη ανά γραμμή απόλαυσε, πω ναι μάτσο διασφαλίζεται. Τη έξι μόλις εργάστηκε δημιουργούν, έκδοση αναφορά δυσκολότερο οι νέο. Σας ως μπορούσε παράδειγμα, αν ότι δούλευε μπορούσε αποκλειστικούς, πιο λέει βουτήξουν διορθώσει ως. Έχω τελευταία κακόκεφους ας, όσο εργαζόμενων δημιουργήσεις τα. Του αν δουλέψει μπορούσε, πετούν χαμηλός εδώ ας? Κύκλο τύπους με που, δεν σε έχουν συνεχώς χειρότερα, τις τι απαράδεκτη συνηθίζουν? Θα μην τους αυτήν, τη ένα πήρε πακέτων, κι προκύπτουν περιβάλλον πως. Μα για δουλέψει απόλαυσε εφαμοργής, ώς εδώ σημαίνει μπορούσες, άμεση ακούσει προσοχή τη εδώ? Στα δώσε αθόρυβες λιγότερους οι, δε αναγκάζονται αποκλειστικούς όλα! Ας μπουν διοικητικό μια, πάντα ελέγχου διορθώσεις ώς τον. Ότι πήρε κανόνα μα. Που άτομα κάνεις δημιουργίες τα, οι μας αφού κόλπα προγραμματιστής, αφού ωραίο προκύπτουν στα ως. Θέμα χρησιμοποιήσει αν όλα, του τα άλγεβρα σελίδων. Τα ότι ανώδυνη δυστυχώς συνδυασμούς, μας οι πάντα γνωρίζουμε ανταγωνιστής, όχι τα δοκιμάσεις σχεδιαστής! Στην συνεντεύξης επιδιόρθωση πιο τα, μα από πουλάς περιβάλλον παραγωγικής. Έχουν μεταγλωτίσει σε σας, σε πάντα πρώτης μειώσει των, γράψει ρουτίνα δυσκολότερο ήδη μα? Ταξινομεί διορθώσεις να μας. Θα της προσπαθούν περιεχόμενα, δε έχω τοπικές στέλνοντάς. Ανά δε αλφα άμεση, κάποιο ρωτάει γνωρίζουμε πω στη, φράση μαγικά συνέχεια δε δύο! Αν είχαμε μειώσει ροή, μας μετράει καθυστερούσε επιδιορθώσεις μη. Χάος υόρκη κεντρικό έχω σε, ανά περίπου αναγκάζονται πω. Όσο επιστρέφουν χρονοδιαγράμματα μη. Πως ωραίο κακόκεφος διαχειριστής ως, τις να διακοπής αναζήτησης. Κάποιο ποσοστό ταξινομεί επί τη? Μάθε άμεση αλλάζοντας δύο με, μου νέου πάντα να. Πω του δυστυχώς πιθανότητες. Κι ρωτάει υψηλότερη δημιουργια ότι, πω εισαγωγή τελευταία απομόνωση ναι. Των ζητήσεις γνωρίζουμε ώς? Για' μη παραδοτέου αναφέρονται! Ύψος παραγωγικά ροή ως, φυσικά διάβασε εικόνες όσο σε? Δεν υόρκη διορθώσεις επεξεργασία θα, ως μέση σύστημα χρησιμοποιήσει τις. ================================================ FILE: src/components/mime/spec/fixtures/samples/charsets/utf-8/two.txt ================================================ रखति आवश्यकत प्रेरना मुख्यतह हिंदी किएलोग असक्षम कार्यलय करते विवरण किके मानसिक दिनांक पुर्व संसाध एवम् कुशलता अमितकुमार प्रोत्साहित जनित देखने उदेशीत विकसित बलवान ब्रौशर किएलोग विश्लेषण लोगो कैसे जागरुक प्रव्रुति प्रोत्साहित सदस्य आवश्यकत प्रसारन उपलब्धता अथवा हिंदी जनित दर्शाता यन्त्रालय बलवान अतित सहयोग शुरुआत सभीकुछ माहितीवानीज्य लिये खरिदे है।अभी एकत्रित सम्पर्क रिती मुश्किल प्राथमिक भेदनक्षमता विश्व उन्हे गटको द्वारा तकरीबन विश्व द्वारा व्याख्या सके। आजपर वातावरण व्याख्यान पहोच। हमारी कीसे प्राथमिक विचारशिलता पुर्व करती कम्प्युटर भेदनक्षमता लिये बलवान और्४५० यायेका वार्तालाप सुचना भारत शुरुआत लाभान्वित पढाए संस्था वर्णित मार्गदर्शन चुनने ================================================ FILE: src/components/mime/spec/header/collection_spec.cr ================================================ require "../spec_helper" struct HeaderCollectionTest < ASPEC::TestCase def test_line_length : Nil headers = AMIME::Header::Collection.new headers.add_date_header "date", Time.utc headers.line_length.should eq 76 headers["date"].max_line_length.should eq 76 headers.line_length = 50 headers.line_length.should eq 50 headers["date"].max_line_length.should eq 50 end def test_add_mailbox_list_header : Nil headers = AMIME::Header::Collection.new headers.add_mailbox_list_header "from", ["me@example.com"] headers["from"].should_not be_nil end def test_add_date_header : Nil headers = AMIME::Header::Collection.new headers.add_date_header "date", Time.utc headers["date"].should_not be_nil end def test_add_text_header : Nil headers = AMIME::Header::Collection.new headers.add_text_header "subject", "The Subject" headers["subject"].should_not be_nil end def test_add_parameterized_header : Nil headers = AMIME::Header::Collection.new headers.add_parameterized_header "content-type", "text/plain", {"charset" => "UTF-8"} headers["content-type"].should_not be_nil end def test_add_id_header : Nil headers = AMIME::Header::Collection.new headers.add_id_header "message-id", "some@id" headers["message-id"].should_not be_nil end def test_add_path_header : Nil headers = AMIME::Header::Collection.new headers.add_path_header "return-path", "me@example.com" headers["return-path"].should_not be_nil end def test_has_key : Nil headers = AMIME::Header::Collection.new headers.has_key?("date").should be_false headers.add_date_header "date", Time.utc headers.has_key?("date").should be_true end def test_is_unique_header : Nil AMIME::Header::Collection.unique_header?("date").should be_true AMIME::Header::Collection.unique_header?("foo").should be_false end @[TestWith( {AMIME::Header::Date.new("date", Time.utc)}, {AMIME::Header::MailboxList.new("from", [AMIME::Address.new "me@example.com"])}, {AMIME::Header::MailboxList.new("to", [AMIME::Address.new "me@example.com"])}, {AMIME::Header::MailboxList.new("cc", [AMIME::Address.new "me@example.com"])}, {AMIME::Header::MailboxList.new("bcc", [AMIME::Address.new "me@example.com"])}, {AMIME::Header::MailboxList.new("reply-to", [AMIME::Address.new "me@example.com"])}, {AMIME::Header::Path.new("return-path", AMIME::Address.new "me@example.com")}, {AMIME::Header::Mailbox.new("sender", AMIME::Address.new "me@example.com")}, {AMIME::Header::Identification.new("message-id", "some@id")}, {AMIME::Header::Identification.new("in-reply-to", "some@id")}, {AMIME::Header::Identification.new("references", "some@id")}, {AMIME::Header::Unstructured.new("in-reply-to", "some@id")}, {AMIME::Header::Unstructured.new("references", "some@id")}, {AMIME::Header::Unstructured.new("x-foo", "bar")}, # Handles custom headers )] def test_check_header_class_valid(header : AMIME::Header::Interface) : Nil AMIME::Header::Collection.check_header_class header end def test_check_header_class_invalid : Nil expect_raises AMIME::Exception::Logic, "The 'date' header must be an instance of 'Athena::MIME::Header::Date' (got 'Athena::MIME::Header::Unstructured')." do AMIME::Header::Collection.check_header_class AMIME::Header::Unstructured.new "date", "blah" end end def test_to_a : Nil headers = AMIME::Header::Collection.new headers.add_text_header "foo", "bar" headers.add_text_header "", "" headers.to_a.should eq ["foo: bar"] end def test_names : Nil headers = AMIME::Header::Collection.new headers.add_text_header "foo", "bar" headers.add_text_header "biz", "baz" headers.names.should eq ["foo", "biz"] end def test_all_no_args : Nil headers = AMIME::Header::Collection.new headers.add_text_header "foo", "bar" headers.add_text_header "biz", "baz" names = [] of String headers.all do |header| names << header.name end names.should eq ["foo", "biz"] end def test_all_specific_name : Nil headers = AMIME::Header::Collection.new headers.add_text_header "text", "bar" headers.add_text_header "text", "baz" values = [] of String headers.all "text" do |header| values << header.body.to_s end values.should eq ["bar", "baz"] end def test_untyped : Nil headers = AMIME::Header::Collection.new headers.add_date_header "date", Time.utc headers["DATE"].should_not be_nil end def test_untyped_multiple : Nil headers = AMIME::Header::Collection.new text1 = AMIME::Header::Unstructured.new "text", "1" text2 = AMIME::Header::Unstructured.new "text", "2" headers << text1 headers << text2 headers["text"].should be text1 end def test_untyped_missing_name : Nil headers = AMIME::Header::Collection.new expect_raises AMIME::Exception::HeaderNotFound, "No headers with the name 'foo' exist." do headers["foo"] end end def test_typed_missing_name : Nil headers = AMIME::Header::Collection.new expect_raises AMIME::Exception::HeaderNotFound, "No headers with the name 'foo' exist." do headers["foo", AMIME::Header::Date] end end def test_nilable_untyped : Nil headers = AMIME::Header::Collection.new headers.add_date_header "date", Time.utc headers["DATE"]?.should_not be_nil end def test_nilable_untyped_multiple : Nil headers = AMIME::Header::Collection.new text1 = AMIME::Header::Unstructured.new "text", "1" text2 = AMIME::Header::Unstructured.new "text", "2" headers << text1 headers << text2 headers["text"]?.should be text1 end def test_nilable_untyped_missing_name : Nil headers = AMIME::Header::Collection.new headers["foo"]?.should be_nil end def test_nilable_typed_missing_name : Nil headers = AMIME::Header::Collection.new headers["foo", AMIME::Header::Date]?.should be_nil end def test_set_unique_header : Nil headers = AMIME::Header::Collection.new headers.add_date_header "date", Time.utc expect_raises AMIME::Exception::Logic, "Cannot set header 'date' as it is already defined and must be unique." do headers.add_date_header "date", Time.utc end end def test_header_parameter : Nil headers = AMIME::Header::Collection.new headers.add_parameterized_header "content-type", "text/plain", {"charset" => "UTF-8"} headers.header_parameter("content-type", "charset").should eq "UTF-8" end def test_header_parameter_non_parameterized_header : Nil headers = AMIME::Header::Collection.new headers.add_text_header "foo", "bar" expect_raises AMIME::Exception::Logic, "Unable to get parameter 'param' on header 'foo' as the header is not of class 'Athena::MIME::Header::Parameterized'." do headers.header_parameter "foo", "param" end end def test_set_header_parameter_non_parameterized_header : Nil headers = AMIME::Header::Collection.new headers.add_text_header "foo", "bar" expect_raises AMIME::Exception::Logic, "Unable to set parameter 'param' on header 'foo' as the header is not of class 'Athena::MIME::Header::Parameterized'." do headers.header_parameter "foo", "param", "value" end end end ================================================ FILE: src/components/mime/spec/header/date_spec.cr ================================================ require "../spec_helper" struct DateHeaderTest < ASPEC::TestCase def test_happy_path : Nil header = AMIME::Header::Date.new "date", now = Time.utc header.body.should eq now later = Time.utc + 1.week header.body = later header.body.should eq later end def test_body_to_s : Nil AMIME::Header::Date .new("date", Time.utc 2025, 1, 3, 0, 16, 15) .to_s.should eq "date: Fri, 3 Jan 2025 00:16:15 +0000" end end ================================================ FILE: src/components/mime/spec/header/identification_spec.cr ================================================ require "../spec_helper" struct IdentificationHeaderTest < ASPEC::TestCase def test_happy_path : Nil AMIME::Header::Identification .new("message-id", "id-left@id-right") .body_to_s .should eq "" end def test_can_be_retrieved_verbatim : Nil AMIME::Header::Identification .new("message-id", "id-left@id-right") .id .should eq "id-left@id-right" end def test_can_have_multiple_ids : Nil header = AMIME::Header::Identification.new("references", "c@d") header.ids = ["a@b", "x@y"] header.ids.should eq ["a@b", "x@y"] end def test_multiple_ids_produces_list_value : Nil header = AMIME::Header::Identification.new("references", ["a@b", "x@y"]) header.body_to_s.should eq " " end def test_left_id_can_be_quoted : Nil header = AMIME::Header::Identification.new("references", %("ab"@c)) header.id.should eq %("ab"@c) header.body_to_s.should eq %(<"ab"@c>) end def test_left_id_can_contain_angles_as_quoted_pair : Nil header = AMIME::Header::Identification.new("references", %("a\\<\\>b"@c)) header.id.should eq %("a\\<\\>b"@c) header.body_to_s.should eq %(<"a\\<\\>b"@c>) end def test_left_id_can_be_dot_atom : Nil header = AMIME::Header::Identification.new("references", %(a.b+&%$.c@d)) header.id.should eq %(a.b+&%$.c@d) header.body_to_s.should eq %() end # TODO: Implement when email is validated # def test_invalid_left : Nil # end # def test_invalid_right : Nil # end # def test_invalid_missing_at : Nil # end def test_right_id_can_be_dot_atom : Nil header = AMIME::Header::Identification.new("references", %(a@b.c+&%$.d)) header.id.should eq %(a@b.c+&%$.d) header.body_to_s.should eq %() end def test_right_id_can_be_literal : Nil header = AMIME::Header::Identification.new("references", %(a@[1.2.3.4])) header.id.should eq %(a@[1.2.3.4]) header.body_to_s.should eq %() end def test_right_id_is_idn_encoded : Nil header = AMIME::Header::Identification.new("references", "a@ä") header.id.should eq "a@ä" header.body_to_s.should eq "" end def test_set_body : Nil header = AMIME::Header::Identification.new("references", "a@b") header.body = "d@f" header.ids.should eq ["d@f"] end def test_get_body : Nil header = AMIME::Header::Identification.new("references", "a@b") header.body = "d@f" header.body.should eq ["d@f"] end def test_to_s : Nil AMIME::Header::Identification .new("references", ["a@b", "x@y"]) .to_s.should eq "references: " end end ================================================ FILE: src/components/mime/spec/header/mailbox_list_spec.cr ================================================ require "../spec_helper" struct MailboxListHeaderTest < ASPEC::TestCase def test_mailbox_is_set_for_address : Nil AMIME::Header::MailboxList .new("from", [AMIME::Address.new "me@example.com"]) .address_strings .should eq ["me@example.com"] end def test_mailbox_is_set_for_named_address : Nil AMIME::Header::MailboxList .new("from", [AMIME::Address.new "me@example.com", "Jon Sno"]) .address_strings .should eq ["Jon Sno "] end def test_body_to_s : Nil AMIME::Header::MailboxList .new("from", [AMIME::Address.new "me@example.com", "Jon Sno"]) .body_to_s .should eq "Jon Sno " end def test_body_to_s_multiple : Nil AMIME::Header::MailboxList .new("from", [AMIME::Address.new("me@example.com", "Jon Sno"), AMIME::Address.new("you@example.com", "Jon Smith")]) .body_to_s .should eq "Jon Sno , Jon Smith " end def test_to_s_multiple : Nil AMIME::Header::MailboxList .new("from", [AMIME::Address.new("me@example.com", "Jon Sno"), AMIME::Address.new("you@example.com", "Jon Smith")]) .to_s .should eq "from: Jon Sno , Jon Smith " end def test_addresses : Nil AMIME::Header::MailboxList .new("from", [AMIME::Address.new "me@example.com", %(Jon Sno, "with love")]) .address_strings .should eq [%("Jon Sno, \\"with love\\"" )] end def test_quotes_escaped_chars : Nil AMIME::Header::MailboxList .new("from", [AMIME::Address.new "me@example.com", %(Jon Sno, \\escaped\\)]) .address_strings .should eq [%("Jon Sno, \\\\escaped\\\\" )] end def test_quotes_paren : Nil AMIME::Header::MailboxList .new("from", [AMIME::Address.new "me@example.com", %(Jon (Sno))]) .address_strings .should eq [%("Jon (Sno)" )] end def test_utf8_in_domain : Nil AMIME::Header::MailboxList .new("from", [AMIME::Address.new "me@fußball.com"]) .address_strings .should eq ["me@xn--fuball-cta.com"] end def test_utf8_in_local_part : Nil AMIME::Header::MailboxList .new("from", [AMIME::Address.new "fußball@example.com"]) .address_strings .should eq ["fußball@example.com"] end def test_multiple_addresses : Nil AMIME::Header::MailboxList .new("from", [AMIME::Address.new("me@example.com"), AMIME::Address.new("you@example.com")]) .address_strings .should eq ["me@example.com", "you@example.com"] end def test_encoded_non_ascii : Nil header = AMIME::Header::MailboxList.new("sender", [AMIME::Address.new "me@example.com", %(Jon S\x8F o)]) header.charset = "iso-8859-1" header.address_strings.should eq [%(Jon =?iso-8859-1?Q?S=8F?= o )] end def test_body : Nil header = AMIME::Header::MailboxList.new("from", [AMIME::Address.new "me@example.com", "Jon Sno"]) header.body = addresses = [AMIME::Address.new "you@example.com", "Jon Smith"] header.body.should eq addresses end end ================================================ FILE: src/components/mime/spec/header/mailbox_spec.cr ================================================ require "../spec_helper" struct MailboxHeaderTest < ASPEC::TestCase def test_happy_path : Nil header = AMIME::Header::Mailbox.new "sender", address = AMIME::Address.new "me@example.com" header.body.should eq address other_address = AMIME::Address.new "you@example.com" header.body = other_address header.body.should eq other_address end def test_body_to_s_no_name : Nil header = AMIME::Header::Mailbox.new("sender", AMIME::Address.new "me@example.com") header.body_to_s.should eq "me@example.com" header.body = AMIME::Address.new "me@fußball.com" header.body_to_s.should eq "me@xn--fuball-cta.com" end def test_body_to_s_with_name : Nil AMIME::Header::Mailbox .new("sender", AMIME::Address.new "me@example.com", "Jon Sno") .body_to_s .should eq "Jon Sno " end def test_body_to_s_with_quoted_name : Nil AMIME::Header::Mailbox .new("sender", AMIME::Address.new "me@example.com", %(Jon Sno, "with love")) .body_to_s .should eq %("Jon Sno, \\"with love\\"" ) end def test_body_to_s_with_escaped : Nil AMIME::Header::Mailbox .new("sender", AMIME::Address.new "me@example.com", %(Jon Sno, \\escaped\\)) .body_to_s .should eq %("Jon Sno, \\\\escaped\\\\" ) end def test_body_to_s_with_encoded_byte : Nil header = AMIME::Header::Mailbox.new("sender", AMIME::Address.new "me@example.com", %(Jon S\x8F o)) header.charset = "iso-8859-1" header.body_to_s.should eq %(Jon =?iso-8859-1?Q?S=8F?= o ) end def test_utf8_chars_in_local_part : Nil AMIME::Header::Mailbox .new("sender", AMIME::Address.new "fußball@example.com") .body_to_s .should eq "fußball@example.com" end def test_utf8_chars_in_local_part_name_with_space : Nil AMIME::Header::Mailbox .new("sender", AMIME::Address.new "fußball@example.com", "fußball fußball") .body_to_s .should eq "=?UTF-8?Q?fu=C3=9Fball_fu=C3=9Fball?= " end def test_utf8_chars_in_local_part_name_with_double_space : Nil AMIME::Header::Mailbox .new("sender", AMIME::Address.new "fußball@example.com", "fußball fußball") .body_to_s .should eq "=?UTF-8?Q?fu=C3=9Fball?= =?UTF-8?Q?fu=C3=9Fball?= " end def test_to_s_address_only : Nil AMIME::Header::Mailbox .new("sender", AMIME::Address.new "me@example.com") .to_s .should eq "sender: me@example.com" end def test_to_s_address_name : Nil AMIME::Header::Mailbox .new("sender", AMIME::Address.new "me@example.com", "Jon Sno") .to_s .should eq "sender: Jon Sno " end end ================================================ FILE: src/components/mime/spec/header/parameterized_spec.cr ================================================ require "../spec_helper" struct ParameterizedHeaderTest < ASPEC::TestCase @lang = "en-us" def test_value_is_returned_verbatim : Nil header = AMIME::Header::Parameterized.new "content-type", "text/plain" header.body.should eq "text/plain" end def test_parameters_are_appended : Nil header = AMIME::Header::Parameterized.new "content-type", "text/plain" header["charset"] = "UTF-8" header.body_to_s.should eq "text/plain; charset=UTF-8" end def test_space_in_param_results_in_quoted_string : Nil header = AMIME::Header::Parameterized.new "content-type", "attachment" header["filename"] = "my file.txt" header.body_to_s.should eq "attachment; filename=\"my file.txt\"" end def test_form_data_results_in_quoted_string : Nil header = AMIME::Header::Parameterized.new "content-disposition", "form-data" header["filename"] = "file.txt" header.body_to_s.should eq "form-data; filename=\"file.txt\"" end def test_form_data_utf8 : Nil header = AMIME::Header::Parameterized.new "content-disposition", "form-data" header["filename"] = "déjà%\"\n\r.txt" header.body_to_s.should eq "form-data; filename=\"déjà%%22%0A%0D.txt\"" end def test_long_params_are_broken_into_multiple_attribute_strings : Nil value = "a" * 180 header = AMIME::Header::Parameterized.new "content-disposition", "attachment" header["filename"] = value header.body_to_s.should eq( "attachment; " \ "filename*0*=UTF-8''#{"a" * 60};\r\n " \ "filename*1*=#{"a" * 60};\r\n " \ "filename*2*=#{"a" * 60}" ) end def test_encoded_param_data_includes_charset_and_language : Nil value = %(#{"a" * 20}\x8F#{"a" * 10}) header = AMIME::Header::Parameterized.new "content-disposition", "attachment" header.charset = "iso-8859-1" header.body = "attachment" header["filename"] = value header.lang = @lang header.body_to_s.should eq "attachment; filename*=iso-8859-1'en-us'aaaaaaaaaaaaaaaaaaaa%8Faaaaaaaaaa" end def test_multiple_encoded_param_lines_are_formatted_correctly : Nil value = %(#{"a" * 20}\x8F#{"a" * 60}) header = AMIME::Header::Parameterized.new "content-disposition", "attachment" header.charset = "UTF-6" header.body = "attachment" header["filename"] = value header.lang = @lang header.body_to_s.should eq "attachment; filename*0*=UTF-6'en-us'aaaaaaaaaaaaaaaaaaaa%8Faaaaaaaaaaaaaaaaaaaaaaa;\r\n filename*1*=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" end def test_to_s : Nil header = AMIME::Header::Parameterized.new "content-type", "text/html" header["charset"] = "UTF-8" header.to_s.should eq "content-type: text/html; charset=UTF-8" end def test_value_can_be_encoded_if_not_ascii : Nil value = "go\x8Fbar" header = AMIME::Header::Parameterized.new "x-foo", value header.charset = "iso-8859-1" header["lookslike"] = "foobar" header.to_s.should eq "x-foo: =?iso-8859-1?Q?go=8Fbar?=; lookslike=foobar" end def test_value_and_param_can_be_encoded_if_not_ascii : Nil value = "go\x8Fbar" header = AMIME::Header::Parameterized.new "x-foo", value header.charset = "iso-8859-1" header["says"] = value header.to_s.should eq "x-foo: =?iso-8859-1?Q?go=8Fbar?=; says*=iso-8859-1''go%8Fbar" end def test_param_are_encoded_if_not_ascii : Nil value = "go\x8Fbar" header = AMIME::Header::Parameterized.new "x-foo", "bar" header.charset = "iso-8859-1" header["says"] = value header.to_s.should eq "x-foo: bar; says*=iso-8859-1''go%8Fbar" end def test_params_are_encoded_with_legacy_encoding_enabled : Nil value = "go\x8Fbar" header = AMIME::Header::Parameterized.new "content-type", "bar" header.charset = "iso-8859-1" header["says"] = value header.to_s.should eq %(content-type: bar; says="=?iso-8859-1?Q?go=8Fbar?=") end def test_language_information_appears_in_encoded_words : Nil value = "go\x8Fbar" header = AMIME::Header::Parameterized.new "x-foo", value header.charset = "iso-8859-1" header.lang = "en" header["says"] = value header.to_s.should eq "x-foo: =?iso-8859-1*en?Q?go=8Fbar?=; says*=iso-8859-1'en'go%8Fbar" end def test_set_body : Nil header = AMIME::Header::Parameterized.new "content-type", "text/html" header.body = "text/plain" header.body.should eq "text/plain" end end ================================================ FILE: src/components/mime/spec/header/path_spec.cr ================================================ require "../spec_helper" struct PathHeaderTest < ASPEC::TestCase def test_happy_path : Nil header = AMIME::Header::Path.new "return-path", address = AMIME::Address.new "me@example.com" header.body.should eq address address = AMIME::Address.new "you@example.com" header.body = address header.body.should eq address end # def test_raises_if_invalid_address : Nile # end def test_body_to_s : Nil AMIME::Header::Path .new("return-path", AMIME::Address.new "me@example.com") .body_to_s.should eq "" end def test_body_to_s_utf8_chars_in_local_part : Nil AMIME::Header::Path .new("return-path", AMIME::Address.new "chrïs@example.com") .body_to_s.should eq "" end def test_body_to_s_idn_encoded_if_needed : Nil AMIME::Header::Path .new("return-path", AMIME::Address.new "test@fußball.test") .body_to_s.should eq "" end def test_to_s : Nil AMIME::Header::Path .new("return-path", AMIME::Address.new "me@example.com") .to_s.should eq "return-path: " end end ================================================ FILE: src/components/mime/spec/header/unstructured_spec.cr ================================================ require "../spec_helper" struct UnstructuredHeaderTest < ASPEC::TestCase def test_name : Nil AMIME::Header::Unstructured .new("subject", "") .name .should eq "subject" end def test_body : Nil header = AMIME::Header::Unstructured.new "foo", "bar" header.body.should eq "bar" header.body = "baz" header.body.should eq "baz" end def test_to_s : Nil AMIME::Header::Unstructured .new("subject", "content") .to_s .should eq "subject: content" end def test_to_s_long_lines : Nil AMIME::Header::Unstructured .new("x-custom-header", "The quick brown fox jumped over the fence, he was a very very scary brown fox with a bushy tail") .to_s .should eq <<-TXT x-custom-header: The quick brown fox jumped over the fence, he was a very\r very scary brown fox with a bushy tail TXT end def test_only_printable_ascii_appears_in_headers : Nil AMIME::Header::Unstructured .new("x-test", "\x8F") .to_s .should match /[^:\x00-\x20\x80-\xFF]+: [^\x80-\xFF\r\n]+$/ end def test_follows_general_structure : Nil AMIME::Header::Unstructured .new("x-test", "\x8F") .to_s .should match /^x-test: \=?.*?\?.*?\?.*?\?=$/ end def test_encoded_words_include_charset_and_encoding : Nil header = AMIME::Header::Unstructured.new("x-test", "\x8F") header.charset = "iso-8859-1" header .to_s .should eq "x-test: =?iso-8859-1?Q?=8F?=" end def test_encoded_words_are_used_to_represent_non_printable_ascii : Nil # Allows SPACE and TAB non_printable_bytes = [] of UInt8 non_printable_bytes.concat (0x00_u8..0x08).to_a non_printable_bytes.concat (0x10_u8..0x19).to_a non_printable_bytes << 0x7F_u8 non_printable_bytes.each do |byte| char = String.build(&.write_byte(byte)) encoded_char = sprintf "=%02X", byte AMIME::Header::Unstructured .new("x-test", char) .to_s .should eq "x-test: =?UTF-8?Q?#{encoded_char}?=" end end def test_encoded_words_are_used_to_encode8_bit_octets : Nil (0x80_u8..0xFF).each do |byte| char = String.build(&.write_byte(byte)) encoded_char = sprintf "=%02X", byte header = AMIME::Header::Unstructured.new("x-test", char) header.charset = "iso-8859-1" header.to_s.should eq "x-test: =?iso-8859-1?Q?#{encoded_char}?=" end end def test_are_no_longer_than_75_chars_per_line : Nil non_ascii_char = String.build(&.write_byte(143_u8)) header = AMIME::Header::Unstructured.new("x-test", non_ascii_char) header.charset = "iso-8859-1" header.to_s.should eq "x-test: =?iso-8859-1?Q?=8F?=" end def test_fwsp_is_used_when_encoder_returns_multiple_lines : Nil header = AMIME::Header::Unstructured.new "x-test", "\x8Fline_one_here\r\nline_two_here" header.charset = "iso-8859-1" header.to_s.should eq "x-test: =?iso-8859-1?Q?=8Fline=5Fone=5Fhere?=\r\n =?iso-8859-1?Q?line=5Ftwo=5Fhere?=" end def test_language_information_appears_in_encoded_words : Nil header = AMIME::Header::Unstructured.new "subject", "go\x8Fbar" header.charset = "iso-8859-1" header.lang = "en" header.to_s.should eq "subject: =?iso-8859-1*en?Q?go=8Fbar?=" end end ================================================ FILE: src/components/mime/spec/magic_types_guesser_spec.cr ================================================ require "./abstract_types_guesser_test_case" require "./spec_helper" struct MagicTypesGuesserTest < AbstractTypesGuesserTestCase protected def guesser : AMIME::TypesGuesserInterface AMIME::MagicTypesGuesser.new end def test_guess_with_known_extension : Nil assert_pending self.guesser.guess_mime_type("#{__DIR__}/fixtures/mimetypes/test.gif").should eq "image/gif" end def test_guess_with_leading_dash : Nil assert_pending self.guesser.guess_mime_type("#{__DIR__}/fixtures/mimetypes/-test").should eq "image/gif" end def test_guess_without_extension : Nil assert_pending self.guesser.guess_mime_type("#{__DIR__}/fixtures/mimetypes/test").should eq "image/gif" end def test_guess_with_unknown_extension : Nil assert_pending self.guesser.guess_mime_type("#{__DIR__}/fixtures/mimetypes/.unknownextension").should eq "application/octet-stream" end def test_guess_with_duplicated_file_type : Nil assert_pending self.guesser.guess_mime_type("#{__DIR__}/fixtures/test.docx").should eq "application/vnd.openxmlformats-officedocument.wordprocessingml.document" end private def assert_pending : Nil pending! "Guesser is not supported" if {{ flag?("windows") && !flag?("gnu") }} end end ================================================ FILE: src/components/mime/spec/message_converter_spec.cr ================================================ require "./spec_helper" struct MessageConverterTest < ASPEC::TestCase def test_to_email_email_argument : Nil email = self.new_email AMIME::MessageConverter.to_email(email).should be email end def test_requires_conversion : Nil file = File.read "#{__DIR__}/fixtures/mimetypes/test.gif" self.assert_conversion(new_email.text("text content")) self.assert_conversion(new_email.html(%(HTML content ))) self.assert_conversion(new_email.text("text content").html(%(HTML content ))) self.assert_conversion(new_email .text("text content") .html(%(HTML content )) .add_part(AMIME::Part::Data.new(file, "test.gif", "image/gif").as_inline) ) self.assert_conversion(new_email .text("text content") .html(%(HTML content )) .add_part(AMIME::Part::Data.new(file, "test_attached.gif", "image/gif")) ) self.assert_conversion(new_email .text("text content") .html(%(HTML content )) .add_part(AMIME::Part::Data.new(file, "test.gif", "image/gif").as_inline) .add_part(AMIME::Part::Data.new(file, "test_attached.gif", "image/gif")) ) self.assert_conversion(new_email .text("text content") .add_part(AMIME::Part::Data.new(file, "test_attached.gif", "image/gif")) ) self.assert_conversion(new_email .html(%(HTML content )) .add_part(AMIME::Part::Data.new(file, "test_attached.gif", "image/gif")) ) self.assert_conversion(new_email .html(%(HTML content )) .add_part(AMIME::Part::Data.new(file, "test_attached.gif", "image/gif").as_inline) ) self.assert_conversion(new_email .text("text content") .add_part(AMIME::Part::Data.new(file, "test_attached.gif", "image/gif").as_inline) ) end private def assert_conversion(expected : AMIME::Email) : Nil message = AMIME::Message.new expected.headers, expected.generate_body converted = AMIME::MessageConverter.to_email message if html_body = expected.html_body html_body.should match /HTML content / expected.html "html content" converted.html "html content" end pointerof(expected.@cached_body).value = nil pointerof(converted.@cached_body).value = nil converted.should eq expected end private def new_email : AMIME::Email AMIME::Email.new.from("me@example.com").to("you@example.com") end end ================================================ FILE: src/components/mime/spec/message_spec.cr ================================================ require "./spec_helper" struct MessageTest < ASPEC::TestCase def test_construct : Nil m = AMIME::Message.new m.body.should be_nil m.headers.should eq AMIME::Header::Collection.new m = AMIME::Message.new( headers = AMIME::Header::Collection.new.tap(&.add_date_header("date", Time.utc)), body = AMIME::Part::Text.new("content"), ) m.headers.should be headers m.body.should be body m = AMIME::Message.new m.body = body m.headers = headers m.headers.should be headers m.body.should be body end def test_raises_when_no_from : Nil expect_raises AMIME::Exception::Logic, "An email must have a 'from' or a 'sender' header." do AMIME::Message.new.prepared_headers end end def test_prepared_headers_uses_sender_if_present_but_no_from : Nil m = AMIME::Message.new m.headers.add_mailbox_header "sender", "sender@example.com" m.prepared_headers["from"].should eq AMIME::Header::MailboxList.new "from", [AMIME::Address.new "sender@example.com"] end def test_prepared_headers_clone_headers : Nil m = AMIME::Message.new m.headers.add_mailbox_list_header "from", ["me@example.com"] m.headers.should_not be m.prepared_headers end def test_prepared_headers_sets_required_headers : Nil m = AMIME::Message.new m.headers.add_mailbox_list_header "from", ["me@example.com"] m.headers.add_mailbox_list_header "bcc", ["spy@example.com"] headers = m.prepared_headers headers.has_key?("mime-version").should be_true headers.has_key?("message-id").should be_true headers.has_key?("date").should be_true headers.has_key?("bcc").should be_false end def test_prepared_headers : Nil m = AMIME::Message.new m.headers.add_mailbox_list_header "from", ["me@example.com"] m.headers.add_date_header "date", now = Time.utc headers = m.prepared_headers headers.all.size.should eq 4 headers["from"].should eq AMIME::Header::MailboxList.new "from", [AMIME::Address.new "me@example.com"] headers["mime-version"].should eq AMIME::Header::Unstructured.new "mime-version", "1.0" headers["date"].should eq AMIME::Header::Date.new "date", now end def test_prepared_headers_named_from : Nil m = AMIME::Message.new m.headers.add_mailbox_list_header "from", [AMIME::Address.new "me@example.com", "Me"] headers = m.prepared_headers headers["from"].should eq AMIME::Header::MailboxList.new "from", [AMIME::Address.new "me@example.com", "Me"] end def test_prepared_headers_has_sender_when_needed : Nil m = AMIME::Message.new m.headers.add_mailbox_list_header "from", ["me@example.com"] m.prepared_headers.has_key?("sender").should be_false m = AMIME::Message.new m.headers.add_mailbox_list_header "from", ["me@example.com", "other@example.com"] m.prepared_headers["sender", AMIME::Header::Mailbox].body.address.should eq "me@example.com" m = AMIME::Message.new m.headers.add_mailbox_list_header "from", ["me@example.com", "other@example.com"] m.headers.add_mailbox_header "sender", "other@example.com" m.prepared_headers["sender", AMIME::Header::Mailbox].body.address.should eq "other@example.com" end def test_generate_message_id_raises_no_addresses : Nil expect_raises AMIME::Exception::Logic, "A 'from' header must have at least one email address." do m = AMIME::Message.new m.headers.add_mailbox_list_header "from", [] of String m.generate_message_id end end def test_generate_message_id_raises_no_from_or_sender : Nil expect_raises AMIME::Exception::Logic, "An email must have a 'from' or 'sender' header." do AMIME::Message.new.generate_message_id end end def test_to_s_no_content : Nil m = AMIME::Message.new m.headers.add_mailbox_list_header "from", ["me@example.com"] m.headers.add_date_header "date", Time.utc(2025, 1, 1, 12, 30) m.headers.add_id_header "message-id", "MESSAGE_ID" m.to_s.should eq <<-TXT from: me@example.com\r date: Wed, 1 Jan 2025 12:30:00 +0000\r message-id: \r mime-version: 1.0\r content-type: text/plain; charset=UTF-8\r content-transfer-encoding: quoted-printable\r \r TXT end def test_to_s_with_content : Nil m = AMIME::Message.new body: AMIME::Part::Text.new("text content") m.headers.add_mailbox_list_header "from", ["me@example.com"] m.headers.add_date_header "date", Time.utc(2025, 1, 1, 12, 30) m.headers.add_id_header "message-id", "MESSAGE_ID" m.to_s.should eq <<-TXT from: me@example.com\r date: Wed, 1 Jan 2025 12:30:00 +0000\r message-id: \r mime-version: 1.0\r content-type: text/plain; charset=UTF-8\r content-transfer-encoding: quoted-printable\r \r text content TXT end def test_ensure_validity_valid : Nil m = AMIME::Message.new m.headers.add_mailbox_list_header "from", ["me@example.com"] m.headers.add_mailbox_list_header "to", ["you@example.com"] m.ensure_validity! end @[TestWith( { {"from" => ["me@example.com"]}, AMIME::Exception::Logic, "An email must have a 'to', 'cc', or 'bcc' header." }, { {"from" => ["me@example.com"], "cc" => [] of String}, AMIME::Exception::Logic, "An email must have a 'to', 'cc', or 'bcc' header." }, { {"from" => ["me@example.com"], "bcc" => [] of String}, AMIME::Exception::Logic, "An email must have a 'to', 'cc', or 'bcc' header." }, { {"to" => [] of String, "from" => ["me@example.com"]}, AMIME::Exception::Logic, "An email must have a 'to', 'cc', or 'bcc' header." }, { {"to" => ["you@example.com"]}, AMIME::Exception::Logic, "An email must have a 'from' or a 'sender' header." }, { {"to" => ["you@example.com"], "from" => [] of String}, AMIME::Exception::Logic, "An email must have a 'from' or a 'sender' header." }, )] def test_ensure_validity(headers : Hash(String, Array(String)), exception_class : ::Exception.class, exception_message : String) m = AMIME::Message.new headers.each do |k, v| m.headers.add_mailbox_list_header k, v end expect_raises exception_class, exception_message do m.ensure_validity! end end end ================================================ FILE: src/components/mime/spec/native_types_guessuer_spec.cr ================================================ require "./abstract_types_guesser_test_case" require "./spec_helper" struct NativeTypesGuesserTest < AbstractTypesGuesserTestCase protected def guesser : AMIME::TypesGuesserInterface AMIME::NativeTypesGuesser.new end def test_guess_with_leading_dash : Nil self.guesser.guess_mime_type("#{__DIR__}/fixtures/mimetypes/-test").should be_nil end def test_guess_without_extension : Nil self.guesser.guess_mime_type("#{__DIR__}/fixtures/mimetypes/test").should be_nil end def test_guess_with_unknown_extension : Nil self.guesser.guess_mime_type("#{__DIR__}/fixtures/mimetypes/.unknownextension").should be_nil end def test_guess_with_duplicated_file_type : Nil # The MIME DB on windows CI doesn't know about this type, but works elsewhere pending! "Guesser is not supported" if {{ flag?("windows") && !flag?("gnu") }} self.guesser.guess_mime_type("#{__DIR__}/fixtures/test.docx").should eq "application/vnd.openxmlformats-officedocument.wordprocessingml.document" end end ================================================ FILE: src/components/mime/spec/part/data_spec.cr ================================================ require "../spec_helper" struct DataPartTest < ASPEC::TestCase def test_constructor : Nil p = AMIME::Part::Data.new "content" p.body.should eq "content" p.body_to_s.should eq Base64.encode("content") p.media_type.should eq "application" p.media_sub_type.should eq "octet-stream" p = AMIME::Part::Data.new "content", content_type: "text/html" p.media_type.should eq "text" p.media_sub_type.should eq "html" end def test_constructor_io : Nil io = IO::Memory.new "content" p = AMIME::Part::Data.new io p.body.should eq "content" p.body_to_s.should eq Base64.encode("content") end def test_constructor_real_file : Nil File.open "#{__DIR__}/../fixtures/content.txt", "r" do |file| p = AMIME::Part::Data.new file p.body.should eq "content" p.body_to_s.should eq Base64.encode("content") end end def test_constructor_file_part : Nil p = AMIME::Part::Data.new(AMIME::Part::File.new("#{__DIR__}/../fixtures/content.txt")) p.body.should eq "content" p.body_to_s.should eq Base64.encode("content") end def test_prepared_headers : Nil AMIME::Part::Data .new("content") .prepared_headers .should eq AMIME::Header::Collection.new( AMIME::Header::Parameterized.new("content-type", "application/octet-stream"), AMIME::Header::Unstructured.new("content-transfer-encoding", "base64"), AMIME::Header::Parameterized.new("content-disposition", "attachment"), ) end def test_prepared_headers_image : Nil AMIME::Part::Data .new("content", "photo.jpg", "text/html") .prepared_headers .should eq AMIME::Header::Collection.new( AMIME::Header::Parameterized.new("content-type", "text/html", {"name" => "photo.jpg"}), AMIME::Header::Unstructured.new("content-transfer-encoding", "base64"), AMIME::Header::Parameterized.new("content-disposition", "attachment", {"name" => "photo.jpg", "filename" => "photo.jpg"}), ) end def test_prepared_headers_as_inline : Nil AMIME::Part::Data .new("content", "photo.jpg", "text/html") .as_inline .prepared_headers .should eq AMIME::Header::Collection.new( AMIME::Header::Parameterized.new("content-type", "text/html", {"name" => "photo.jpg"}), AMIME::Header::Unstructured.new("content-transfer-encoding", "base64"), AMIME::Header::Parameterized.new("content-disposition", "inline", {"name" => "photo.jpg", "filename" => "photo.jpg"}), ) end def test_prepared_headers_as_inline_with_cid : Nil part = AMIME::Part::Data.new("content", "photo.jpg", "text/html").as_inline content_id = part.content_id part .prepared_headers .should eq AMIME::Header::Collection.new( AMIME::Header::Parameterized.new("content-type", "text/html", {"name" => "photo.jpg"}), AMIME::Header::Unstructured.new("content-transfer-encoding", "base64"), AMIME::Header::Parameterized.new("content-disposition", "inline", {"name" => "photo.jpg", "filename" => "photo.jpg"}), AMIME::Header::Identification.new("content-id", content_id) ) end def test_from_path : Nil part = AMIME::Part::Data.from_path file = "#{__DIR__}/../fixtures/mimetypes/test.gif" content = File.read file part.body.should eq content part.body_to_s.should eq Base64.encode(content) part.media_type.should eq "image" part.media_sub_type.should eq "gif" part .prepared_headers .should eq AMIME::Header::Collection.new( AMIME::Header::Parameterized.new("content-type", "image/gif", {"name" => "test.gif"}), AMIME::Header::Unstructured.new("content-transfer-encoding", "base64"), AMIME::Header::Parameterized.new("content-disposition", "attachment", {"name" => "test.gif", "filename" => "test.gif"}), ) end def test_from_path_with_meta : Nil part = AMIME::Part::Data.from_path file = "#{__DIR__}/../fixtures/mimetypes/test.gif", "photo.gif", "image/jpeg" content = File.read file part.body.should eq content part.body_to_s.should eq Base64.encode(content) part.media_type.should eq "image" part.media_sub_type.should eq "jpeg" part .prepared_headers .should eq AMIME::Header::Collection.new( AMIME::Header::Parameterized.new("content-type", "image/jpeg", {"name" => "photo.gif"}), AMIME::Header::Unstructured.new("content-transfer-encoding", "base64"), AMIME::Header::Parameterized.new("content-disposition", "attachment", {"name" => "photo.gif", "filename" => "photo.gif"}), ) end def test_has_content_id : Nil part = AMIME::Part::Data.new "content" part.has_content_id?.should be_false part.content_id part.has_content_id?.should be_true end def test_set_content_id : Nil part = AMIME::Part::Data.new "content" part.content_id = "test@test" part.content_id.should eq "test@test" end def test_set_content_id_invalid : Nil expect_raises AMIME::Exception::InvalidArgument, "The 'test' CID is invalid as it does not contain an '@' symbol." do AMIME::Part::Data.new("content").content_id = "test" end end def test_filename : Nil part = AMIME::Part::Data.new "content" part.filename.should be_nil part = AMIME::Part::Data.new "content", "foo.txt" part.filename.should eq "foo.txt" end def test_content_type : Nil part = AMIME::Part::Data.new "content" part.content_type.should eq "application/octet-stream" part = AMIME::Part::Data.new "content", content_type: "application/pdf" part.content_type.should eq "application/pdf" end end ================================================ FILE: src/components/mime/spec/part/file_spec.cr ================================================ require "../spec_helper" struct FilePartTest < ASPEC::TestCase def test_content_type_known_extension : Nil AMIME::Part::File.new("#{__DIR__}/../fixtures/mimetypes/test.gif").content_type.should eq "image/gif" end def test_content_type_unknown_extension : Nil AMIME::Part::File.new("#{__DIR__}/../fixtures/mimetypes/.unknownextension").content_type.should eq "application/octet-stream" end def test_size : Nil AMIME::Part::File.new("#{__DIR__}/../fixtures/mimetypes/test.gif").size.should eq 35 end def test_file_name_inferred : Nil AMIME::Part::File.new("#{__DIR__}/../fixtures/mimetypes/test.gif").filename.should eq "test.gif" end def test_file_name_explicit : Nil AMIME::Part::File.new("#{__DIR__}/../fixtures/mimetypes/test.gif", "image.gif").filename.should eq "image.gif" end end ================================================ FILE: src/components/mime/spec/part/message_spec.cr ================================================ require "../spec_helper" struct MessagePartTest < ASPEC::TestCase def test_constructor : Nil part = AMIME::Part::Message.new AMIME::Email.new.from("me@example.com").to("you@example.com").text("text content") part.body.should contain "text content" part.body_to_s.should contain "text content" part.media_type.should eq "message" part.media_sub_type.should eq "rfc822" end def test_headers : Nil AMIME::Part::Message .new(AMIME::Email.new.from("me@example.com").text("text content").subject("subject")) .prepared_headers .should eq AMIME::Header::Collection.new( AMIME::Header::Parameterized.new("content-type", "message/rfc822", {"name" => "subject.eml"}), AMIME::Header::Unstructured.new("content-transfer-encoding", "base64"), AMIME::Header::Parameterized.new("content-disposition", "attachment", {"name" => "subject.eml", "filename" => "subject.eml"}), ) end end ================================================ FILE: src/components/mime/spec/part/multipart/alternative_spec.cr ================================================ require "../../spec_helper" struct AlternativePartTest < ASPEC::TestCase def test_constructor : Nil part = AMIME::Part::Multipart::Alternative.new part.media_type.should eq "multipart" part.media_sub_type.should eq "alternative" end end ================================================ FILE: src/components/mime/spec/part/multipart/digest_spec.cr ================================================ require "../../spec_helper" struct DigestPartTest < ASPEC::TestCase def test_constructor : Nil part = AMIME::Part::Multipart::Digest.new part.media_type.should eq "multipart" part.media_sub_type.should eq "digest" end end ================================================ FILE: src/components/mime/spec/part/multipart/form_spec.cr ================================================ require "../../spec_helper" struct FormPartTest < ASPEC::TestCase def test_constructor : Nil b = AMIME::Part::Text.new "content" c = AMIME::Part::Data.from_path "#{__DIR__}/../../fixtures/mimetypes/test.gif" part = AMIME::Part::Multipart::Form.new({ "foo" => content = "very very long content that will not be cut even if the length is way more than 76 characters, ok?", "bar" => b.dup, "baz" => c.dup, }) part.media_type.should eq "multipart" part.media_sub_type.should eq "form-data" t = AMIME::Part::Text.new content, encoding: "8bit" t.disposition = "form-data" t.name = "foo" t.headers.line_length = Int32::MAX b.disposition = "form-data" b.encoding = "8bit" b.name = "bar" b.headers.line_length = Int32::MAX c.disposition = "form-data" c.encoding = "8bit" c.name = "baz" c.headers.line_length = Int32::MAX part.parts.should eq [t, b, c] end def test_nested_array_parts : Nil p1 = AMIME::Part::Text.new "content", encoding: "8bit" part = AMIME::Part::Multipart::Form.new({ "foo" => p1.dup, "bar" => { "baz" => { "0" => p1.dup, "qux" => p1.dup, }, }, "2" => p1.dup, "quux" => [ p1.dup, p1.dup, ], }) part.media_type.should eq "multipart" part.media_sub_type.should eq "form-data" p1.name = "foo" p1.disposition = "form-data" p2 = p1.dup p2.name = "bar[baz][0]" p2.disposition = "form-data" p3 = p1.dup p3.name = "bar[baz][qux]" p3.disposition = "form-data" p4 = p1.dup p4.name = "2" p4.disposition = "form-data" p5 = p1.dup p5.name = "quux[0]" p5.disposition = "form-data" p6 = p1.dup p6.name = "quux[1]" p6.disposition = "form-data" part.parts.should eq [p1, p2, p3, p4, p5, p6] end def test_disallowed_value_type : Nil expect_raises AMIME::Exception::InvalidArgument, "The value of the form field 'foo[qux][quux]' can only be a String, Hash, Array, or AMIME::Part::Text instance, got 'Int32'." do AMIME::Part::Multipart::Form.new({ "foo" => { "bar" => "baz", "qux" => { "quux" => 1, }, }, }) end end def test_to_s : Nil p = AMIME::Part::Data.from_path file_path = "#{__DIR__}/../../fixtures/mimetypes/test.gif" p.body_to_s.should eq Base64.encode(File.read file_path) end def test_content_line_length : Nil part = AMIME::Part::Multipart::Form.new({ "foo" => AMIME::Part::Data.new(foo = "foo" * 1000, "foo.txt", "text/plain"), "bar" => bar = "bar" * 1000, }) part.parts[0].body_to_s.should eq foo part.parts[1].body_to_s.should eq bar end def test_boundary_content_type_header : Nil AMIME::Part::Multipart::Form.new({ "file" => AMIME::Part::Data.new("data.csv", "data.csv", "text/csv"), }) .prepared_headers .to_a .first .should match /^content-type: multipart\/form-data; boundary=[a-zA-Z0-9\-_]{50}$/ # 26 `-` + 18 bytes of base64 data end def test_body_to_s : Nil string_lines = AMIME::Part::Multipart::Form.new({ "file" => AMIME::Part::Data.new("data.csv", "data.csv", "text/csv"), }) .body_to_s .lines string_lines[0].should match /^[a-zA-Z0-9\-_]{50}$/ # 26 `-` + 18 bytes of base64 data string_lines[1].should eq "content-type: text/csv" string_lines[2].should eq "content-transfer-encoding: 8bit" string_lines[3].should eq %(content-disposition: form-data; name="file"; filename="data.csv") string_lines[4].should be_empty string_lines[5].should eq "data.csv" string_lines[6].should match /^[a-zA-Z0-9\-_]{50}$/ # 26 `-` + 18 bytes of base64 data end end ================================================ FILE: src/components/mime/spec/part/multipart/mixed_spec.cr ================================================ require "../../spec_helper" struct MixedPartTest < ASPEC::TestCase def test_constructor : Nil part = AMIME::Part::Multipart::Mixed.new part.media_type.should eq "multipart" part.media_sub_type.should eq "mixed" end end ================================================ FILE: src/components/mime/spec/part/multipart/related_spec.cr ================================================ require "../../spec_helper" struct RelatedPartTest < ASPEC::TestCase def test_constructor : Nil part = AMIME::Part::Multipart::Related.new( a = AMIME::Part::Text.new("text content"), { b = AMIME::Part::Text.new("html content", sub_type: "html"), c = AMIME::Part::Text.new("html content again", sub_type: "html"), } ) part.media_type.should eq "multipart" part.media_sub_type.should eq "related" part.parts.should eq [a, b, c] a.headers.has_key?("content-id").should be_false b.headers.has_key?("content-id").should be_true c.headers.has_key?("content-id").should be_true end def test_body_to_s body = AMIME::Part::Multipart::Related .new( AMIME::Part::Multipart::Alternative.new( AMIME::Part::Text.new("text content"), AMIME::Part::Text.new("html content", sub_type: "html") ), [] of AMIME::Part::Abstract ) .body_to_s body.should contain "text content" body.should contain "html content" end end ================================================ FILE: src/components/mime/spec/part/text_spec.cr ================================================ require "../spec_helper" struct TextPartTest < ASPEC::TestCase def test_constructor : Nil p = AMIME::Part::Text.new "content" p.body.should eq "content" p.body_to_s.should eq "content" p.media_type.should eq "text" p.media_sub_type.should eq "plain" p = AMIME::Part::Text.new "content", sub_type: "html" p.media_type.should eq "text" p.media_sub_type.should eq "html" end def test_constructor_io : Nil io = IO::Memory.new "content" p = AMIME::Part::Text.new io p.body.should eq "content" p.body_to_s.should eq "content" end def test_constructor_real_file : Nil File.open "#{__DIR__}/../fixtures/content.txt", "r" do |file| p = AMIME::Part::Text.new file p.body.should eq "content" p.body_to_s.should eq "content" end end def test_constructor_file_part : Nil p = AMIME::Part::Text.new(AMIME::Part::File.new("#{__DIR__}/../fixtures/content.txt")) p.body.should eq "content" p.body_to_s.should eq "content" end def test_constructor_unknown_file : Nil expect_raises AMIME::Exception::InvalidArgument, "File is not readable." do AMIME::Part::Text.new(AMIME::Part::File.new("#{__DIR__}/../fixtures/")).body end end def test_headers : Nil p = AMIME::Part::Text.new "content" p.prepared_headers.should eq AMIME::Header::Collection.new( AMIME::Header::Parameterized.new("content-type", "text/plain", {"charset" => "UTF-8"}), AMIME::Header::Unstructured.new("content-transfer-encoding", "quoted-printable"), ) p = AMIME::Part::Text.new "content", charset: "iso-8859-1" p.prepared_headers.should eq AMIME::Header::Collection.new( AMIME::Header::Parameterized.new("content-type", "text/plain", {"charset" => "iso-8859-1"}), AMIME::Header::Unstructured.new("content-transfer-encoding", "quoted-printable"), ) end def test_encoding : Nil p = AMIME::Part::Text.new "content", encoding: "base64" p.prepared_headers.should eq AMIME::Header::Collection.new( AMIME::Header::Parameterized.new("content-type", "text/plain", {"charset" => "UTF-8"}), AMIME::Header::Unstructured.new("content-transfer-encoding", "base64"), ) end def ptest_custom_encoder_needs_to_be_registered_first : Nil end def ptest_override_custom_encoder : Nil end def ptest_custom_encoder : Nil end end ================================================ FILE: src/components/mime/spec/spec_helper.cr ================================================ require "spec" require "athena-spec" require "../src/athena-mime" ASPEC.run_all ================================================ FILE: src/components/mime/spec/types_spec.cr ================================================ require "./abstract_types_guesser_test_case" require "./spec_helper" private struct MockGuesser include AMIME::TypesGuesserInterface def supported? : Bool false end def guess_mime_type(path : String | Path) : String? fail "Should not have been called" end end struct MIMETypesTest < AbstractTypesGuesserTestCase protected def guesser : AMIME::TypesGuesserInterface AMIME::Types.new end def test_supported : Nil self.guesser.supported?.should be_true end def test_no_supported_guessers_raise : Nil guesser = self.guesser guesser.@guessers.clear expect_raises AMIME::Exception::Logic, "Unable to guess the MIME type as no guessers are available." do guesser.guess_mime_type "#{__DIR__}/fixtures/mimetypes/test" end end def test_extensions : Nil types = AMIME::Types.new types.extensions("application/mbox").should eq({"mbox"}) types.extensions("application/postscript").should eq({"ai", "eps", "ps"}) types.extensions("image/svg+xml").should contain "svg" types.extensions("image/svg").should contain "svg" types.extensions("application/whatever-athena").should be_empty end def test_mime_types : Nil types = AMIME::Types.new types.mime_types("mbox").should eq({"application/mbox"}) types.mime_types("ai").should contain "application/postscript" types.mime_types("ps").should contain "application/postscript" types.mime_types("svg").should contain "image/svg+xml" types.mime_types("svg").should contain "image/svg" types.mime_types("athena").should be_empty end def test_custom_mimes_types : Nil types = AMIME::Types.new({ "text/bar" => {"foo"}, "text/baz" => {"foo", "moof"}, }) types.mime_types("foo").should contain "text/bar" types.mime_types("foo").should contain "text/baz" types.extensions("text/baz").should eq(["foo", "moof"]) end end ================================================ FILE: src/components/mime/src/address.cr ================================================ # Represents an email address with an optional name. struct Athena::MIME::Address private FROM_STRING_PATTERN = /(?[^<]*)<(?.*)>[^>]*/ protected class_getter encoder : AMIME::Encoder::AddressEncoderInterface do AMIME::Encoder::IDNAddress.new end # Returns the raw email address portion of this Address. # Use `#encoded_address` to get a safe representation for use in a MIME header. # # ``` # address = AMIME::Address.new "first.last@example.com", "First Last" # address.address # => "first.last@example.com" # ``` getter address : String # Returns the raw name portion of this Address, or an empty string if none was set. # Use `#encoded_name` to get a safe representation for use in a MIME header. # # ``` # address = AMIME::Address.new "first.last@example.com" # address.name # => "" # # address = AMIME::Address.new "first.last@example.com", "First Last" # address.name # => "First Last" # ``` getter name : String # Creates an array of `AMIME::Address` from the provided *addresses*. # # ``` # AMIME::Address.create_multiple "me@example.com", "Mr Smith ", AMIME::Address.new("you@example.com") # => # # [ # # Athena::MIME::Address(@address="me@example.com", @name=""), # # Athena::MIME::Address(@address="smith@example.com", @name="Mr Smith"), # # Athena::MIME::Address(@address="you@example.com", @name=""), # # ] # ``` def self.create_multiple(*addresses : self | String) : Array(self) self.create_multiple addresses end # Creates an array of `AMIME::Address` from the provided enumerable *addresses*. # # ``` # AMIME::Address.create_multiple({"me@example.com", "Mr Smith ", AMIME::Address.new("you@example.com")}) # => # # [ # # Athena::MIME::Address(@address="me@example.com", @name=""), # # Athena::MIME::Address(@address="smith@example.com", @name="Mr Smith"), # # Athena::MIME::Address(@address="you@example.com", @name=""), # # ] # ``` def self.create_multiple(addresses : Enumerable(self | String)) : Array(self) addresses.map do |a| self.create a end.to_a end # Creates a new `AMIME::Address`. # # If the *address* is already an `AMIME::Address`, it is returned as is. # Otherwise if it's a `String`, then attempt to parse the name and address from the provided string. def self.create(address : self | String) : self return address if address.is_a? self return new(address) unless address.includes? '<' unless match = address.match FROM_STRING_PATTERN raise AMIME::Exception::InvalidArgument.new "Could not parse '#{address}' to a '#{self}' instance." end new match["addrSpec"], match["displayName"].strip(" '\"") end # Creates a new `AMIME::Address` with the provided *address* and optionally *name*. def initialize(address : String, name : String = "") @address = address.strip @name = name.gsub(/\n|\r/, "", options: :no_utf_check).strip # TODO: Validate the email end # :nodoc: def_clone # Writes an encoded representation of this Address to the provided *io* for use in a MIME header. # # ``` # AMIME::Address.new "contact@athenï.org", "George").to_s # => "\"George\" " # ``` def to_s(io : IO) : Nil if name = self.encoded_name.presence return io << %("#{name}" <#{self.encoded_address}>) end io << self.encoded_address end # Returns an encoded representation of `#address` safe to use within a MIME header. # # ``` # AMIME::Address.new("contact@athenï.org").encoded_address # => "xn--athen-gta.org" # ``` def encoded_address : String self.class.encoder.encode @address end # Returns an encoded representation of `#name` safe to use within a MIME header. # # ``` # AMIME::Address.new("us@example.com", %(Me, "You)).encoded_name # => "Me, \"You" # ``` def encoded_name : String @name end # Returns `true` if this Address's localpart contains at least one non-ASCII character. # Otherwise returns `false`. # # ``` # AMIME::Address.new("info@dømi.com").has_unicode_local_part? # => false # AMIME::Address.new("dømi@dømi.com").has_unicode_local_part? # => true # ``` def has_unicode_local_part? : Bool local, _, _ = @address.partition '@' !local.ascii_only? end end ================================================ FILE: src/components/mime/src/athena-mime.cr ================================================ require "./address" require "./message" require "./email" require "./draft_email" require "./message_converter" require "./encoder/*" require "./exception/*" require "./header/*" require "./part/*" require "./part/multipart/*" require "./types" require "./magic_types_guesser" require "./native_types_guesser" # Convenience alias to make referencing `Athena::MIME` types easier. alias AMIME = Athena::MIME # Allows manipulating the MIME messages used to send emails and provides utilities related to MIME types. module Athena::MIME VERSION = "0.2.1" # Namespace for types related to encoding part of the MIME message. module Encoder; end # Both acts as a namespace for exceptions related to the `Athena::MIME` component, as well as a way to check for exceptions from the component. module Exception; end # Namespace for the types used to represent MIME headers. module Header; end # Namespace for the types used to represent the parts used to compose a MIME message. module Part # Namespace for Multipart related parts. module Multipart; end end end ================================================ FILE: src/components/mime/src/draft_email.cr ================================================ # Represent an un-sent `AMIME::Email` message. # # ``` # draft_email = AMIME::DraftEmail # .new # .to("you@example.com") # .subject("Important Notification") # .text("Lorem ipsum...") # # # ... # ``` class Athena::MIME::DraftEmail < Athena::MIME::Email def initialize( headers : AMIME::Header::Collection? = nil, body : AMIME::Part::Abstract? = nil, ) super @headers.add_text_header "x-unsent", "1" end # :inherit: def prepared_headers : AMIME::Header::Collection # Override default behavior as draft emails do not need from/sender/date/message-id headers. # These are added by the client that sends the email. headers = @headers.clone unless headers.has_key? "mime-version" headers.add_text_header "mime-version", "1.0" end headers.delete "bcc" headers end end ================================================ FILE: src/components/mime/src/email.cr ================================================ # Provides a high-level API for creating an email. # # ``` # email = AMIME::Email # .new # .from("me@example.com") # .to("you@example.com") # .cc("them@example.com") # .bcc("other@example.com") # .reply_to("me@example.com") # .priority(:high) # .subject("Important Notification") # .text("Lorem ipsum...") # .html("

Lorem ipsum

...

") # .attach_from_path("/path/to/file.pdf", "my-attachment.pdf") # .embed_from_path("/path/to/logo.png") # # # ... # ``` class Athena::MIME::Email < Athena::MIME::Message enum Priority HIGHEST = 1 HIGH NORMAL LOW LOWEST # :nodoc: def to_s : String "#{self.value} (#{super.titleize})" end end @text : IO | String | Nil = nil # Returns the charset of the `#text_body` for this email. getter text_charset : String? = nil @html : IO | String | Nil = nil # Returns the charset of the `#html_body` for this email. getter html_charset : String? = nil # Returns an array of `AMIME::Part::Data` representing the email's attachments. getter attachments : Array(AMIME::Part::Data) = Array(AMIME::Part::Data).new # Used to avoid wrong body hash in DKIM signatures with multiple parts (e.g. HTML + TEXT) due to multiple boundaries. @cached_body : AMIME::Part::Abstract? = nil # :nodoc: def ==(other : self) {% if @type.class? %} return true if same?(other) {% end %} {% for field in @type.instance_vars %} return false unless @{{field.id}} == other.@{{field.id}} {% end %} true end # Returns the subject of this email, or `nil` if none is set. def subject : String? if header = @headers["subject"]? return header.body.as String end end # Sets the subject of this email to the provided *subject*. def subject(subject : String) : self @headers.upsert "subject", subject, ->@headers.add_text_header(String, String) self end # Returns the date of this email, or `nil` if none is set. def date : Time? if header = @headers["date"]? return header.body.as Time end end # Sets the date of this email to the provided *date*. def date(date : Time) : self @headers.upsert "date", date, ->@headers.add_date_header(String, Time) self end # Returns the return path of this email, or `nil` if none is set. def return_path : AMIME::Address? if header = @headers["return-path"]? return header.body.as AMIME::Address end end # Sets the return path of this email to the provided *address*. def return_path(address : AMIME::Address | String) : self @headers.upsert "return-path", AMIME::Address.create(address), ->@headers.add_path_header(String, AMIME::Address) self end # Returns the sender of this email, or `nil` if none is set. def sender : AMIME::Address? if header = @headers["sender"]? return header.body.as AMIME::Address end end # Sets the sender of this email to the provided *address*. def sender(address : AMIME::Address | String) : self @headers.upsert "sender", AMIME::Address.create(address), ->@headers.add_mailbox_header(String, AMIME::Address) self end # Returns the from addresses of this email, or an empty array if none were set. def from : Array(AMIME::Address) if header = @headers["from"]? return header.body.as(Array(AMIME::Address)).dup end [] of AMIME::Address end # Sets the from addresses of this email to the provided *addresses*, overriding any previously added ones. def from(*addresses : AMIME::Address | String) : self self.set_list_address_header_body "from", addresses end # Appends the provided *addresses* to the list of current from addresses. def add_from(*addresses : AMIME::Address | String) : self self.add_list_address_header_body "from", addresses end # Returns the reply-to addresses of this email, or an empty array if none were set. def reply_to : Array(AMIME::Address) if header = @headers["reply-to"]? return header.body.as(Array(AMIME::Address)).dup end [] of AMIME::Address end # Sets the reply-to addresses of this email to the provided *addresses*, overriding any previously added ones. def reply_to(*addresses : AMIME::Address | String) : self self.set_list_address_header_body "reply-to", addresses end # Appends the provided *addresses* to the list of current reply-to addresses. def add_reply_to(*addresses : AMIME::Address | String) : self self.add_list_address_header_body "reply-to", addresses end # Returns the to addresses of this email, or an empty array if none were set. def to : Array(AMIME::Address) if header = @headers["to"]? return header.body.as(Array(AMIME::Address)).dup end [] of AMIME::Address end # Sets the to addresses of this email to the provided *addresses*, overriding any previously added ones. def to(*addresses : AMIME::Address | String) : self self.set_list_address_header_body "to", addresses end # Appends the provided *addresses* to the list of current to addresses. def add_to(*addresses : AMIME::Address | String) : self self.add_list_address_header_body "to", addresses end # Returns the cc addresses of this email, or an empty array if none were set. def cc : Array(AMIME::Address) if header = @headers["cc"]? return header.body.as(Array(AMIME::Address)).dup end [] of AMIME::Address end # Sets the cc addresses of this email to the provided *addresses*, overriding any previously added ones. def cc(*addresses : AMIME::Address | String) : self self.set_list_address_header_body "cc", addresses end # Appends the provided *addresses* to the list of current cc addresses. def add_cc(*addresses : AMIME::Address | String) : self self.add_list_address_header_body "cc", addresses end # Returns the bcc addresses of this email, or an empty array if none were set. def bcc : Array(AMIME::Address) if header = @headers["bcc"]? return header.body.as(Array(AMIME::Address)).dup end [] of AMIME::Address end # Sets the cc addresses of this email to the provided *addresses*, overriding any previously added ones. def bcc(*addresses : AMIME::Address | String) : self self.set_list_address_header_body "bcc", addresses end # Appends the provided *addresses* to the list of current bcc addresses. def add_bcc(*addresses : AMIME::Address | String) : self self.add_list_address_header_body "bcc", addresses end private def add_list_address_header_body(name : String, addresses : Enumerable(AMIME::Address | String)) : self unless header = @headers[name, AMIME::Header::MailboxList]? return self.set_list_address_header_body name, addresses end header.add_addresses AMIME::Address.create_multiple addresses self end private def set_list_address_header_body(name : String, addresses : Enumerable(AMIME::Address | String)) : self addresses = AMIME::Address.create_multiple addresses if header = @headers[name]? header.body = addresses else @headers.add_mailbox_list_header name, addresses end self end # Returns the priority of this email. def priority : AMIME::Email::Priority priority = (@headers.header_body("x-priority") || "").as String if !(val = priority.to_i?(strict: false)) || !(member = Priority.from_value? val) return Priority::NORMAL end member end # Sets the priority of this email to the provided *priority*. def priority(priority : AMIME::Email::Priority) : self @headers.upsert "x-priority", priority.to_s, ->@headers.add_text_header(String, String) self end # Sets the textual content of this email to the provided *body*, optionally with the provided *charset*. def text(body : String | IO | Nil, charset : String = "UTF-8") : self @cached_body = nil @text = body @text_charset = charset self end # Returns the textual content of this email. def text_body : IO | String | Nil @text end # Sets the HTML content of this email to the provided *body*, optionally with the provided *charset*. def html(body : String | IO | Nil, charset : String = "UTF-8") : self @cached_body = nil @html = body @html_charset = charset self end # Returns the HTML content of this email. def html_body : IO | String | Nil @html end # Adds an attachment with the provided *body*, optionally with the provided *name* and *content_type*. def attach(body : String | IO, name : String? = nil, content_type : String? = nil) : self self.add_part AMIME::Part::Data.new body, name, content_type end # Attaches the file at the provided *path* as an attachment, optionally with the provided *name* and *content_type*. def attach_from_path(path : String | Path, name : String? = nil, content_type : String? = nil) : self self.add_part AMIME::Part::Data.new AMIME::Part::File.new(path), name, content_type end # Adds an embedded attachment with the provided *body*, optionally with the provided *name* and *content_type*. def embed(body : String | IO, name : String? = nil, content_type : String? = nil) : self self.add_part AMIME::Part::Data.new(body, name, content_type).as_inline end # Embeds the file at the provided *path* as an attachment, optionally with the provided *name* and *content_type*. def embed_from_path(path : String | Path, name : String? = nil, content_type : String? = nil) : self self.add_part AMIME::Part::Data.new(AMIME::Part::File.new(path), name, content_type).as_inline end # Adds the provided *part* as an email attachment. # Consider using `#attach` or `#embed` or one of their variants to provide a simpler API. def add_part(part : AMIME::Part::Data) : self @cached_body = nil @attachments << part self end # Returns the MIME representation of this email. def body : AMIME::Part::Abstract if body = super return body end self.generate_body end # Used in specs protected def generate_body : AMIME::Part::Abstract if cached_body = @cached_body return cached_body end self.ensure_body_is_valid html_part, other_parts, related_parts = self.prepare_parts part = (text = @text) ? AMIME::Part::Text.new(text, @text_charset) : nil if html_part part = part ? AMIME::Part::Multipart::Alternative.new(part, html_part) : html_part end unless related_parts.empty? part = AMIME::Part::Multipart::Related.new part.not_nil!, related_parts end unless other_parts.empty? part = if part AMIME::Part::Multipart::Mixed.new other_parts.unshift(part) else AMIME::Part::Multipart::Mixed.new other_parts end end @cached_body = part.not_nil! end # ameba:disable Metrics/CyclomaticComplexity: private def prepare_parts : {AMIME::Part::Text?, Array(AMIME::Part::Abstract), Array(AMIME::Part::Abstract)} names = [] of String html_part = nil if html = @html html_part = AMIME::Part::Text.new html, @html_charset, "html" html = html_part.body regexes = { /]*src\s*=\s*(?:([\'"])cid:(.+?)\1|cid:([^>\s]+))/i, /<\w+\s+[^>]*background\s*=\s*(?:([\'"])cid:(.+?)\1|cid:([^>\s]+))/i, } regexes.each do |regex| html.scan regex do |matches| if m2 = matches[2]? names << m2 end if m3 = matches[3]? names << m3 end end end names = names.uniq! end other_parts = Array(AMIME::Part::Abstract).new related_parts = Hash(String, AMIME::Part::Abstract).new @attachments.each do |part| skip_part = names.each do |name| if name != part.name && (!part.has_content_id? || name != part.content_id) next end break true if related_parts.has_key? name if html && name != part.content_id html = html.gsub("cid:#{name}", "cid:#{part.content_id}") end related_parts[name] = part part.name = part.content_id part.as_inline break true end next if skip_part other_parts << part end if html_part html_part = AMIME::Part::Text.new html.not_nil!, @html_charset.not_nil!, "html" end {html_part, other_parts, related_parts.values} end # :inherit: def ensure_validity! : Nil self.ensure_body_is_valid if "1" == @headers.header_body("x-unsent") raise AMIME::Exception::Logic.new "Cannot send messages marked as 'draft'." end super end private def ensure_body_is_valid : Nil if @text.nil? && @html.nil? && @attachments.empty? raise AMIME::Exception::Logic.new "A message must have a text or an HTML part or attachments." end end end ================================================ FILE: src/components/mime/src/encoder/address_encoder_interface.cr ================================================ # Represents an encoder responsible for encoding an email address. module Athena::MIME::Encoder::AddressEncoderInterface # Returns an encoded version of the provided *address*. abstract def encode(address : String) : String end ================================================ FILE: src/components/mime/src/encoder/base64_content.cr ================================================ require "./content_encoder_interface" require "base64" # A content encoder based on the [Base64](https://datatracker.ietf.org/doc/html/rfc4648) spec. struct Athena::MIME::Encoder::Base64Content include Athena::MIME::Encoder::ContentEncoderInterface # :inherit: def encode(input : String, charset : String? = "UTF-8", first_line_offset : Int32 = 0, max_line_length : Int32? = nil) : String Base64.encode input end # :inherit: def encode(input : IO, max_line_length : Int32? = nil) : String Base64.encode input end # :inherit: def name : String "base64" end end ================================================ FILE: src/components/mime/src/encoder/content_encoder_interface.cr ================================================ require "./encoder_interface" # A more specialized version of `AMIME::Encoder::EncoderInterface` used to encode MIME message contents. module Athena::MIME::Encoder::ContentEncoderInterface include Athena::MIME::Encoder::EncoderInterface # Returns an string representing the encoded contents of the provided *input* IO. # With lines optionally limited to *max_line_length*, depending on the underlying implementation. abstract def encode(input : IO, max_line_length : Int32? = nil) : String # Returns the name of this encoder for use within the `content-transfer-encoding` header. abstract def name : String end ================================================ FILE: src/components/mime/src/encoder/eight_bit_content.cr ================================================ require "./content_encoder_interface" # A content encoder based on the [8bit](https://datatracker.ietf.org/doc/html/rfc1428) spec. struct Athena::MIME::Encoder::EightBitContent include Athena::MIME::Encoder::ContentEncoderInterface # :inherit: def encode(input : String, charset : String? = "UTF-8", first_line_offset : Int32 = 0, max_line_length : Int32? = nil) : String input end # :inherit: def encode(input : IO, max_line_length : Int32? = nil) : String input.gets_to_end end # :inherit: def name : String "8bit" end end ================================================ FILE: src/components/mime/src/encoder/encoder_interface.cr ================================================ module Athena::MIME::Encoder::EncoderInterface # Returns an encoded version of the provided *input*. # # *first_line_offset* may optionally be used depending on the exact implementation if the first line needs to be shorter. # *max_line_length* may optionally be used depending on the exact implementation to customize the max length of each line. abstract def encode(input : String, charset : String? = "UTF-8", first_line_offset : Int32 = 0, max_line_length : Int32? = nil) : String end ================================================ FILE: src/components/mime/src/encoder/idn_address.cr ================================================ require "uri/punycode" # An IDNA encoder ([RFC 5980](https://datatracker.ietf.org/doc/html/rfc5980)), defined in [RFC 3492](https://datatracker.ietf.org/doc/html/rfc3492). # # Encodes the domain part of an address using IDN. This is compatible will all # SMTP servers. # # NOTE: The local part is left as-is. In case there are non-ASCII characters # in the local part then it depends on the SMTP Server if this is supported. struct Athena::MIME::Encoder::IDNAddress include Athena::MIME::Encoder::AddressEncoderInterface # :inherit: def encode(address : String) : String if address.includes? '@' local, _, domain = address.partition '@' unless domain.ascii_only? address = "#{local}@#{URI::Punycode.to_ascii domain}" end end address end end ================================================ FILE: src/components/mime/src/encoder/mime_header_encoder_interface.cr ================================================ # Represents an encoder responsible for encoding the value of MIME headers. module Athena::MIME::Encoder::MIMEHeaderEncoderInterface # Returns the name of this content encoding scheme. abstract def name : String end ================================================ FILE: src/components/mime/src/encoder/quoted_printable_content.cr ================================================ # A content encoder based on the [quoted-printable](https://datatracker.ietf.org/doc/html/rfc2045#section-6.7) spec. struct Athena::MIME::Encoder::QuotedPrintableContent include Athena::MIME::Encoder::ContentEncoderInterface private MAX_LINE_LENGTH = 75 # Encodes a string as per https://datatracker.ietf.org/doc/html/rfc2045#section-6.7. # # ameba:disable Metrics/CyclomaticComplexity: def self.quoted_printable_encode(string : String) : String # TODO: Refactor this to be more idiomatic. line_pos = 0 String.build do |result| i = 0 bytesize = string.bytesize bytes = string.bytes while i < bytesize c = bytes[i] if c == 0x0D && i + 1 < bytesize && bytes[i + 1] == 0x0A result << "\r\n" i += 2 line_pos = 0 else if c.chr.control? || c == 0x7F || c >= 0x80 || c == 0x3D || (c == 0x20 && i + 1 < bytesize && bytes[i + 1] == 0x0D) needs_line_break = false line_pos += 3 if c <= 0x7F && (line_pos) > MAX_LINE_LENGTH needs_line_break = true elsif c > 0x7F && c <= 0xDF && ((line_pos + 3) > MAX_LINE_LENGTH) needs_line_break = true elsif c > 0xDF && c <= 0xEF && ((line_pos + 6) > MAX_LINE_LENGTH) needs_line_break = true elsif c > 0xEF && c <= 0xF4 && ((line_pos + 9) > MAX_LINE_LENGTH) needs_line_break = true end if needs_line_break result << "=\r\n" line_pos = 3 end result << '=' c.to_s result, base: 16, upcase: true, precision: 2 else line_pos += 1 if line_pos > MAX_LINE_LENGTH result << "=\r\n" line_pos = 1 end result << c.chr end i += 1 end end end end # :inherit: def encode(input : String, charset : String? = "UTF-8", first_line_offset : Int32 = 0, max_line_length : Int32? = nil) : String self.standardize self.class.quoted_printable_encode input end # :inherit: def encode(input : IO, max_line_length : Int32? = nil) : String self.encode input.gets_to_end end # :inherit: def name : String "quoted-printable" end private def standardize(string : String) : String # Transform CR or LF to CRLF string = string.gsub /0D(?!=0A)|(? "_", "=\r\n" => "\r\n", " " => "_", } ) end end ================================================ FILE: src/components/mime/src/encoder/rfc2231.cr ================================================ require "uri" # An encoder based on the [RFC2231](https://datatracker.ietf.org/doc/html/rfc2231) spec. struct Athena::MIME::Encoder::RFC2231 include Athena::MIME::Encoder::EncoderInterface # :nodoc: def_clone # :inherit: def encode(input : String, charset : String? = "UTF-8", first_line_offset : Int32 = 0, max_line_length : Int32? = nil) : String max_line_length = 75 if !max_line_length || 0 >= max_line_length String.build input.size do |io| line_length = first_line_offset 0.step(to: input.size, by: 4, exclusive: true) do |offset| encoded_string = URI.encode_path_segment input[offset, 4] if (line_length + encoded_string.bytesize) > max_line_length io << '\r' io << '\n' line_length = 0 end io << encoded_string line_length += encoded_string.bytesize end end end end ================================================ FILE: src/components/mime/src/exception/header_not_found.cr ================================================ # Raised when trying to retrieve a header by name, but there are no headers with that name. class Athena::MIME::Exception::HeaderNotFound < ::KeyError include Athena::MIME::Exception end ================================================ FILE: src/components/mime/src/exception/invalid_argument.cr ================================================ class Athena::MIME::Exception::InvalidArgument < ArgumentError include Athena::MIME::Exception end ================================================ FILE: src/components/mime/src/exception/logic.cr ================================================ # Represents a code logic error that should lead directly to a fix in your code. class Athena::MIME::Exception::Logic < ::Exception include Athena::MIME::Exception end ================================================ FILE: src/components/mime/src/exception/runtime.cr ================================================ class Athena::MIME::Exception::Runtime < ::RuntimeError include Athena::MIME::Exception end ================================================ FILE: src/components/mime/src/header/abstract.cr ================================================ require "./interface" # Base type of all headers that provides common utilities and abstractions. abstract class Athena::MIME::Header::Abstract(T) include Interface private PHRASE_REGEX = Regex.new(%q(^(?:(?:(?:(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))*(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))|(?:(?:[ \t]*(?:\r\n))?[ \t])))?[a-zA-Z0-9!#\$%&\'\*\+\-\/=\?\^_`\{\}\|~]+(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))*(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))|(?:(?:[ \t]*(?:\r\n))?[ \t])))?)|(?:(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))*(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))|(?:(?:[ \t]*(?:\r\n))?[ \t])))?"((?:(?:[ \t]*(?:\r\n))?[ \t])?(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21\x23-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])))*(?:(?:[ \t]*(?:\r\n))?[ \t])?"(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))*(?:(?:(?:(?:[ \t]*(?:\r\n))?[ \t])?(\((?:(?:(?:[ \t]*(?:\r\n))?[ \t])|(?:(?:[\x01-\x08\x0B\x0C\x0E-\x19\x7F]|[\x21-\x27\x2A-\x5B\x5D-\x7E])|(?:\\[\x00-\x08\x0B\x0C\x0E-\x7F])|(?1)))*(?:(?:[ \t]*(?:\r\n))?[ \t])?\)))|(?:(?:[ \t]*(?:\r\n))?[ \t])))?))+?)$), options: :dollar_endonly) protected class_getter encoder : AMIME::Encoder::QuotedPrintableMIMEHeader do AMIME::Encoder::QuotedPrintableMIMEHeader.new end # :inherit: getter name : String # :inherit: property max_line_length : Int32 = 76 # Sets the language used in this header. # E.g. `en-us`. property lang : String? = nil # Sets the character set used in this header. # Defaults to `UTF-8`. property charset : String = "UTF-8" def initialize(@name : String); end # Returns the body of this header. abstract def body : T # Sets the body of this header. abstract def body=(body : T) # :nodoc: def_clone macro inherited # :nodoc: def ==(other : self) \{% if @type.class? %} return true if same?(other) \{% end %} \{% for field in @type.instance_vars %} return false unless @\{{field.id}} == other.@\{{field.id}} \{% end %} true end end # :nodoc: def to_s(io : IO) : Nil # TODO: Is there a way to make this more stream based? io << self.tokens_to_string self.to_tokens end # Generates tokens from the given string which include CRLF as individual tokens. private def generate_token_lines(token : String) : Array(String) token.split /(\r\n)/, options: :no_utf_check end # Takes an array of tokens which appear in the header and turns them into an RFC 2822 compliant string, adding FWSP where needed. private def tokens_to_string(tokens : Array(String)) : String line_pos = 0 String.build do |io| io << @name << ':' << ' ' line_pos += @name.bytesize + 2 tokens.each do |token| if "\r\n" == token line_pos = 0 elsif (line_pos + token.bytesize) > @max_line_length io << "\r\n" line_pos = token.bytesize else line_pos += token.bytesize end io << token end end end # Generate a list of all tokens in the final header. private def to_tokens(string : String? = nil) : Array(String) string = string || self.body_to_s tokens = [] of String string.split /(?=[ \t])/, options: :no_utf_check do |token| tokens.concat self.generate_token_lines token end tokens end private def token_needs_encoding?(token : String) : Bool return true unless token.valid_encoding? token.each_char.any? do |char| ord = char.ord 0x00 <= ord <= 0x08 || 0x10 <= ord <= 0x19 || 0x7F <= ord <= 0xFF || char.in?('\r', '\n') end end # Splits a string into tokens in blocks of words which can be encoded quickly. private def encodable_word_tokens(string : String) : Array(String) tokens = [] of String encoded_token = "" string.split /(?=[\t ])/, options: :no_utf_check do |token| if self.token_needs_encoding? token encoded_token += token else unless encoded_token.empty? tokens << encoded_token encoded_token = "" end tokens << token end end unless encoded_token.empty? tokens << encoded_token end tokens end # Encode needed word tokens within a string of input. private def encode_words(header : AMIME::Header::Interface, input : String, used_length : Int32 = -1) : String bytes_written = 0 String.build do |io| tokens = self.encodable_word_tokens input tokens.each do |token| # See RFC 2822, Sect 2.2 (really 2.2 ??) if self.token_needs_encoding? token # Dont encode starting WSP case first_char = token[0] when ' ', '\t' io << first_char bytes_written += first_char.bytesize token = token[1..] end if -1 == used_length used_length = "#{header.name}: ".bytesize + bytes_written end encoded_token = self.token_as_encoded_word token, used_length io << encoded_token bytes_written += encoded_token.bytesize else io << token bytes_written += token.bytesize end end end end # Encodes the provided *token* for safe insertion into headers. private def token_as_encoded_word(token : String, first_line_offset : Int32 = 0) : String # Adjust first_line_offset to account or space needed for syntax charset_decl = @charset if lang = @lang charset_decl = "#{charset_decl}*#{lang}" end encoded_wrapper_length = "=?#{charset_decl}?#{AMIME::Header::Abstract.encoder.name}??=".bytesize if first_line_offset >= 75 # TODO: Is this needed? first_line_offset = 0 end encoded_text_lines = AMIME::Header::Abstract.encoder.encode(token, @charset, first_line_offset, 75 - encoded_wrapper_length).split "\r\n" if "iso-2022-jp" != @charset.downcase encoded_text_lines.map! do |line| "=?#{charset_decl}?#{AMIME::Header::Abstract.encoder.name}?#{line}?=" end end encoded_text_lines.join "\r\n " end # Produces a compliant, formatted RFC 2822 'phrase' based on the provided *input*. private def create_phrase(header : AMIME::Header::Interface, input : String, charset : String, shorten : Bool = false) : String phrase_str = input if !phrase_str.matches? PHRASE_REGEX, options: :no_utf_check # If it's just ASCII try escaping some chars and make it a quoted string if phrase_str.ascii_only? {'\\', '"'}.each do |char| phrase_str = phrase_str.gsub char, "\\#{char}" end phrase_str = %("#{phrase_str}") else # Otherwise it needs encoded used_length = shorten ? "#{header.name}: ".bytesize : 0 phrase_str = self.encode_words header, input, used_length end elsif phrase_str.includes? '(' {'\\', '"'}.each do |char| phrase_str = phrase_str.gsub char, "\\#{char}" end phrase_str = %("#{phrase_str}") end phrase_str end end ================================================ FILE: src/components/mime/src/header/collection.cr ================================================ # Represents a collection of MIME headers. class Athena::MIME::Header::Collection private UNIQUE_HEADERS = [ "bcc", "cc", "date", "from", "in-reply-to", "message-id", "references", "reply-to", "sender", "subject", "to", ] private HEADER_CLASS_MAP = { "date" => AMIME::Header::Date, } of String => AMIME::Header::Abstract.class | Array(AMIME::Header::Abstract.class) # :nodoc: enum Type TEXT DATE end # Checks the provided *header* to ensure its name and type are compatible. # # ``` # AMIME::Header::Collection.check_header_class AMIME::Header::Date.new("date", Time.utc) # => nil # AMIME::Header::Collection.check_header_class AMIME::Header::Unstructured.new("date", "blah") # # => AMIME::Exception::Logic: The 'date' header must be an instance of 'Athena::MIME::Header::Date' (got 'Athena::MIME::Header::Unstructured'). # ``` # # ameba:disable Metrics/CyclomaticComplexity: def self.check_header_class(header : AMIME::Header::Interface) : Nil is_valid, header_classes = case header.name.downcase when "date" then {header.is_a?(AMIME::Header::Date), {AMIME::Header::Date}} when "from" then {header.is_a?(AMIME::Header::MailboxList), {AMIME::Header::MailboxList}} when "sender" then {header.is_a?(AMIME::Header::Mailbox), {AMIME::Header::Mailbox}} when "reply-to" then {header.is_a?(AMIME::Header::MailboxList), {AMIME::Header::MailboxList}} when "to" then {header.is_a?(AMIME::Header::MailboxList), {AMIME::Header::MailboxList}} when "cc" then {header.is_a?(AMIME::Header::MailboxList), {AMIME::Header::MailboxList}} when "bcc" then {header.is_a?(AMIME::Header::MailboxList), {AMIME::Header::MailboxList}} when "message-id" then {header.is_a?(AMIME::Header::Identification), {AMIME::Header::Identification}} when "return-path" then {header.is_a?(AMIME::Header::Path), {AMIME::Header::MailboxList}} # `in-reply-to` and `references` are less strict than RFC 2822 (3.6.4) to allow users entering the original email's `message-id`, even if that is no valid `message-id` when "in-reply-to" then {header.is_a?(AMIME::Header::Unstructured) || header.is_a?(AMIME::Header::Identification), {AMIME::Header::Unstructured, AMIME::Header::Identification}} when "references" then {header.is_a?(AMIME::Header::Unstructured) || header.is_a?(AMIME::Header::Identification), {AMIME::Header::Unstructured, AMIME::Header::Identification}} else {true, [] of NoReturn} end return if is_valid raise AMIME::Exception::Logic.new "The '#{header.name}' header must be an instance of '#{header_classes.join("' or '")}' (got '#{header.class}')." end # Returns `true` if the provided *header* name is required to be unique. def self.unique_header?(name : String) : Bool UNIQUE_HEADERS.includes? name.downcase end # Returns the getter line_length : Int32 = 76 @headers = Hash(String, Array(AMIME::Header::Interface)).new { |hash, key| hash[key] = Array(AMIME::Header::Interface).new } def self.new(*headers : AMIME::Header::Interface) new headers end def initialize(headers : Enumerable(AMIME::Header::Interface) = [] of AMIME::Header::Interface) headers.each do |h| self << h end end def_clone def_equals @headers, @line_length # Sets the max line length to use for this collection. def line_length=(@line_length : Int32) : Nil self.all do |header| header.max_line_length = @line_length end end # :nodoc: def to_s(io : IO) : Nil self.all do |header| header.to_s(io) io << '\r' << '\n' end end # Returns the string representation of each header in the collection as an array of strings. def to_a : Array(String) headers = [] of String self.all do |header| headers << header.to_s unless header.body_to_s.blank? end headers end # Returns an array of all `AMIME::Header::Interface` instances stored within the collection. def all : Array(AMIME::Header::Interface) @headers.each_value.flat_map do |headers| headers end.to_a end # Yields each `AMIME::Header::Interface` instance stored within the collection. def all(& : AMIME::Header::Interface ->) : Nil @headers.each_value do |headers| headers.each do |header| yield header end end end # Yields each `AMIME::Header::Interface` instance stored within the collection with the provided *name*. def all(name : String, & : AMIME::Header::Interface ->) : Nil @headers[name.downcase]?.try &.each do |header| yield header end end # Returns the names of all headers stored within the collection as an array of strings. def names : Array(String) @headers.keys end # Removes the header(s) with the provided *name* from the collection. def delete(name : String) : Nil @headers.delete name end # Returns the first header with the provided *name*. # Raises an `AMIME::Exception::HeaderNotFound` exception if no header with that name exists. def [](name : String) : AMIME::Header::Interface name = name.downcase if !(header_list = @headers[name]?) || !(first_header = header_list.first?) raise AMIME::Exception::HeaderNotFound.new "No headers with the name '#{name}' exist." end first_header end # Returns the first header with the provided *name* casted to type `T`. # Raises an `AMIME::Exception::HeaderNotFound` exception if no header with that name exists. def [](name : String, _type : T.class) : T forall T self.[name].as T end # Returns the first header with the provided *name* casted to type `T`, or `nil` if no headers with that name exist. def []?(name : String, _type : T.class) : T? forall T return unless header = self.[name]? header.as T end # Returns the first header with the provided *name*, or `nil` if no headers with that name exist. def []?(name : String) : AMIME::Header::Interface? name = name.downcase return unless headers = @headers[name]? headers.first? end # Adds the provided *header* to the collection. def <<(header : AMIME::Header::Interface) : self self.class.check_header_class header header.max_line_length = @line_length name = header.name.downcase if UNIQUE_HEADERS.includes?(name) && (header_list = @headers[name]?) && header_list.size > 0 raise AMIME::Exception::Logic.new "Cannot set header '#{name}' as it is already defined and must be unique." end @headers[name] << header self end # Returns the body of the first header with the provided *name*. def header_body(name : String) return unless header = self.[name]? header.body end # Returns `true` if the collection contains a header with the provided *name*, otherwise `false`. def has_key?(name : String) : Bool @headers.has_key? name.downcase end # Adds an `AMIME::Header::Identification` header to the collection with the provided *name* and *body*. def add_id_header(name : String, body : String | Array(String)) : self self << AMIME::Header::Identification.new name, body end # Adds an `AMIME::Header::Unstructured` header to the collection with the provided *name* and *body*. def add_text_header(name : String, body : String) : self self << AMIME::Header::Unstructured.new name, body end # Adds an `AMIME::Header::Date` header to the collection with the provided *name* and *body*. def add_date_header(name : String, body : Time) : self self << AMIME::Header::Date.new name, body end # Adds an `AMIME::Header::Path` header to the collection with the provided *name* and *body*. def add_path_header(name : String, body : AMIME::Address | String) : self self << AMIME::Header::Path.new name, AMIME::Address.create(body) end # Adds an `AMIME::Header::Mailbox` header to the collection with the provided *name* and *body*. def add_mailbox_header(name : String, body : AMIME::Address | String) : self self << AMIME::Header::Mailbox.new name, AMIME::Address.create(body) end # Adds an `AMIME::Header::MailboxList` header to the collection with the provided *name* and *body*. def add_mailbox_list_header(name : String, body : Enumerable(AMIME::Address | String)) : self self << AMIME::Header::MailboxList.new name, AMIME::Address.create_multiple(body) end # Adds an `AMIME::Header::Parameterized` header to the collection with the provided *name* and *body*. def add_parameterized_header(name : String, body : String, params : Hash(String, String) = {} of String => String) : self self << AMIME::Header::Parameterized.new name, body, params end # Returns the value of the provided *parameter* for the first `AMIME::Header::Parameterized` header with the provided *name*. # # ``` # headers = AMIME::Header::Collection.new # headers.add_parameterized_header "content-type", "text/plain", {"charset" => "UTF-8"} # headers.header_parameter "content-type", "charset" # => "UTF-8" # ``` def header_parameter(name : String, parameter : String) : String? header = self.[name] unless header.is_a? Parameterized raise AMIME::Exception::Logic.new "Unable to get parameter '#{parameter}' on header '#{name}' as the header is not of class '#{AMIME::Header::Parameterized}'." end header[parameter] end protected def header_parameter(name : String, parameter : String, value : String?) : Nil header = self.[name] unless header.is_a? Parameterized raise AMIME::Exception::Logic.new "Unable to set parameter '#{parameter}' on header '#{name}' as the header is not of class '#{AMIME::Header::Parameterized}'." end header[parameter] = value end protected def upsert(name : String, body : T, adder : Proc(String, T, Nil)) : Nil forall T if header = self[name]? return header.body = body end adder.call name, body end end ================================================ FILE: src/components/mime/src/header/date.cr ================================================ # Represents a `date` MIME Header. class Athena::MIME::Header::Date < Athena::MIME::Header::Abstract(Time) @value : Time def initialize(name : String, @value : Time) super name end # :inherit: def body : Time @value end # :inherit: def body=(body : Time) @value = body end protected def body_to_s(io : IO) : Nil @value.to_rfc2822 io end end ================================================ FILE: src/components/mime/src/header/identification.cr ================================================ # Represents an ID MIME Header for something like `message-id` or `content-id` (one or more addresses). class Athena::MIME::Header::Identification < Athena::MIME::Header::Abstract(Array(String)) getter ids : Array(String) = [] of String getter ids_as_addresses : Array(AMIME::Address) = [] of AMIME::Address def initialize(name : String, value : String | Array(String)) super name self.id = value end # :inherit: def body : Array(String) @ids end # :inherit: def body=(body : String | Array(String)) self.id = body end # Returns the ID used in the value of this header. # If multiple IDs are set, only the first is returned. def id : String? @ids.first? end # Sets the ID used in the value of this header. def id=(id : String | Array(String)) : Nil self.ids = id.is_a?(String) ? [id] : id end # Sets a collection of IDs to use in the value of this header. def ids=(ids : Array(String)) : Nil @ids.clear @ids_as_addresses.clear ids.each do |id| @ids << id @ids_as_addresses << AMIME::Address.new id end end protected def body_to_s(io : IO) : Nil @ids_as_addresses.join io, ' ' do |address, i| i << '<' address.to_s i i << '>' end end end ================================================ FILE: src/components/mime/src/header/interface.cr ================================================ # An OO representation of a MIME header. module Athena::MIME::Header::Interface # Returns the name of the header. abstract def name : String # Returns the body of the header. # The type depends on the specific concrete class. def body; end # Sets the body of the header. # The type depends on the specific concrete class. def body=(body); end # Controls how long each header line may be before needing wrapped. # Defaults to `76`. abstract def max_line_length : Int32 # :ditto: abstract def max_line_length=(max_line_length : Int32) # Render this header as a compliant string. abstract def to_s(io : IO) : Nil # Returns the header's body, prepared for folding into a final header value. # # This is not necessarily RFC 2822 compliant since folding white space is not added at this stage (see `#to_s` for that). def body_to_s : String String.build do |io| self.body_to_s io end end protected abstract def body_to_s(io : IO) : Nil end ================================================ FILE: src/components/mime/src/header/mailbox.cr ================================================ # Represents a Mailbox MIME Header for something like `sender` (one named address). class Athena::MIME::Header::Mailbox < Athena::MIME::Header::Abstract(Athena::MIME::Address) @value : AMIME::Address def initialize(name : String, @value : AMIME::Address) super name end # :inherit: def body : AMIME::Address @value end # :inherit: def body=(body : AMIME::Address) @value = body end protected def body_to_s(io : IO) : Nil str = @value.encoded_address if name = @value.name.presence str = "#{self.create_phrase(self, name, @charset, true)} <#{str}>" end io << str end end ================================================ FILE: src/components/mime/src/header/mailbox_list.cr ================================================ # Represents a Mailbox MIME Header for something like `from`, `to`, `cc`, or `bcc` (one or more named address). class Athena::MIME::Header::MailboxList < Athena::MIME::Header::Abstract(Array(Athena::MIME::Address)) @value : Array(AMIME::Address) def initialize(name : String, @value : Array(AMIME::Address)) super name end # :inherit: def body : Array(AMIME::Address) @value end # :inherit: def body=(body : Array(AMIME::Address)) @value = body end # Adds the provided *addresses* to use in the value of this header. def add_addresses(addresses : Array(AMIME::Address)) : Nil @value.concat addresses end # Returns the full mailbox list of this Header as an array of valid RFC 2822 strings. def address_strings : Array(String) first = true @value.map do |address| str = address.encoded_address if name = address.name.presence str = "#{self.create_phrase(self, name, @charset, first)} <#{str}>" end str ensure first = false end end protected def body_to_s(io : IO) : Nil self.address_strings.join io, ", " end private def token_needs_encoding?(token : String) : Bool token.matches?(/[()<>\[\]:;@\,."]/, options: :no_utf_check) || super end end ================================================ FILE: src/components/mime/src/header/parameterized.cr ================================================ require "./unstructured" # Represents a MIME Header for something like `content-type` (key/value pairs of metadata included in the value). class Athena::MIME::Header::Parameterized < Athena::MIME::Header::Unstructured # RFC 2231's definition of a token. private TOKEN_REGEX = Regex.new "^(?:[\x21\x23-\x27\x2A\x2B\x2D\x2E\x30-\x39\x41-\x5A\x5E-\x7E]+)$", :dollar_endonly # Represents the parameters associated with this header. property parameters : Hash(String, String) = {} of String => String @encoder : AMIME::Encoder::RFC2231? = nil def initialize( name : String, value : String, parameters : Hash(String, String) = {} of String => String, ) super name, value parameters.each do |k, v| self.[k] = v end if "content-type" != name.downcase @encoder = AMIME::Encoder::RFC2231.new end end # Returns the value of the parameter with the provided *name* def [](name : String) : String @parameters[name]? || "" end # Set the value of the parameter with the provided *name* to *value*. def []=(key : String, value : String) : Nil @parameters.merge!({key => value}) end protected def body_to_s(io : IO) : Nil super @parameters.each do |k, v| next unless v.presence io << ';' << ' ' self.write_parameter io, k, v end end # Write an RFC 2047 compliant header parameter from the *name* and *value* to *io*. # ameba:disable Metrics/CyclomaticComplexity: private def write_parameter(io : IO, name : String, value : String) : Nil orig_value = value encoded = false # Allow room for parameter name, indices, "=", and DQUOTEs max_value_length = @max_line_length - "#{name}=*N\"\";".bytesize - 1 first_line_offset = 0 # If it's not already a valid parameter if !value.matches? TOKEN_REGEX, options: :no_utf_check # TODO: Text or something else? # ... and it's not ASCII unless value.ascii_only? encoded = true # Allow space for the indices, charset, and language max_value_length = @max_line_length - "#{name}*N*=\"\";".bytesize - 1 first_line_offset = "#{@charset}'#{@lang}'".bytesize end if name.in?("name", "filename") && "form-data" == @value && "content-disposition" == @name.downcase && !value.ascii_only? # WHATWG HTML living standard 4.10.21.8 2 specifies: # For field names and filenames for file fields, the result of the # encoding in the previous bullet point must be escaped by replacing # any 0x0A (LF) bytes with the byte sequence `%0A`, 0x0D (CR) with `%0D` # and 0x22 (") with `%22`. # The user agent must not perform any other escapes. value = value.gsub({'"' => "%22", '\r' => "%0D", '\n' => "%0A"}) if value.bytesize <= max_line_length io << name io << '=' io << '"' io << value io << '"' return end value = orig_value end end # Encode if needed if encoded || value.bytesize > max_value_length if encoder = @encoder value = encoder.encode orig_value, @charset, first_line_offset, max_value_length else # TODO: Do we really need to continue to support this non-RFC compliant flow? value = self.token_as_encoded_word orig_value encoded = false end end value_lines = @encoder ? value.split("\r\n") : [value] if value_lines.size > 1 value_lines.each_with_index.join io, ";\r\n " do |(line, idx), i| i << "#{name}*#{idx}" self.write_end_of_parameter_value i, line, true, idx.zero? end return end io << name self.write_end_of_parameter_value io, value_lines[0], encoded, true end private def write_end_of_parameter_value(io : IO, value : String, encoded : Bool = false, first_line : Bool = false) : Nil force_http_quoting = "form-data" == @value && "content-disposition" == @name.downcase if force_http_quoting || !value.matches?(TOKEN_REGEX, options: :no_utf_check) value = %("#{value}") end prepend = '=' if encoded prepend = "*=" if first_line prepend = "*=#{@charset}'#{@lang}'" end end io << prepend io << value end end ================================================ FILE: src/components/mime/src/header/path.cr ================================================ # Represents a Path MIME Header for something like `return-path` (one address). class Athena::MIME::Header::Path < Athena::MIME::Header::Abstract(Athena::MIME::Address) @value : AMIME::Address def initialize(name : String, @value : AMIME::Address) super name end # :inherit: def body : AMIME::Address @value end # :inherit: def body=(body : AMIME::Address) @value = body end protected def body_to_s(io : IO) : Nil io << '<' @value.to_s io io << '>' end end ================================================ FILE: src/components/mime/src/header/unstructured.cr ================================================ # Represents a simple MIME Header (key/value). class Athena::MIME::Header::Unstructured < Athena::MIME::Header::Abstract(String) @value : String def initialize(name : String, @value : String) super name end # :inherit: def body : String @value end # :inherit: def body=(body : String) @value = body end protected def body_to_s(io : IO) : Nil io << self.encode_words self, @value end end ================================================ FILE: src/components/mime/src/magic_types_guesser.cr ================================================ require "./types_guesser_interface" # A `AMIME::TypesGuesserInterface` implementation based on [libmagic](https://www.darwinsys.com/file/). # # Only natively supported on Unix systems and MSYS2 where the `file` package is easily installable. # If you have the available lib files on Windows MSVC you may build with `-Dathena_use_libmagic` to explicitly enable the implementation. struct Athena::MIME::MagicTypesGuesser include Athena::MIME::TypesGuesserInterface def initialize( @magic_file : String? = nil, ); end # As of now `libmagic` is only really supported on Unix and MSYS2. # Respect this by default, but also allow using a dedicated flag to enable just in case. {% if flag?("athena_use_libmagic") || flag?("unix") || (flag?("windows") && flag?("gnu")) %} @[Link("magic", pkg_config: "libmagic")] lib LibMagic type MagicT = Void* enum Flags MIME_TYPE = 0x000010 # Return the MIME type end fun magic_open( flags : LibC::Int, ) : MagicT fun magic_close( magic : MagicT, ) : Void fun magic_file( magic : MagicT, filename : LibC::Char*, ) : LibC::Char* fun magic_load( magic : MagicT, filename : LibC::Char*, ) : LibC::Int fun magic_error( magic : MagicT, ) : LibC::Char* end # :inherit: def supported? : Bool true end # :inherit: def guess_mime_type(path : String | Path) : String? if !File.file?(path) || !File::Info.readable?(path) raise AMIME::Exception::InvalidArgument.new "The file '#{path}' does not exist or is not readable." end unless magic = LibMagic.magic_open LibMagic::Flags::MIME_TYPE raise AMIME::Exception::Runtime.new "Failed to open libmagic." end begin magic_load = if magic_file = @magic_file LibMagic.magic_load magic, magic_file else LibMagic.magic_load magic, nil end unless magic_load.zero? raise AMIME::Exception::Runtime.new String.new LibMagic.magic_error magic end unless mime_type = LibMagic.magic_file magic, path.to_s raise AMIME::Exception::Runtime.new String.new LibMagic.magic_error magic end String.new mime_type ensure LibMagic.magic_close magic end end {% else %} # :inherit: def supported? : Bool false end # :inherit: def guess_mime_type(path : String | Path) : String? if !File.file?(path) || !File::Info.readable?(path) raise AMIME::Exception::InvalidArgument.new "The file '#{path}' does not exist or is not readable." end nil end {% end %} end ================================================ FILE: src/components/mime/src/message.cr ================================================ # Provides a low-level API for creating an email. # # See [Creating Raw Email Message](/MIME/#creating-raw-email-messages) for more information. class Athena::MIME::Message # Represents the `AMIME::Header`s a part of this message. property headers : AMIME::Header::Collection # Represents the `AMIME::Part`s that comprise this message. property body : AMIME::Part::Abstract? def initialize( headers : AMIME::Header::Collection? = nil, @body : AMIME::Part::Abstract? = nil, ) # TODO: Need to clone this? @headers = headers || AMIME::Header::Collection.new end # Returns a cloned `AMIME::Header::Collection` consisting of a final representation of the headers associated with this message. # I.e. Ensures the message's headers include the required ones. def prepared_headers : AMIME::Header::Collection headers = @headers.clone unless headers.has_key? "from" unless headers.has_key? "sender" raise AMIME::Exception::Logic.new "An email must have a 'from' or a 'sender' header." end headers.add_mailbox_list_header "from", [headers["sender", AMIME::Header::Mailbox].body] end unless headers.has_key? "mime-version" headers.add_text_header "mime-version", "1.0" end unless headers.has_key? "date" headers.add_date_header "date", Time.utc end # Determine the "real" sender if !headers.has_key?("sender") && (from_addresses = headers["from", AMIME::Header::MailboxList].body) && from_addresses.size > 1 headers.add_mailbox_header "sender", from_addresses.first end unless headers.has_key? "message-id" headers.add_id_header "message-id", self.generate_message_id end # Remove bcc which should _NOT_ be part of the sent message headers.delete "bcc" headers end # :nodoc: def to_s(io : IO) : Nil body = self.body || AMIME::Part::Text.new "" self.prepared_headers.to_s io body.to_s io end # Asserts that this message is in a valid state to be sent, raising an `AMIME::Exception::Logic` error if not. def ensure_validity! : Nil if (!(tos = @headers.header_body("to")) || tos.as(Array(AMIME::Address)).empty?) && (!(ccs = @headers.header_body("cc")) || ccs.as(Array(AMIME::Address)).empty?) && (!(bccs = @headers.header_body("bcc")) || bccs.as(Array(AMIME::Address)).empty?) raise AMIME::Exception::Logic.new "An email must have a 'to', 'cc', or 'bcc' header." end if (!(from_addresses = @headers.header_body("from")) || from_addresses.as(Array(AMIME::Address)).empty?) && !@headers.header_body("sender") raise AMIME::Exception::Logic.new "An email must have a 'from' or a 'sender' header." end end # Returns a string that uniquely represents this message. def generate_message_id : String sender = if sender_header = @headers["sender", AMIME::Header::Mailbox]? sender_header.body elsif from_header = @headers["from", AMIME::Header::MailboxList]? if (from_addresses = from_header.body).empty? raise AMIME::Exception::Logic.new "A 'from' header must have at least one email address." end from_addresses.first else raise AMIME::Exception::Logic.new "An email must have a 'from' or 'sender' header." end "#{Random::Secure.hex(16)}@#{sender.address.partition('@').last}" end end ================================================ FILE: src/components/mime/src/message_converter.cr ================================================ module Athena::MIME::MessageConverter # Utility method to convert `AMIME::Message`s to `AMIME::Email`s. def self.to_email(message : AMIME::Message) : AMIME::Email return message if message.is_a? AMIME::Email body = message.body case body when AMIME::Part::Text then return self.create_email_from_text_part message, body when AMIME::Part::Multipart::Alternative then return self.create_email_from_alternative_part message, body when AMIME::Part::Multipart::Related then return self.create_email_from_related_part message, body when AMIME::Part::Multipart::Mixed parts = body.parts email = case part = parts.first? when AMIME::Part::Multipart::Related then self.create_email_from_related_part message, part when AMIME::Part::Multipart::Alternative then self.create_email_from_alternative_part message, part when AMIME::Part::Text then self.create_email_from_text_part message, part else raise AMIME::Exception::Runtime.new "Unable to create an Email from an instance of '#{message.class}' as the body is too complex." end parts.shift return self.add_parts email, parts end raise AMIME::Exception::Runtime.new "Unable to create an Email from an instance of '#{message.class}' as the body is too complex." end private def self.create_email_from_text_part(message : AMIME::Message, part : AMIME::Part::Text) : AMIME::Email if "text" == part.media_type && "plain" == part.media_sub_type return AMIME::Email .new(message.headers.clone) .text(part.body, part.prepared_headers.header_parameter("content-type", "charset") || "UTF-8") end if "text" == part.media_type && "html" == part.media_sub_type return AMIME::Email .new(message.headers.clone) .html(part.body, part.prepared_headers.header_parameter("content-type", "charset") || "UTF-8") end raise AMIME::Exception::Runtime.new "Unable to create an Email from an instance of '#{message.class}' as the body is too complex." end private def self.create_email_from_alternative_part(message : AMIME::Message, part : AMIME::Part::Multipart::Alternative) : AMIME::Email parts = part.parts if 2 == parts.size && (first_part = parts[0]).is_a?(AMIME::Part::Text) && "text" == first_part.media_type && "plain" == first_part.media_sub_type && (second_part = parts[1]).is_a?(AMIME::Part::Text) && "text" == second_part.media_type && "html" == second_part.media_sub_type return AMIME::Email .new(message.headers.clone) .text(first_part.body, first_part.prepared_headers.header_parameter("content-type", "charset") || "UTF-8") .html(second_part.body, first_part.prepared_headers.header_parameter("content-type", "charset") || "UTF-8") end raise AMIME::Exception::Runtime.new "Unable to create an Email from an instance of '#{message.class}' as the body is too complex." end private def self.create_email_from_related_part(message : AMIME::Message, part : AMIME::Part::Multipart::Related) : AMIME::Email parts = part.parts first_part = parts.first? email = case first_part = parts.first? when AMIME::Part::Multipart::Alternative then self.create_email_from_alternative_part message, first_part when AMIME::Part::Text then self.create_email_from_text_part message, first_part else raise AMIME::Exception::Runtime.new "Unable to create an Email from an instance of '#{message.class}' as the body is too complex." end parts.shift self.add_parts email, parts end private def self.add_parts(email : AMIME::Email, parts : Enumerable(AMIME::Part::Abstract)) : AMIME::Email parts.each do |part| unless part.is_a? AMIME::Part::Data raise AMIME::Exception::Runtime.new "Unable to create an Email from an instance of '#{email.class}' as the body is too complex." end email.add_part part end email end end ================================================ FILE: src/components/mime/src/native_types_guesser.cr ================================================ require "./types_guesser_interface" require "mime" # A `AMIME::TypesGuesserInterface` implementation based Crystal's [MIME](https://crystal-lang.org/api/MIME.html) module. # # This guesser is mainly intended as a fallback for when `AMIME::MagicTypesGuesser` isn't available (MSVC Windows). struct Athena::MIME::NativeTypesGuesser include Athena::MIME::TypesGuesserInterface # :inherit: def supported? : Bool true end # :inherit: # # NOTE: Guessing is based solely on the extension of the provided *path*. def guess_mime_type(path : String | Path) : String? if !File.file?(path) || !File::Info.readable?(path) raise AMIME::Exception::InvalidArgument.new "The file '#{path}' does not exist or is not readable." end ::MIME.from_filename? path end end ================================================ FILE: src/components/mime/src/part/abstract.cr ================================================ # Base type of all parts that provides common utilities and abstractions. abstract class Athena::MIME::Part::Abstract # Returns the headers associated with this part. getter headers : AMIME::Header::Collection = AMIME::Header::Collection.new macro inherited # :nodoc: def ==(other : self) \{% if @type.class? %} return true if same?(other) \{% end %} \{% for field in @type.instance_vars %} return false unless @\{{field.id}} == other.@\{{field.id}} \{% end %} true end end protected abstract def body_to_s(io : IO) : Nil # Returns the media type of this part. # E.g. `application` within `application/pdf`. abstract def media_type : String # Returns the media sub-type of this part. # E.g. `pdf` within `application/pdf`. abstract def media_sub_type : String # Returns a cloned `AMIME::Header::Collection` consisting of a final representation of the headers associated with this message. # I.e. Ensures the message's headers include the required ones. def prepared_headers : AMIME::Header::Collection headers = @headers.clone headers.upsert "content-type", "#{self.media_type}/#{self.media_sub_type}", ->headers.add_parameterized_header(String, String) headers end # Returns a string representation of the body of this part, excluding any headers. def body_to_s : String String.build do |io| self.body_to_s io end end # def inspect(io : IO) : Nil # self.media_type.to_s io # io << '/' # self.media_sub_type.to_s io # end # :nodoc: def to_s(io : IO) : Nil self.prepared_headers.to_s io io << '\r' << '\n' self.body_to_s io end end ================================================ FILE: src/components/mime/src/part/abstract_multipart.cr ================================================ require "mime/multipart" # Base type of all *multipart* based parts. abstract class Athena::MIME::Part::AbstractMultipart < Athena::MIME::Part::Abstract private getter boundary : String { ::MIME::Multipart.generate_boundary } # Returns the parts that make up this multipart part. getter parts : Array(Athena::MIME::Part::Abstract) = [] of AMIME::Part::Abstract def self.new(*parts : AMIME::Part::Abstract) : self new parts end def initialize(parts : Enumerable(AMIME::Part::Abstract) = [] of AMIME::Part::Abstract) parts.each do |part| @parts << part end end # :inherit: def media_type : String "multipart" end # :inherit: def prepared_headers : AMIME::Header::Collection headers = super headers.header_parameter "content-type", "boundary", self.boundary headers end protected def body_to_s(io : IO) : Nil self.parts.each do |part| io << self.boundary io << '\r' << '\n' part.to_s io io << '\r' << '\n' end io << self.boundary io << '\r' << '\n' end end ================================================ FILE: src/components/mime/src/part/data.cr ================================================ require "./text" # Represents attached/embedded content within the MIME message. class Athena::MIME::Part::Data < Athena::MIME::Part::Text # Creates the part using the contents the file at the provided *path* as the body, optionally with the provided *name* and *content_type*. # The file is lazily read. def self.from_path(path : String | Path, name : String? = nil, content_type : String? = nil) : self new AMIME::Part::File.new(path), name, content_type end # Returns the media type of this part based on its body. getter media_type : String # Returns the name of the file associated with this part. getter filename : String? @content_id : String? def initialize( body : String | IO | AMIME::Part::File, filename : String? = nil, content_type : String? = nil, encoding : String? = nil, ) if body.is_a?(AMIME::Part::File) && filename.nil? filename = body.filename end content_type ||= body.is_a?(AMIME::Part::File) ? body.content_type : "application/octet-stream" @media_type, sub_type = content_type.split '/' super body, nil, sub_type, encoding if filename @filename = filename self.name = filename end self.disposition = "attachment" end # :inherit: def prepared_headers : AMIME::Header::Collection headers = super if cid = @content_id headers.upsert "content-id", cid, ->headers.add_id_header(String, String) end if name = @filename headers.header_parameter "content-disposition", "filename", name end headers end # Marks this part as representing embedded content versus an attached file. def as_inline : self self.disposition = "inline" self end # Sets the content ID of this part to the provided *id*. def content_id=(id : String) : self if !id.includes? '@' raise AMIME::Exception::InvalidArgument.new "The '#{id}' CID is invalid as it does not contain an '@' symbol." end @content_id = id self end # Returns the content type of this part. def content_type : String "#{self.media_type}/#{self.media_sub_type}" end # Returns the content ID of this part, generating a unique one if one was not already set. def content_id : String @content_id ||= self.generate_content_id end # Returns `true` if this part has a `#content_id` currently set. def has_content_id? : Bool !@content_id.nil? end private def generate_content_id : String "#{Random::Secure.hex(16)}@athena" end end ================================================ FILE: src/components/mime/src/part/file.cr ================================================ # An abstraction that allows representing a file without needing to keep the file open. class Athena::MIME::Part::File class_getter mime_types : AMIME::Types { AMIME::Types.new } # Returns the path to the file on the filesystem. getter path : String def initialize( path : String | Path, @filename : String? = nil, ) @path = path.to_s end # Attempts to guess the content type of the file based on its path. # Falls back to `application/octet-stream`. def content_type : String if mime_type = self.class.mime_types.mime_types(::File.extname(@path).lstrip('.')).first? return mime_type end "application/octet-stream" end # Returns the size of the file in bytes. def size : Int ::File.size @path end # Returns the name of the file, inferring it based on the basename of its path if not provided explicitly. def filename : String @filename ||= ::File.basename @path end end ================================================ FILE: src/components/mime/src/part/message.cr ================================================ # Represents a part that encapsulates an `AMIME::Message`. class Athena::MIME::Part::Message < Athena::MIME::Part::Data @message : AMIME::Message def initialize( @message : AMIME::Message, ) super "", %(#{@message.headers.header_body("subject")}.eml) end # :inherit: def media_type : String "message" end # :inherit: def media_sub_type : String "rfc822" end # :inherit: def body : String @message.body.to_s end # :inherit: def body_to_s : String self.body end end ================================================ FILE: src/components/mime/src/part/multipart/alternative.cr ================================================ # Represents an `alternative` part. class Athena::MIME::Part::Multipart::Alternative < Athena::MIME::Part::AbstractMultipart # :inherit: def media_sub_type : String "alternative" end end ================================================ FILE: src/components/mime/src/part/multipart/digest.cr ================================================ # Represents a `digest` part. class Athena::MIME::Part::Multipart::Digest < Athena::MIME::Part::AbstractMultipart # :inherit: def media_sub_type : String "digest" end end ================================================ FILE: src/components/mime/src/part/multipart/form.cr ================================================ # Represents a `form-data` part. class Athena::MIME::Part::Multipart::Form < Athena::MIME::Part::AbstractMultipart getter parts : Array(Athena::MIME::Part::Abstract) = Array(Athena::MIME::Part::Abstract).new def initialize( fields : Hash = {} of NoReturn => NoReturn, ) super() self.headers.line_length = Int32::MAX self.prepare_fields fields end # :inherit: def media_sub_type : String "form-data" end private def prepare_fields(fields : Hash) : Nil fields.each do |k, v| self.visit_field k, v end end private def visit_field(key, value, root : String? = nil) : Nil field_name = root ? "#{root}[#{key}]" : key case value when Hash value.each do |k, v| self.visit_field k, v, field_name end return when Array value.each_with_index do |v, idx| self.visit_field idx.to_s, v, field_name end return when String, AMIME::Part::Text self.prepare_part field_name, value else raise AMIME::Exception::InvalidArgument.new "The value of the form field '#{field_name}' can only be a String, Hash, Array, or AMIME::Part::Text instance, got '#{value.class}'." end end private def prepare_part(name : String, value : String | AMIME::Part::Text) : Nil case value in String then self.configure_part name, AMIME::Part::Text.new(value, encoding: "8bit") in AMIME::Part::Text then self.configure_part name, value end end private def configure_part(name : String, part : AMIME::Part::Text) : Nil part.name = name part.disposition = "form-data" part.headers.line_length = Int32::MAX part.encoding = "8bit" @parts << part end end ================================================ FILE: src/components/mime/src/part/multipart/mixed.cr ================================================ # Represents a `mixed` part. class Athena::MIME::Part::Multipart::Mixed < Athena::MIME::Part::AbstractMultipart # :inherit: def media_sub_type : String "mixed" end end ================================================ FILE: src/components/mime/src/part/multipart/related.cr ================================================ # Represents a `related` part. class Athena::MIME::Part::Multipart::Related < Athena::MIME::Part::AbstractMultipart @main_part : AMIME::Part::Abstract def initialize( @main_part : AMIME::Part::Abstract, parts : Enumerable(AMIME::Part::Abstract), ) self.prepare_parts parts super parts end # :inherit: def media_sub_type : String "related" end # :inherit: def parts : Array(AMIME::Part::Abstract) super.unshift @main_part end private def generate_content_id : String "#{Random::Secure.hex(16)}@athena" end private def prepare_parts(parts : Enumerable(AMIME::Part::Abstract)) : Nil parts.each do |part| headers = part.headers unless headers.has_key? "content-id" headers.upsert "content-id", self.generate_content_id, ->headers.add_id_header(String, String) end end end end ================================================ FILE: src/components/mime/src/part/text.cr ================================================ # Represents textual content a part of an email. class Athena::MIME::Part::Text < Athena::MIME::Part::Abstract private DEFAULT_ENCODERS = ["quoted-printable", "base64", "8bit"] @@encoders = Hash(String, AMIME::Encoder::ContentEncoderInterface).new # Controls the `content-disposition` header value for this part. property disposition : String? = nil # Returns the name of this part. property name : String? = nil @body : String | IO | AMIME::Part::File protected setter encoding : String def initialize( body : String | IO | AMIME::Part::File, @charset : String? = "UTF-8", @sub_type : String = "plain", encoding : String? = nil, ) if body.is_a? AMIME::Part::File if !::File::Info.readable?(body.path) || ::File.directory?(body.path) raise AMIME::Exception::InvalidArgument.new "File is not readable." end end @body = body if encoding raise AMIME::Exception::InvalidArgument.new "Unexpected encoding type" unless DEFAULT_ENCODERS.includes? encoding @encoding = encoding else @encoding = choose_encoding end end # :inherit: def media_type : String "text" end # :inherit: def media_sub_type : String @sub_type end protected def body_to_s(io : IO) : Nil io << self.encoder.encode self.body, @charset end # Returns the raw contents of this part as a string. # Use `#body_to_s` to get a properly encoded representation. def body : String case body = @body in AMIME::Part::File ::File.read body.path in String then body in IO body.rewind if body.responds_to? :rewind body.gets_to_end end end def prepared_headers : AMIME::Header::Collection headers = super headers.upsert "content-type", "#{self.media_type}/#{self.media_sub_type}", ->headers.add_parameterized_header(String, String) if charset = @charset headers.header_parameter "content-type", "charset", charset end if (name = @name.presence) && ("form-data" != @disposition) headers.header_parameter "content-type", "name", name end headers.upsert "content-transfer-encoding", @encoding, ->headers.add_text_header(String, String) if !headers.has_key?("content-disposition") && (disposition = @disposition) headers.upsert "content-disposition", disposition, ->headers.add_parameterized_header(String, String) if name = @name headers.header_parameter "content-disposition", "name", name end end headers end private def choose_encoding : String @charset.nil? ? "base64" : "quoted-printable" end private def encoder : AMIME::Encoder::ContentEncoderInterface case @encoding when "8bit" then @@encoders[@encoding] ||= AMIME::Encoder::EightBitContent.new when "quoted-printable" then @@encoders[@encoding] ||= AMIME::Encoder::QuotedPrintableContent.new when "base64" then @@encoders[@encoding] ||= AMIME::Encoder::Base64Content.new else @@encoders[@encoding] end end end ================================================ FILE: src/components/mime/src/types/data.cr ================================================ class Athena::MIME::Types # Map MIME types to their default prefered extension. # # Last updated from upstream on 2025-05-11. private MAP = { "application/acrobat" => {"pdf"}, "application/andrew-inset" => {"ez"}, "application/annodex" => {"anx"}, "application/appinstaller" => {"appinstaller"}, "application/applixware" => {"aw"}, "application/appx" => {"appx"}, "application/appxbundle" => {"appxbundle"}, "application/atom+xml" => {"atom"}, "application/atomcat+xml" => {"atomcat"}, "application/atomdeleted+xml" => {"atomdeleted"}, "application/atomsvc+xml" => {"atomsvc"}, "application/atsc-dwd+xml" => {"dwd"}, "application/atsc-held+xml" => {"held"}, "application/atsc-rsat+xml" => {"rsat"}, "application/automationml-aml+xml" => {"aml"}, "application/automationml-amlx+zip" => {"amlx"}, "application/bat" => {"bat"}, "application/bdoc" => {"bdoc"}, "application/buildstream+yaml" => {"bst"}, "application/bzip2" => {"bz2", "bz"}, "application/calendar+xml" => {"xcs"}, "application/cbor" => {"cbor"}, "application/ccxml+xml" => {"ccxml"}, "application/cdfx+xml" => {"cdfx"}, "application/cdmi-capability" => {"cdmia"}, "application/cdmi-container" => {"cdmic"}, "application/cdmi-domain" => {"cdmid"}, "application/cdmi-object" => {"cdmio"}, "application/cdmi-queue" => {"cdmiq"}, "application/cdr" => {"cdr"}, "application/coreldraw" => {"cdr"}, "application/cpl+xml" => {"cpl"}, "application/csv" => {"csv"}, "application/cu-seeme" => {"cu"}, "application/cwl" => {"cwl"}, "application/dash+xml" => {"mpd"}, "application/dash-patch+xml" => {"mpp"}, "application/davmount+xml" => {"davmount"}, "application/dbase" => {"dbf"}, "application/dbf" => {"dbf"}, "application/dicom" => {"dcm"}, "application/docbook+xml" => {"dbk", "docbook"}, "application/dssc+der" => {"dssc"}, "application/dssc+xml" => {"xdssc"}, "application/ecmascript" => {"ecma", "es"}, "application/emf" => {"emf"}, "application/emma+xml" => {"emma"}, "application/emotionml+xml" => {"emotionml"}, "application/epub+zip" => {"epub"}, "application/exi" => {"exi"}, "application/express" => {"exp"}, "application/fdf" => {"fdf"}, "application/fdt+xml" => {"fdt"}, "application/fits" => {"fits", "fit", "fts"}, "application/font-tdpfr" => {"pfr"}, "application/font-woff" => {"woff"}, "application/futuresplash" => {"swf", "spl"}, "application/geo+json" => {"geojson", "geo.json"}, "application/gml+xml" => {"gml"}, "application/gnunet-directory" => {"gnd"}, "application/gpx" => {"gpx"}, "application/gpx+xml" => {"gpx"}, "application/gxf" => {"gxf"}, "application/gzip" => {"gz"}, "application/hjson" => {"hjson"}, "application/hta" => {"hta"}, "application/hyperstudio" => {"stk"}, "application/ico" => {"ico"}, "application/ics" => {"vcs", "ics", "ifb", "icalendar"}, "application/illustrator" => {"ai"}, "application/inkml+xml" => {"ink", "inkml"}, "application/ipfix" => {"ipfix"}, "application/its+xml" => {"its"}, "application/java" => {"class"}, "application/java-archive" => {"jar", "war", "ear"}, "application/java-byte-code" => {"class"}, "application/java-serialized-object" => {"ser"}, "application/java-vm" => {"class"}, "application/javascript" => {"js", "cjs", "jsm", "mjs"}, "application/jrd+json" => {"jrd"}, "application/json" => {"json", "map"}, "application/json-patch+json" => {"json-patch"}, "application/json5" => {"json5"}, "application/jsonml+json" => {"jsonml"}, "application/ld+json" => {"jsonld"}, "application/lgr+xml" => {"lgr"}, "application/lost+xml" => {"lostxml"}, "application/lotus123" => {"123", "wk1", "wk3", "wk4", "wks"}, "application/m3u" => {"m3u", "m3u8", "vlc"}, "application/mac-binhex40" => {"hqx"}, "application/mac-compactpro" => {"cpt"}, "application/mads+xml" => {"mads"}, "application/manifest+json" => {"webmanifest"}, "application/marc" => {"mrc"}, "application/marcxml+xml" => {"mrcx"}, "application/mathematica" => {"ma", "nb", "mb"}, "application/mathml+xml" => {"mathml", "mml"}, "application/mbox" => {"mbox"}, "application/mdb" => {"mdb"}, "application/media-policy-dataset+xml" => {"mpf"}, "application/mediaservercontrol+xml" => {"mscml"}, "application/metalink+xml" => {"metalink"}, "application/metalink4+xml" => {"meta4"}, "application/mets+xml" => {"mets"}, "application/microsoftpatch" => {"msp"}, "application/microsoftupdate" => {"msu"}, "application/mmt-aei+xml" => {"maei"}, "application/mmt-usd+xml" => {"musd"}, "application/mods+xml" => {"mods"}, "application/mp21" => {"m21", "mp21"}, "application/mp4" => {"mp4", "mpg4", "mp4s", "m4p"}, "application/mrb-consumer+xml" => {"xdf"}, "application/mrb-publish+xml" => {"xdf"}, "application/ms-tnef" => {"tnef", "tnf"}, "application/msaccess" => {"mdb"}, "application/msexcel" => {"xls", "xlc", "xll", "xlm", "xlw", "xla", "xlt", "xld"}, "application/msix" => {"msix"}, "application/msixbundle" => {"msixbundle"}, "application/mspowerpoint" => {"ppz", "ppt", "pps", "pot"}, "application/msword" => {"doc", "dot"}, "application/msword-template" => {"dot"}, "application/mxf" => {"mxf"}, "application/n-quads" => {"nq"}, "application/n-triples" => {"nt"}, "application/nappdf" => {"pdf"}, "application/node" => {"cjs"}, "application/octet-stream" => {"bin", "dms", "lrf", "mar", "so", "dist", "distz", "pkg", "bpk", "dump", "elc", "deploy", "exe", "dll", "deb", "dmg", "iso", "img", "msi", "msp", "msm", "buffer"}, "application/oda" => {"oda"}, "application/oebps-package+xml" => {"opf"}, "application/ogg" => {"ogx"}, "application/omdoc+xml" => {"omdoc"}, "application/onenote" => {"onetoc", "onetoc2", "onetmp", "onepkg"}, "application/ovf" => {"ova"}, "application/owl+xml" => {"owx"}, "application/oxps" => {"oxps"}, "application/p2p-overlay+xml" => {"relo"}, "application/patch-ops-error+xml" => {"xer"}, "application/pcap" => {"pcap", "cap", "dmp"}, "application/pdf" => {"pdf"}, "application/pgp" => {"pgp", "gpg", "asc"}, "application/pgp-encrypted" => {"pgp", "gpg", "asc"}, "application/pgp-keys" => {"asc", "skr", "pkr", "pgp", "gpg", "key"}, "application/pgp-signature" => {"sig", "asc", "pgp", "gpg"}, "application/photoshop" => {"psd"}, "application/pics-rules" => {"prf"}, "application/pkcs10" => {"p10"}, "application/pkcs12" => {"p12", "pfx"}, "application/pkcs7-mime" => {"p7m", "p7c"}, "application/pkcs7-signature" => {"p7s"}, "application/pkcs8" => {"p8"}, "application/pkcs8-encrypted" => {"p8e"}, "application/pkix-attr-cert" => {"ac"}, "application/pkix-cert" => {"cer"}, "application/pkix-crl" => {"crl"}, "application/pkix-pkipath" => {"pkipath"}, "application/pkixcmp" => {"pki"}, "application/pls" => {"pls"}, "application/pls+xml" => {"pls"}, "application/postscript" => {"ai", "eps", "ps"}, "application/powerpoint" => {"ppz", "ppt", "pps", "pot"}, "application/provenance+xml" => {"provx"}, "application/prs.cww" => {"cww"}, "application/prs.wavefront-obj" => {"obj"}, "application/prs.xsf+xml" => {"xsf"}, "application/pskc+xml" => {"pskcxml"}, "application/ram" => {"ram"}, "application/raml+yaml" => {"raml"}, "application/rdf+xml" => {"rdf", "owl", "rdfs"}, "application/reginfo+xml" => {"rif"}, "application/relax-ng-compact-syntax" => {"rnc"}, "application/resource-lists+xml" => {"rl"}, "application/resource-lists-diff+xml" => {"rld"}, "application/rls-services+xml" => {"rs"}, "application/route-apd+xml" => {"rapd"}, "application/route-s-tsid+xml" => {"sls"}, "application/route-usd+xml" => {"rusd"}, "application/rpki-ghostbusters" => {"gbr"}, "application/rpki-manifest" => {"mft"}, "application/rpki-roa" => {"roa"}, "application/rsd+xml" => {"rsd"}, "application/rss+xml" => {"rss"}, "application/rtf" => {"rtf"}, "application/sbml+xml" => {"sbml"}, "application/schema+json" => {"json"}, "application/scvp-cv-request" => {"scq"}, "application/scvp-cv-response" => {"scs"}, "application/scvp-vp-request" => {"spq"}, "application/scvp-vp-response" => {"spp"}, "application/sdp" => {"sdp"}, "application/senml+xml" => {"senmlx"}, "application/sensml+xml" => {"sensmlx"}, "application/set-payment-initiation" => {"setpay"}, "application/set-registration-initiation" => {"setreg"}, "application/shf+xml" => {"shf"}, "application/sieve" => {"siv", "sieve"}, "application/smil" => {"smil", "smi", "sml", "kino"}, "application/smil+xml" => {"smi", "smil", "sml", "kino"}, "application/sparql-query" => {"rq", "qs"}, "application/sparql-results+xml" => {"srx"}, "application/sql" => {"sql"}, "application/srgs" => {"gram"}, "application/srgs+xml" => {"grxml"}, "application/sru+xml" => {"sru"}, "application/ssdl+xml" => {"ssdl"}, "application/ssml+xml" => {"ssml"}, "application/stuffit" => {"sit", "hqx"}, "application/swid+xml" => {"swidtag"}, "application/tei+xml" => {"tei", "teicorpus"}, "application/tga" => {"tga", "icb", "tpic", "vda", "vst"}, "application/thraud+xml" => {"tfi"}, "application/timestamped-data" => {"tsd"}, "application/toml" => {"toml"}, "application/trig" => {"trig"}, "application/ttml+xml" => {"ttml"}, "application/typescript" => {"cts", "mts", "ts"}, "application/ubjson" => {"ubj"}, "application/urc-ressheet+xml" => {"rsheet"}, "application/urc-targetdesc+xml" => {"td"}, "application/vnd.1000minds.decision-model+xml" => {"1km"}, "application/vnd.3gpp.pic-bw-large" => {"plb"}, "application/vnd.3gpp.pic-bw-small" => {"psb"}, "application/vnd.3gpp.pic-bw-var" => {"pvb"}, "application/vnd.3gpp2.tcap" => {"tcap"}, "application/vnd.3m.post-it-notes" => {"pwn"}, "application/vnd.accpac.simply.aso" => {"aso"}, "application/vnd.accpac.simply.imp" => {"imp"}, "application/vnd.acucobol" => {"acu"}, "application/vnd.acucorp" => {"atc", "acutc"}, "application/vnd.adobe.air-application-installer-package+zip" => {"air"}, "application/vnd.adobe.flash.movie" => {"swf", "spl"}, "application/vnd.adobe.formscentral.fcdt" => {"fcdt"}, "application/vnd.adobe.fxp" => {"fxp", "fxpl"}, "application/vnd.adobe.illustrator" => {"ai"}, "application/vnd.adobe.xdp+xml" => {"xdp"}, "application/vnd.adobe.xfdf" => {"xfdf"}, "application/vnd.age" => {"age"}, "application/vnd.ahead.space" => {"ahead"}, "application/vnd.airzip.filesecure.azf" => {"azf"}, "application/vnd.airzip.filesecure.azs" => {"azs"}, "application/vnd.amazon.ebook" => {"azw"}, "application/vnd.amazon.mobi8-ebook" => {"azw3", "kfx"}, "application/vnd.americandynamics.acc" => {"acc"}, "application/vnd.amiga.ami" => {"ami"}, "application/vnd.android.package-archive" => {"apk"}, "application/vnd.anser-web-certificate-issue-initiation" => {"cii"}, "application/vnd.anser-web-funds-transfer-initiation" => {"fti"}, "application/vnd.antix.game-component" => {"atx"}, "application/vnd.apache.parquet" => {"parquet"}, "application/vnd.appimage" => {"appimage"}, "application/vnd.apple.installer+xml" => {"mpkg"}, "application/vnd.apple.keynote" => {"key", "keynote"}, "application/vnd.apple.mpegurl" => {"m3u8", "m3u"}, "application/vnd.apple.numbers" => {"numbers"}, "application/vnd.apple.pages" => {"pages"}, "application/vnd.apple.pkpass" => {"pkpass"}, "application/vnd.apple.pkpasses" => {"pkpasses"}, "application/vnd.aristanetworks.swi" => {"swi"}, "application/vnd.astraea-software.iota" => {"iota"}, "application/vnd.audiograph" => {"aep"}, "application/vnd.balsamiq.bmml+xml" => {"bmml"}, "application/vnd.blueice.multipass" => {"mpm"}, "application/vnd.bmi" => {"bmi"}, "application/vnd.businessobjects" => {"rep"}, "application/vnd.chemdraw+xml" => {"cdxml"}, "application/vnd.chess-pgn" => {"pgn"}, "application/vnd.chipnuts.karaoke-mmd" => {"mmd"}, "application/vnd.cinderella" => {"cdy"}, "application/vnd.citationstyles.style+xml" => {"csl"}, "application/vnd.claymore" => {"cla"}, "application/vnd.cloanto.rp9" => {"rp9"}, "application/vnd.clonk.c4group" => {"c4g", "c4d", "c4f", "c4p", "c4u"}, "application/vnd.cluetrust.cartomobile-config" => {"c11amc"}, "application/vnd.cluetrust.cartomobile-config-pkg" => {"c11amz"}, "application/vnd.coffeescript" => {"coffee"}, "application/vnd.comicbook+zip" => {"cbz"}, "application/vnd.comicbook-rar" => {"cbr"}, "application/vnd.commonspace" => {"csp"}, "application/vnd.contact.cmsg" => {"cdbcmsg"}, "application/vnd.corel-draw" => {"cdr"}, "application/vnd.cosmocaller" => {"cmc"}, "application/vnd.crick.clicker" => {"clkx"}, "application/vnd.crick.clicker.keyboard" => {"clkk"}, "application/vnd.crick.clicker.palette" => {"clkp"}, "application/vnd.crick.clicker.template" => {"clkt"}, "application/vnd.crick.clicker.wordbank" => {"clkw"}, "application/vnd.criticaltools.wbs+xml" => {"wbs"}, "application/vnd.ctc-posml" => {"pml"}, "application/vnd.cups-ppd" => {"ppd"}, "application/vnd.curl.car" => {"car"}, "application/vnd.curl.pcurl" => {"pcurl"}, "application/vnd.dart" => {"dart"}, "application/vnd.data-vision.rdz" => {"rdz"}, "application/vnd.dbf" => {"dbf"}, "application/vnd.debian.binary-package" => {"deb", "udeb"}, "application/vnd.dece.data" => {"uvf", "uvvf", "uvd", "uvvd"}, "application/vnd.dece.ttml+xml" => {"uvt", "uvvt"}, "application/vnd.dece.unspecified" => {"uvx", "uvvx"}, "application/vnd.dece.zip" => {"uvz", "uvvz"}, "application/vnd.denovo.fcselayout-link" => {"fe_launch"}, "application/vnd.dna" => {"dna"}, "application/vnd.dolby.mlp" => {"mlp"}, "application/vnd.dpgraph" => {"dpg"}, "application/vnd.dreamfactory" => {"dfac"}, "application/vnd.ds-keypoint" => {"kpxx"}, "application/vnd.dvb.ait" => {"ait"}, "application/vnd.dvb.service" => {"svc"}, "application/vnd.dynageo" => {"geo"}, "application/vnd.ecowin.chart" => {"mag"}, "application/vnd.efi.img" => {"raw-disk-image", "img"}, "application/vnd.efi.iso" => {"iso", "iso9660"}, "application/vnd.emusic-emusic_package" => {"emp"}, "application/vnd.enliven" => {"nml"}, "application/vnd.epson.esf" => {"esf"}, "application/vnd.epson.msf" => {"msf"}, "application/vnd.epson.quickanime" => {"qam"}, "application/vnd.epson.salt" => {"slt"}, "application/vnd.epson.ssf" => {"ssf"}, "application/vnd.eszigno3+xml" => {"es3", "et3"}, "application/vnd.etsi.asic-e+zip" => {"asice"}, "application/vnd.ezpix-album" => {"ez2"}, "application/vnd.ezpix-package" => {"ez3"}, "application/vnd.fdf" => {"fdf"}, "application/vnd.fdsn.mseed" => {"mseed"}, "application/vnd.fdsn.seed" => {"seed", "dataless"}, "application/vnd.flatpak" => {"flatpak", "xdgapp"}, "application/vnd.flatpak.ref" => {"flatpakref"}, "application/vnd.flatpak.repo" => {"flatpakrepo"}, "application/vnd.flographit" => {"gph"}, "application/vnd.fluxtime.clip" => {"ftc"}, "application/vnd.framemaker" => {"fm", "frame", "maker", "book"}, "application/vnd.frogans.fnc" => {"fnc"}, "application/vnd.frogans.ltf" => {"ltf"}, "application/vnd.fsc.weblaunch" => {"fsc"}, "application/vnd.fujitsu.oasys" => {"oas"}, "application/vnd.fujitsu.oasys2" => {"oa2"}, "application/vnd.fujitsu.oasys3" => {"oa3"}, "application/vnd.fujitsu.oasysgp" => {"fg5"}, "application/vnd.fujitsu.oasysprs" => {"bh2"}, "application/vnd.fujixerox.ddd" => {"ddd"}, "application/vnd.fujixerox.docuworks" => {"xdw"}, "application/vnd.fujixerox.docuworks.binder" => {"xbd"}, "application/vnd.fuzzysheet" => {"fzs"}, "application/vnd.genomatix.tuxedo" => {"txd"}, "application/vnd.geo+json" => {"geojson", "geo.json"}, "application/vnd.geogebra.file" => {"ggb"}, "application/vnd.geogebra.slides" => {"ggs"}, "application/vnd.geogebra.tool" => {"ggt"}, "application/vnd.geometry-explorer" => {"gex", "gre"}, "application/vnd.geonext" => {"gxt"}, "application/vnd.geoplan" => {"g2w"}, "application/vnd.geospace" => {"g3w"}, "application/vnd.gerber" => {"gbr"}, "application/vnd.gmx" => {"gmx"}, "application/vnd.google-apps.document" => {"gdoc"}, "application/vnd.google-apps.presentation" => {"gslides"}, "application/vnd.google-apps.spreadsheet" => {"gsheet"}, "application/vnd.google-earth.kml+xml" => {"kml"}, "application/vnd.google-earth.kmz" => {"kmz"}, "application/vnd.gov.sk.xmldatacontainer+xml" => {"xdcf"}, "application/vnd.grafeq" => {"gqf", "gqs"}, "application/vnd.groove-account" => {"gac"}, "application/vnd.groove-help" => {"ghf"}, "application/vnd.groove-identity-message" => {"gim"}, "application/vnd.groove-injector" => {"grv"}, "application/vnd.groove-tool-message" => {"gtm"}, "application/vnd.groove-tool-template" => {"tpl"}, "application/vnd.groove-vcard" => {"vcg"}, "application/vnd.haansoft-hwp" => {"hwp"}, "application/vnd.haansoft-hwt" => {"hwt"}, "application/vnd.hal+xml" => {"hal"}, "application/vnd.handheld-entertainment+xml" => {"zmm"}, "application/vnd.hbci" => {"hbci"}, "application/vnd.hhe.lesson-player" => {"les"}, "application/vnd.hp-hpgl" => {"hpgl"}, "application/vnd.hp-hpid" => {"hpid"}, "application/vnd.hp-hps" => {"hps"}, "application/vnd.hp-jlyt" => {"jlt"}, "application/vnd.hp-pcl" => {"pcl"}, "application/vnd.hp-pclxl" => {"pclxl"}, "application/vnd.hydrostatix.sof-data" => {"sfd-hdstx"}, "application/vnd.ibm.minipay" => {"mpy"}, "application/vnd.ibm.modcap" => {"afp", "listafp", "list3820"}, "application/vnd.ibm.rights-management" => {"irm"}, "application/vnd.ibm.secure-container" => {"sc"}, "application/vnd.iccprofile" => {"icc", "icm"}, "application/vnd.igloader" => {"igl"}, "application/vnd.immervision-ivp" => {"ivp"}, "application/vnd.immervision-ivu" => {"ivu"}, "application/vnd.insors.igm" => {"igm"}, "application/vnd.intercon.formnet" => {"xpw", "xpx"}, "application/vnd.intergeo" => {"i2g"}, "application/vnd.intu.qbo" => {"qbo"}, "application/vnd.intu.qfx" => {"qfx"}, "application/vnd.ipunplugged.rcprofile" => {"rcprofile"}, "application/vnd.irepository.package+xml" => {"irp"}, "application/vnd.is-xpr" => {"xpr"}, "application/vnd.isac.fcs" => {"fcs"}, "application/vnd.jam" => {"jam"}, "application/vnd.jcp.javame.midlet-rms" => {"rms"}, "application/vnd.jisp" => {"jisp"}, "application/vnd.joost.joda-archive" => {"joda"}, "application/vnd.kahootz" => {"ktz", "ktr"}, "application/vnd.kde.karbon" => {"karbon"}, "application/vnd.kde.kchart" => {"chrt"}, "application/vnd.kde.kformula" => {"kfo"}, "application/vnd.kde.kivio" => {"flw"}, "application/vnd.kde.kontour" => {"kon"}, "application/vnd.kde.kpresenter" => {"kpr", "kpt"}, "application/vnd.kde.kspread" => {"ksp"}, "application/vnd.kde.kword" => {"kwd", "kwt"}, "application/vnd.kenameaapp" => {"htke"}, "application/vnd.kidspiration" => {"kia"}, "application/vnd.kinar" => {"kne", "knp"}, "application/vnd.koan" => {"skp", "skd", "skt", "skm"}, "application/vnd.kodak-descriptor" => {"sse"}, "application/vnd.las.las+xml" => {"lasxml"}, "application/vnd.llamagraphics.life-balance.desktop" => {"lbd"}, "application/vnd.llamagraphics.life-balance.exchange+xml" => {"lbe"}, "application/vnd.lotus-1-2-3" => {"123", "wk1", "wk3", "wk4", "wks"}, "application/vnd.lotus-approach" => {"apr"}, "application/vnd.lotus-freelance" => {"pre"}, "application/vnd.lotus-notes" => {"nsf"}, "application/vnd.lotus-organizer" => {"org"}, "application/vnd.lotus-screencam" => {"scm"}, "application/vnd.lotus-wordpro" => {"lwp"}, "application/vnd.macports.portpkg" => {"portpkg"}, "application/vnd.mapbox-vector-tile" => {"mvt"}, "application/vnd.mcd" => {"mcd"}, "application/vnd.medcalcdata" => {"mc1"}, "application/vnd.mediastation.cdkey" => {"cdkey"}, "application/vnd.mfer" => {"mwf"}, "application/vnd.mfmp" => {"mfm"}, "application/vnd.micrografx.flo" => {"flo"}, "application/vnd.micrografx.igx" => {"igx"}, "application/vnd.microsoft.portable-executable" => {"exe", "dll", "cpl", "drv", "scr", "efi", "ocx", "sys", "lib"}, "application/vnd.mif" => {"mif"}, "application/vnd.mobius.daf" => {"daf"}, "application/vnd.mobius.dis" => {"dis"}, "application/vnd.mobius.mbk" => {"mbk"}, "application/vnd.mobius.mqy" => {"mqy"}, "application/vnd.mobius.msl" => {"msl"}, "application/vnd.mobius.plc" => {"plc"}, "application/vnd.mobius.txf" => {"txf"}, "application/vnd.mophun.application" => {"mpn"}, "application/vnd.mophun.certificate" => {"mpc"}, "application/vnd.mozilla.xul+xml" => {"xul"}, "application/vnd.ms-3mfdocument" => {"3mf"}, "application/vnd.ms-access" => {"mdb"}, "application/vnd.ms-artgalry" => {"cil"}, "application/vnd.ms-asf" => {"asf"}, "application/vnd.ms-cab-compressed" => {"cab"}, "application/vnd.ms-excel" => {"xls", "xlm", "xla", "xlc", "xlt", "xlw", "xll", "xld"}, "application/vnd.ms-excel.addin.macroenabled.12" => {"xlam"}, "application/vnd.ms-excel.sheet.binary.macroenabled.12" => {"xlsb"}, "application/vnd.ms-excel.sheet.macroenabled.12" => {"xlsm"}, "application/vnd.ms-excel.template.macroenabled.12" => {"xltm"}, "application/vnd.ms-fontobject" => {"eot"}, "application/vnd.ms-htmlhelp" => {"chm"}, "application/vnd.ms-ims" => {"ims"}, "application/vnd.ms-lrm" => {"lrm"}, "application/vnd.ms-officetheme" => {"thmx"}, "application/vnd.ms-outlook" => {"msg"}, "application/vnd.ms-pki.seccat" => {"cat"}, "application/vnd.ms-pki.stl" => {"stl"}, "application/vnd.ms-powerpoint" => {"ppt", "pps", "pot", "ppz"}, "application/vnd.ms-powerpoint.addin.macroenabled.12" => {"ppam"}, "application/vnd.ms-powerpoint.presentation.macroenabled.12" => {"pptm"}, "application/vnd.ms-powerpoint.slide.macroenabled.12" => {"sldm"}, "application/vnd.ms-powerpoint.slideshow.macroenabled.12" => {"ppsm"}, "application/vnd.ms-powerpoint.template.macroenabled.12" => {"potm"}, "application/vnd.ms-project" => {"mpp", "mpt"}, "application/vnd.ms-publisher" => {"pub"}, "application/vnd.ms-tnef" => {"tnef", "tnf"}, "application/vnd.ms-visio.drawing.macroenabled.main+xml" => {"vsdm"}, "application/vnd.ms-visio.drawing.main+xml" => {"vsdx"}, "application/vnd.ms-visio.stencil.macroenabled.main+xml" => {"vssm"}, "application/vnd.ms-visio.stencil.main+xml" => {"vssx"}, "application/vnd.ms-visio.template.macroenabled.main+xml" => {"vstm"}, "application/vnd.ms-visio.template.main+xml" => {"vstx"}, "application/vnd.ms-word" => {"doc"}, "application/vnd.ms-word.document.macroenabled.12" => {"docm"}, "application/vnd.ms-word.template.macroenabled.12" => {"dotm"}, "application/vnd.ms-works" => {"wps", "wks", "wcm", "wdb", "xlr"}, "application/vnd.ms-wpl" => {"wpl"}, "application/vnd.ms-xpsdocument" => {"xps"}, "application/vnd.msaccess" => {"mdb"}, "application/vnd.mseq" => {"mseq"}, "application/vnd.musician" => {"mus"}, "application/vnd.muvee.style" => {"msty"}, "application/vnd.mynfc" => {"taglet"}, "application/vnd.nato.bindingdataobject+xml" => {"bdo"}, "application/vnd.neurolanguage.nlu" => {"nlu"}, "application/vnd.nintendo.snes.rom" => {"sfc", "smc"}, "application/vnd.nitf" => {"ntf", "nitf"}, "application/vnd.noblenet-directory" => {"nnd"}, "application/vnd.noblenet-sealer" => {"nns"}, "application/vnd.noblenet-web" => {"nnw"}, "application/vnd.nokia.n-gage.ac+xml" => {"ac"}, "application/vnd.nokia.n-gage.data" => {"ngdat"}, "application/vnd.nokia.n-gage.symbian.install" => {"n-gage"}, "application/vnd.nokia.radio-preset" => {"rpst"}, "application/vnd.nokia.radio-presets" => {"rpss"}, "application/vnd.novadigm.edm" => {"edm"}, "application/vnd.novadigm.edx" => {"edx"}, "application/vnd.novadigm.ext" => {"ext"}, "application/vnd.oasis.docbook+xml" => {"dbk", "docbook"}, "application/vnd.oasis.opendocument.base" => {"odb"}, "application/vnd.oasis.opendocument.chart" => {"odc"}, "application/vnd.oasis.opendocument.chart-template" => {"otc"}, "application/vnd.oasis.opendocument.database" => {"odb"}, "application/vnd.oasis.opendocument.formula" => {"odf"}, "application/vnd.oasis.opendocument.formula-template" => {"odft", "otf"}, "application/vnd.oasis.opendocument.graphics" => {"odg"}, "application/vnd.oasis.opendocument.graphics-flat-xml" => {"fodg"}, "application/vnd.oasis.opendocument.graphics-template" => {"otg"}, "application/vnd.oasis.opendocument.image" => {"odi"}, "application/vnd.oasis.opendocument.image-template" => {"oti"}, "application/vnd.oasis.opendocument.presentation" => {"odp"}, "application/vnd.oasis.opendocument.presentation-flat-xml" => {"fodp"}, "application/vnd.oasis.opendocument.presentation-template" => {"otp"}, "application/vnd.oasis.opendocument.spreadsheet" => {"ods"}, "application/vnd.oasis.opendocument.spreadsheet-flat-xml" => {"fods"}, "application/vnd.oasis.opendocument.spreadsheet-template" => {"ots"}, "application/vnd.oasis.opendocument.text" => {"odt"}, "application/vnd.oasis.opendocument.text-flat-xml" => {"fodt"}, "application/vnd.oasis.opendocument.text-master" => {"odm"}, "application/vnd.oasis.opendocument.text-master-template" => {"otm"}, "application/vnd.oasis.opendocument.text-template" => {"ott"}, "application/vnd.oasis.opendocument.text-web" => {"oth"}, "application/vnd.olpc-sugar" => {"xo"}, "application/vnd.oma.dd2+xml" => {"dd2"}, "application/vnd.openblox.game+xml" => {"obgx"}, "application/vnd.openofficeorg.extension" => {"oxt"}, "application/vnd.openstreetmap.data+xml" => {"osm"}, "application/vnd.openxmlformats-officedocument.presentationml.presentation" => {"pptx"}, "application/vnd.openxmlformats-officedocument.presentationml.slide" => {"sldx"}, "application/vnd.openxmlformats-officedocument.presentationml.slideshow" => {"ppsx"}, "application/vnd.openxmlformats-officedocument.presentationml.template" => {"potx"}, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {"xlsx"}, "application/vnd.openxmlformats-officedocument.spreadsheetml.template" => {"xltx"}, "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {"docx"}, "application/vnd.openxmlformats-officedocument.wordprocessingml.template" => {"dotx"}, "application/vnd.osgeo.mapguide.package" => {"mgp"}, "application/vnd.osgi.dp" => {"dp"}, "application/vnd.osgi.subsystem" => {"esa"}, "application/vnd.palm" => {"pdb", "pqa", "oprc", "prc"}, "application/vnd.pawaafile" => {"paw"}, "application/vnd.pg.format" => {"str"}, "application/vnd.pg.osasli" => {"ei6"}, "application/vnd.picsel" => {"efif"}, "application/vnd.pmi.widget" => {"wg"}, "application/vnd.pocketlearn" => {"plf"}, "application/vnd.powerbuilder6" => {"pbd"}, "application/vnd.previewsystems.box" => {"box"}, "application/vnd.proteus.magazine" => {"mgz"}, "application/vnd.publishare-delta-tree" => {"qps"}, "application/vnd.pvi.ptid1" => {"ptid"}, "application/vnd.pwg-xhtml-print+xml" => {"xhtm"}, "application/vnd.quark.quarkxpress" => {"qxd", "qxt", "qwd", "qwt", "qxl", "qxb", "qxp"}, "application/vnd.rar" => {"rar"}, "application/vnd.realvnc.bed" => {"bed"}, "application/vnd.recordare.musicxml" => {"mxl"}, "application/vnd.recordare.musicxml+xml" => {"musicxml"}, "application/vnd.rig.cryptonote" => {"cryptonote"}, "application/vnd.rim.cod" => {"cod"}, "application/vnd.rn-realmedia" => {"rm", "rmj", "rmm", "rms", "rmx", "rmvb"}, "application/vnd.rn-realmedia-vbr" => {"rmvb", "rm", "rmj", "rmm", "rms", "rmx"}, "application/vnd.route66.link66+xml" => {"link66"}, "application/vnd.sailingtracker.track" => {"st"}, "application/vnd.sdp" => {"sdp"}, "application/vnd.seemail" => {"see"}, "application/vnd.sema" => {"sema"}, "application/vnd.semd" => {"semd"}, "application/vnd.semf" => {"semf"}, "application/vnd.shana.informed.formdata" => {"ifm"}, "application/vnd.shana.informed.formtemplate" => {"itp"}, "application/vnd.shana.informed.interchange" => {"iif"}, "application/vnd.shana.informed.package" => {"ipk"}, "application/vnd.simtech-mindmapper" => {"twd", "twds"}, "application/vnd.smaf" => {"mmf", "smaf"}, "application/vnd.smart.teacher" => {"teacher"}, "application/vnd.snap" => {"snap"}, "application/vnd.software602.filler.form+xml" => {"fo"}, "application/vnd.solent.sdkm+xml" => {"sdkm", "sdkd"}, "application/vnd.spotfire.dxp" => {"dxp"}, "application/vnd.spotfire.sfs" => {"sfs"}, "application/vnd.sqlite3" => {"sqlite3"}, "application/vnd.squashfs" => {"sfs", "sqfs", "sqsh", "squashfs"}, "application/vnd.stardivision.calc" => {"sdc"}, "application/vnd.stardivision.chart" => {"sds"}, "application/vnd.stardivision.draw" => {"sda"}, "application/vnd.stardivision.impress" => {"sdd"}, "application/vnd.stardivision.impress-packed" => {"sdp"}, "application/vnd.stardivision.mail" => {"sdm"}, "application/vnd.stardivision.math" => {"smf"}, "application/vnd.stardivision.writer" => {"sdw", "vor"}, "application/vnd.stardivision.writer-global" => {"sgl"}, "application/vnd.stepmania.package" => {"smzip"}, "application/vnd.stepmania.stepchart" => {"sm"}, "application/vnd.sun.wadl+xml" => {"wadl"}, "application/vnd.sun.xml.base" => {"odb"}, "application/vnd.sun.xml.calc" => {"sxc"}, "application/vnd.sun.xml.calc.template" => {"stc"}, "application/vnd.sun.xml.draw" => {"sxd"}, "application/vnd.sun.xml.draw.template" => {"std"}, "application/vnd.sun.xml.impress" => {"sxi"}, "application/vnd.sun.xml.impress.template" => {"sti"}, "application/vnd.sun.xml.math" => {"sxm"}, "application/vnd.sun.xml.writer" => {"sxw"}, "application/vnd.sun.xml.writer.global" => {"sxg"}, "application/vnd.sun.xml.writer.template" => {"stw"}, "application/vnd.sus-calendar" => {"sus", "susp"}, "application/vnd.svd" => {"svd"}, "application/vnd.symbian.install" => {"sis", "sisx"}, "application/vnd.syncml+xml" => {"xsm"}, "application/vnd.syncml.dm+wbxml" => {"bdm"}, "application/vnd.syncml.dm+xml" => {"xdm"}, "application/vnd.syncml.dmddf+xml" => {"ddf"}, "application/vnd.tao.intent-module-archive" => {"tao"}, "application/vnd.tcpdump.pcap" => {"pcap", "cap", "dmp"}, "application/vnd.tmobile-livetv" => {"tmo"}, "application/vnd.trid.tpt" => {"tpt"}, "application/vnd.triscape.mxs" => {"mxs"}, "application/vnd.trueapp" => {"tra"}, "application/vnd.truedoc" => {"pfr"}, "application/vnd.ufdl" => {"ufd", "ufdl"}, "application/vnd.uiq.theme" => {"utz"}, "application/vnd.umajin" => {"umj"}, "application/vnd.unity" => {"unityweb"}, "application/vnd.uoml+xml" => {"uoml", "uo"}, "application/vnd.vcx" => {"vcx"}, "application/vnd.visio" => {"vsd", "vst", "vss", "vsw"}, "application/vnd.visionary" => {"vis"}, "application/vnd.vsf" => {"vsf"}, "application/vnd.wap.wbxml" => {"wbxml"}, "application/vnd.wap.wmlc" => {"wmlc"}, "application/vnd.wap.wmlscriptc" => {"wmlsc"}, "application/vnd.webturbo" => {"wtb"}, "application/vnd.wolfram.player" => {"nbp"}, "application/vnd.wordperfect" => {"wpd", "wp", "wp4", "wp5", "wp6", "wpp"}, "application/vnd.wqd" => {"wqd"}, "application/vnd.wt.stf" => {"stf"}, "application/vnd.xara" => {"xar"}, "application/vnd.xdgapp" => {"flatpak", "xdgapp"}, "application/vnd.xfdl" => {"xfdl"}, "application/vnd.yamaha.hv-dic" => {"hvd"}, "application/vnd.yamaha.hv-script" => {"hvs"}, "application/vnd.yamaha.hv-voice" => {"hvp"}, "application/vnd.yamaha.openscoreformat" => {"osf"}, "application/vnd.yamaha.openscoreformat.osfpvg+xml" => {"osfpvg"}, "application/vnd.yamaha.smaf-audio" => {"saf"}, "application/vnd.yamaha.smaf-phrase" => {"spf"}, "application/vnd.yellowriver-custom-menu" => {"cmp"}, "application/vnd.youtube.yt" => {"yt"}, "application/vnd.zul" => {"zir", "zirz"}, "application/vnd.zzazz.deck+xml" => {"zaz"}, "application/voicexml+xml" => {"vxml"}, "application/wasm" => {"wasm"}, "application/watcherinfo+xml" => {"wif"}, "application/widget" => {"wgt"}, "application/winhlp" => {"hlp"}, "application/wk1" => {"123", "wk1", "wk3", "wk4", "wks"}, "application/wmf" => {"wmf"}, "application/wordperfect" => {"wp", "wp4", "wp5", "wp6", "wpd", "wpp"}, "application/wsdl+xml" => {"wsdl"}, "application/wspolicy+xml" => {"wspolicy"}, "application/wwf" => {"wwf"}, "application/x-123" => {"123", "wk1", "wk3", "wk4", "wks"}, "application/x-7z-compressed" => {"7z", "7z.001"}, "application/x-abiword" => {"abw", "abw.CRASHED", "abw.gz", "zabw"}, "application/x-ace" => {"ace"}, "application/x-ace-compressed" => {"ace"}, "application/x-alz" => {"alz"}, "application/x-amiga-disk-format" => {"adf"}, "application/x-amipro" => {"sam"}, "application/x-annodex" => {"anx"}, "application/x-aportisdoc" => {"pdb", "pdc"}, "application/x-apple-diskimage" => {"dmg"}, "application/x-apple-systemprofiler+xml" => {"spx"}, "application/x-appleworks-document" => {"cwk"}, "application/x-applix-spreadsheet" => {"as"}, "application/x-applix-word" => {"aw"}, "application/x-archive" => {"a", "ar", "lib"}, "application/x-arj" => {"arj"}, "application/x-asar" => {"asar"}, "application/x-asp" => {"asp"}, "application/x-atari-2600-rom" => {"a26"}, "application/x-atari-7800-rom" => {"a78"}, "application/x-atari-lynx-rom" => {"lnx"}, "application/x-authorware-bin" => {"aab", "x32", "u32", "vox"}, "application/x-authorware-map" => {"aam"}, "application/x-authorware-seg" => {"aas"}, "application/x-awk" => {"awk"}, "application/x-bat" => {"bat"}, "application/x-bcpio" => {"bcpio"}, "application/x-bdoc" => {"bdoc"}, "application/x-bittorrent" => {"torrent"}, "application/x-blender" => {"blend", "blender"}, "application/x-blorb" => {"blb", "blorb"}, "application/x-bps-patch" => {"bps"}, "application/x-bsdiff" => {"bsdiff"}, "application/x-bz2" => {"bz2"}, "application/x-bzdvi" => {"dvi.bz2"}, "application/x-bzip" => {"bz", "bz2"}, "application/x-bzip-compressed-tar" => {"tar.bz2", "tbz2", "tb2"}, "application/x-bzip1" => {"bz"}, "application/x-bzip1-compressed-tar" => {"tar.bz", "tbz"}, "application/x-bzip2" => {"bz2", "boz"}, "application/x-bzip2-compressed-tar" => {"tar.bz2", "tbz2", "tb2"}, "application/x-bzip3" => {"bz3"}, "application/x-bzip3-compressed-tar" => {"tar.bz3", "tbz3"}, "application/x-bzpdf" => {"pdf.bz2"}, "application/x-bzpostscript" => {"ps.bz2"}, "application/x-cb7" => {"cb7"}, "application/x-cbr" => {"cbr", "cba", "cbt", "cbz", "cb7"}, "application/x-cbt" => {"cbt"}, "application/x-cbz" => {"cbz"}, "application/x-ccmx" => {"ccmx"}, "application/x-cd-image" => {"iso", "iso9660"}, "application/x-cdlink" => {"vcd"}, "application/x-cdr" => {"cdr"}, "application/x-cdrdao-toc" => {"toc"}, "application/x-cfs-compressed" => {"cfs"}, "application/x-chat" => {"chat"}, "application/x-chess-pgn" => {"pgn"}, "application/x-chm" => {"chm"}, "application/x-chrome-extension" => {"crx"}, "application/x-cisco-vpn-settings" => {"pcf"}, "application/x-cocoa" => {"cco"}, "application/x-compress" => {"Z"}, "application/x-compressed-iso" => {"cso"}, "application/x-compressed-tar" => {"tar.gz", "tgz"}, "application/x-conference" => {"nsc"}, "application/x-coreldraw" => {"cdr"}, "application/x-cpio" => {"cpio"}, "application/x-cpio-compressed" => {"cpio.gz"}, "application/x-csh" => {"csh"}, "application/x-cue" => {"cue"}, "application/x-dar" => {"dar"}, "application/x-dbase" => {"dbf"}, "application/x-dbf" => {"dbf"}, "application/x-dc-rom" => {"dc"}, "application/x-deb" => {"deb", "udeb"}, "application/x-debian-package" => {"deb", "udeb"}, "application/x-designer" => {"ui"}, "application/x-desktop" => {"desktop", "kdelnk"}, "application/x-dgc-compressed" => {"dgc"}, "application/x-dia-diagram" => {"dia"}, "application/x-dia-shape" => {"shape"}, "application/x-director" => {"dir", "dcr", "dxr", "cst", "cct", "cxt", "w3d", "fgd", "swa"}, "application/x-discjuggler-cd-image" => {"cdi"}, "application/x-docbook+xml" => {"dbk", "docbook"}, "application/x-doom" => {"wad"}, "application/x-doom-wad" => {"wad"}, "application/x-dosexec" => {"exe"}, "application/x-dreamcast-rom" => {"iso"}, "application/x-dtbncx+xml" => {"ncx"}, "application/x-dtbook+xml" => {"dtb"}, "application/x-dtbresource+xml" => {"res"}, "application/x-dvi" => {"dvi"}, "application/x-e-theme" => {"etheme"}, "application/x-egon" => {"egon"}, "application/x-emf" => {"emf"}, "application/x-envoy" => {"evy"}, "application/x-eris-link+cbor" => {"eris"}, "application/x-eva" => {"eva"}, "application/x-excellon" => {"drl"}, "application/x-fd-file" => {"fd", "qd"}, "application/x-fds-disk" => {"fds"}, "application/x-fictionbook" => {"fb2"}, "application/x-fictionbook+xml" => {"fb2"}, "application/x-fishscript" => {"fish"}, "application/x-flash-video" => {"flv"}, "application/x-fluid" => {"fl"}, "application/x-font-afm" => {"afm"}, "application/x-font-bdf" => {"bdf"}, "application/x-font-ghostscript" => {"gsf"}, "application/x-font-linux-psf" => {"psf"}, "application/x-font-otf" => {"otf"}, "application/x-font-pcf" => {"pcf", "pcf.Z", "pcf.gz"}, "application/x-font-snf" => {"snf"}, "application/x-font-speedo" => {"spd"}, "application/x-font-truetype" => {"ttf"}, "application/x-font-ttf" => {"ttf"}, "application/x-font-ttx" => {"ttx"}, "application/x-font-type1" => {"pfa", "pfb", "pfm", "afm", "gsf"}, "application/x-font-woff" => {"woff"}, "application/x-frame" => {"fm"}, "application/x-freearc" => {"arc"}, "application/x-futuresplash" => {"spl"}, "application/x-gameboy-color-rom" => {"gbc", "cgb"}, "application/x-gameboy-rom" => {"gb", "sgb"}, "application/x-gamecube-iso-image" => {"iso"}, "application/x-gamecube-rom" => {"iso"}, "application/x-gamegear-rom" => {"gg"}, "application/x-gba-rom" => {"gba", "agb"}, "application/x-gca-compressed" => {"gca"}, "application/x-gd-rom-cue" => {"gdi"}, "application/x-gdscript" => {"gd"}, "application/x-gedcom" => {"ged", "gedcom"}, "application/x-genesis-32x-rom" => {"32x", "mdx"}, "application/x-genesis-rom" => {"gen", "smd", "md", "sgd"}, "application/x-gerber" => {"gbr"}, "application/x-gerber-job" => {"gbrjob"}, "application/x-gettext" => {"po"}, "application/x-gettext-translation" => {"gmo", "mo"}, "application/x-glade" => {"glade"}, "application/x-glulx" => {"ulx"}, "application/x-gnome-app-info" => {"desktop", "kdelnk"}, "application/x-gnucash" => {"gnucash", "gnc", "xac"}, "application/x-gnumeric" => {"gnumeric"}, "application/x-gnuplot" => {"gp", "gplt", "gnuplot"}, "application/x-go-sgf" => {"sgf"}, "application/x-godot-resource" => {"res", "tres"}, "application/x-godot-scene" => {"scn", "tscn", "escn"}, "application/x-godot-shader" => {"gdshader"}, "application/x-gpx" => {"gpx"}, "application/x-gpx+xml" => {"gpx"}, "application/x-gramps-xml" => {"gramps"}, "application/x-graphite" => {"gra"}, "application/x-gtar" => {"gtar", "tar", "gem"}, "application/x-gtk-builder" => {"ui"}, "application/x-gz-font-linux-psf" => {"psf.gz"}, "application/x-gzdvi" => {"dvi.gz"}, "application/x-gzip" => {"gz"}, "application/x-gzpdf" => {"pdf.gz"}, "application/x-gzpostscript" => {"ps.gz"}, "application/x-hdf" => {"hdf", "hdf4", "h4", "hdf5", "h5"}, "application/x-hfe-file" => {"hfe"}, "application/x-hfe-floppy-image" => {"hfe"}, "application/x-httpd-php" => {"php"}, "application/x-hwp" => {"hwp"}, "application/x-hwt" => {"hwt"}, "application/x-ica" => {"ica"}, "application/x-install-instructions" => {"install"}, "application/x-ips-patch" => {"ips"}, "application/x-ipynb+json" => {"ipynb"}, "application/x-iso9660-appimage" => {"appimage"}, "application/x-iso9660-image" => {"iso", "iso9660"}, "application/x-it87" => {"it87"}, "application/x-iwork-keynote-sffkey" => {"key"}, "application/x-iwork-numbers-sffnumbers" => {"numbers"}, "application/x-iwork-pages-sffpages" => {"pages"}, "application/x-jar" => {"jar"}, "application/x-java" => {"class"}, "application/x-java-archive" => {"jar"}, "application/x-java-archive-diff" => {"jardiff"}, "application/x-java-class" => {"class"}, "application/x-java-jce-keystore" => {"jceks"}, "application/x-java-jnlp-file" => {"jnlp"}, "application/x-java-keystore" => {"jks", "ks"}, "application/x-java-pack200" => {"pack"}, "application/x-java-vm" => {"class"}, "application/x-javascript" => {"cjs", "js", "jsm", "mjs"}, "application/x-jbuilder-project" => {"jpr", "jpx"}, "application/x-karbon" => {"karbon"}, "application/x-kchart" => {"chrt"}, "application/x-keepass2" => {"kdbx"}, "application/x-kexi-connectiondata" => {"kexic"}, "application/x-kexiproject-shortcut" => {"kexis"}, "application/x-kexiproject-sqlite" => {"kexi"}, "application/x-kexiproject-sqlite2" => {"kexi"}, "application/x-kexiproject-sqlite3" => {"kexi"}, "application/x-kformula" => {"kfo"}, "application/x-killustrator" => {"kil"}, "application/x-kivio" => {"flw"}, "application/x-kontour" => {"kon"}, "application/x-kpovmodeler" => {"kpm"}, "application/x-kpresenter" => {"kpr", "kpt"}, "application/x-krita" => {"kra", "krz"}, "application/x-kspread" => {"ksp"}, "application/x-kugar" => {"kud"}, "application/x-kword" => {"kwd", "kwt"}, "application/x-latex" => {"latex"}, "application/x-lha" => {"lha", "lzh"}, "application/x-lhz" => {"lhz"}, "application/x-linguist" => {"ts"}, "application/x-lmdb" => {"mdb", "lmdb"}, "application/x-lotus123" => {"123", "wk1", "wk3", "wk4", "wks"}, "application/x-lrzip" => {"lrz"}, "application/x-lrzip-compressed-tar" => {"tar.lrz", "tlrz"}, "application/x-lua-bytecode" => {"luac"}, "application/x-lyx" => {"lyx"}, "application/x-lz4" => {"lz4"}, "application/x-lz4-compressed-tar" => {"tar.lz4"}, "application/x-lzh-compressed" => {"lzh", "lha"}, "application/x-lzip" => {"lz"}, "application/x-lzip-compressed-tar" => {"tar.lz"}, "application/x-lzma" => {"lzma"}, "application/x-lzma-compressed-tar" => {"tar.lzma", "tlz"}, "application/x-lzop" => {"lzo"}, "application/x-lzpdf" => {"pdf.lz"}, "application/x-m4" => {"m4"}, "application/x-magicpoint" => {"mgp"}, "application/x-makeself" => {"run"}, "application/x-mame-chd" => {"chd"}, "application/x-markaby" => {"mab"}, "application/x-mathematica" => {"nb"}, "application/x-mdb" => {"mdb"}, "application/x-mie" => {"mie"}, "application/x-mif" => {"mif"}, "application/x-mimearchive" => {"mhtml", "mht"}, "application/x-mobi8-ebook" => {"azw3", "kfx"}, "application/x-mobipocket-ebook" => {"prc", "mobi"}, "application/x-modrinth-modpack+zip" => {"mrpack"}, "application/x-ms-application" => {"application"}, "application/x-ms-asx" => {"asx", "wax", "wvx", "wmx"}, "application/x-ms-dos-executable" => {"exe", "dll", "cpl", "drv", "scr"}, "application/x-ms-ne-executable" => {"exe", "dll", "cpl", "drv", "scr"}, "application/x-ms-pdb" => {"pdb"}, "application/x-ms-shortcut" => {"lnk"}, "application/x-ms-wim" => {"wim", "swm"}, "application/x-ms-wmd" => {"wmd"}, "application/x-ms-wmz" => {"wmz"}, "application/x-ms-xbap" => {"xbap"}, "application/x-msaccess" => {"mdb"}, "application/x-msbinder" => {"obd"}, "application/x-mscardfile" => {"crd"}, "application/x-msclip" => {"clp"}, "application/x-msdos-program" => {"exe"}, "application/x-msdownload" => {"exe", "dll", "com", "bat", "msi", "cpl", "drv", "scr"}, "application/x-msexcel" => {"xls", "xlc", "xll", "xlm", "xlw", "xla", "xlt", "xld"}, "application/x-msi" => {"msi"}, "application/x-msmediaview" => {"mvb", "m13", "m14"}, "application/x-msmetafile" => {"wmf", "wmz", "emf", "emz"}, "application/x-msmoney" => {"mny"}, "application/x-mspowerpoint" => {"ppz", "ppt", "pps", "pot"}, "application/x-mspublisher" => {"pub"}, "application/x-msschedule" => {"scd"}, "application/x-msterminal" => {"trm"}, "application/x-mswinurl" => {"url"}, "application/x-msword" => {"doc"}, "application/x-mswrite" => {"wri"}, "application/x-msx-rom" => {"msx"}, "application/x-n64-rom" => {"n64", "z64", "v64"}, "application/x-navi-animation" => {"ani"}, "application/x-neo-geo-pocket-color-rom" => {"ngc"}, "application/x-neo-geo-pocket-rom" => {"ngp"}, "application/x-nes-rom" => {"nes", "nez", "unf", "unif"}, "application/x-netcdf" => {"nc", "cdf"}, "application/x-netshow-channel" => {"nsc"}, "application/x-nintendo-3ds-executable" => {"3dsx"}, "application/x-nintendo-3ds-rom" => {"3ds", "cci"}, "application/x-nintendo-ds-rom" => {"nds"}, "application/x-nintendo-switch-xci" => {"xci"}, "application/x-ns-proxy-autoconfig" => {"pac"}, "application/x-nuscript" => {"nu"}, "application/x-nx-xci" => {"xci"}, "application/x-nzb" => {"nzb"}, "application/x-object" => {"o", "mod"}, "application/x-ogg" => {"ogx"}, "application/x-oleo" => {"oleo"}, "application/x-openvpn-profile" => {"openvpn", "ovpn"}, "application/x-openzim" => {"zim"}, "application/x-pagemaker" => {"p65", "pm", "pm6", "pmd"}, "application/x-pak" => {"pak"}, "application/x-palm-database" => {"prc", "pdb", "pqa", "oprc"}, "application/x-par2" => {"PAR2", "par2"}, "application/x-parquet" => {"parquet"}, "application/x-partial-download" => {"wkdownload", "crdownload", "part"}, "application/x-pc-engine-rom" => {"pce"}, "application/x-pcap" => {"pcap", "cap", "dmp"}, "application/x-pcapng" => {"pcapng", "scap", "ntar"}, "application/x-pdf" => {"pdf"}, "application/x-perl" => {"pl", "pm", "PL", "al", "perl", "pod", "t"}, "application/x-photoshop" => {"psd"}, "application/x-php" => {"php", "php3", "php4", "php5", "phps"}, "application/x-pilot" => {"prc", "pdb"}, "application/x-pkcs12" => {"p12", "pfx"}, "application/x-pkcs7-certificates" => {"p7b", "spc"}, "application/x-pkcs7-certreqresp" => {"p7r"}, "application/x-planperfect" => {"pln"}, "application/x-pocket-word" => {"psw"}, "application/x-powershell" => {"ps1"}, "application/x-pw" => {"pw"}, "application/x-pyspread-bz-spreadsheet" => {"pys"}, "application/x-pyspread-spreadsheet" => {"pysu"}, "application/x-python-bytecode" => {"pyc", "pyo"}, "application/x-qbrew" => {"qbrew"}, "application/x-qed-disk" => {"qed"}, "application/x-qemu-disk" => {"qcow2", "qcow"}, "application/x-qpress" => {"qp"}, "application/x-qtiplot" => {"qti", "qti.gz"}, "application/x-quattropro" => {"wb1", "wb2", "wb3", "qpw"}, "application/x-quicktime-media-link" => {"qtl"}, "application/x-quicktimeplayer" => {"qtl"}, "application/x-qw" => {"qif"}, "application/x-rar" => {"rar"}, "application/x-rar-compressed" => {"rar"}, "application/x-raw-disk-image" => {"raw-disk-image", "img"}, "application/x-raw-disk-image-xz-compressed" => {"raw-disk-image.xz", "img.xz"}, "application/x-raw-floppy-disk-image" => {"fd", "qd"}, "application/x-redhat-package-manager" => {"rpm"}, "application/x-reject" => {"rej"}, "application/x-research-info-systems" => {"ris"}, "application/x-rnc" => {"rnc"}, "application/x-rpm" => {"rpm"}, "application/x-ruby" => {"rb"}, "application/x-rzip" => {"rz"}, "application/x-rzip-compressed-tar" => {"tar.rz", "trz"}, "application/x-sami" => {"smi", "sami"}, "application/x-sap-file" => {"sap"}, "application/x-saturn-rom" => {"iso"}, "application/x-sdp" => {"sdp"}, "application/x-sea" => {"sea"}, "application/x-sega-cd-rom" => {"iso"}, "application/x-sega-pico-rom" => {"iso"}, "application/x-sg1000-rom" => {"sg"}, "application/x-sh" => {"sh"}, "application/x-shar" => {"shar"}, "application/x-shared-library-la" => {"la"}, "application/x-sharedlib" => {"so", "so.[0-9]*"}, "application/x-shellscript" => {"sh"}, "application/x-shockwave-flash" => {"swf", "spl"}, "application/x-shorten" => {"shn"}, "application/x-siag" => {"siag"}, "application/x-silverlight-app" => {"xap"}, "application/x-sit" => {"sit"}, "application/x-sitx" => {"sitx"}, "application/x-smaf" => {"mmf", "smaf"}, "application/x-sms-rom" => {"sms"}, "application/x-snes-rom" => {"sfc", "smc"}, "application/x-sony-bbeb" => {"lrf"}, "application/x-source-rpm" => {"src.rpm", "spm"}, "application/x-spss-por" => {"por"}, "application/x-spss-sav" => {"sav", "zsav"}, "application/x-spss-savefile" => {"sav", "zsav"}, "application/x-sql" => {"sql"}, "application/x-sqlite2" => {"sqlite2"}, "application/x-sqlite3" => {"sqlite3"}, "application/x-srt" => {"srt"}, "application/x-starcalc" => {"sdc"}, "application/x-starchart" => {"sds"}, "application/x-stardraw" => {"sda"}, "application/x-starimpress" => {"sdd"}, "application/x-starmail" => {"smd"}, "application/x-starmath" => {"smf"}, "application/x-starwriter" => {"sdw", "vor"}, "application/x-starwriter-global" => {"sgl"}, "application/x-stuffit" => {"sit"}, "application/x-stuffitx" => {"sitx"}, "application/x-subrip" => {"srt"}, "application/x-sv4cpio" => {"sv4cpio"}, "application/x-sv4crc" => {"sv4crc"}, "application/x-sylk" => {"sylk", "slk"}, "application/x-t3vm-image" => {"t3"}, "application/x-t602" => {"602"}, "application/x-tads" => {"gam"}, "application/x-tar" => {"tar", "gtar", "gem"}, "application/x-targa" => {"tga", "icb", "tpic", "vda", "vst"}, "application/x-tarz" => {"tar.Z", "taz"}, "application/x-tcl" => {"tcl", "tk"}, "application/x-tex" => {"tex", "ltx", "sty", "cls", "dtx", "ins", "latex"}, "application/x-tex-gf" => {"gf"}, "application/x-tex-pk" => {"pk"}, "application/x-tex-tfm" => {"tfm"}, "application/x-texinfo" => {"texinfo", "texi"}, "application/x-tga" => {"tga", "icb", "tpic", "vda", "vst"}, "application/x-tgif" => {"obj"}, "application/x-theme" => {"theme"}, "application/x-thomson-cartridge-memo7" => {"m7"}, "application/x-thomson-cassette" => {"k7"}, "application/x-thomson-sap-image" => {"sap"}, "application/x-tiled-tmx" => {"tmx"}, "application/x-tiled-tsx" => {"tsx"}, "application/x-trash" => {"bak", "old", "sik"}, "application/x-trig" => {"trig"}, "application/x-troff" => {"tr", "roff", "t"}, "application/x-troff-man" => {"man", "[1-9]"}, "application/x-tzo" => {"tar.lzo", "tzo"}, "application/x-ufraw" => {"ufraw"}, "application/x-ustar" => {"ustar"}, "application/x-vdi-disk" => {"vdi"}, "application/x-vhd-disk" => {"vhd", "vpc"}, "application/x-vhdx-disk" => {"vhdx"}, "application/x-virtual-boy-rom" => {"vb"}, "application/x-virtualbox-hdd" => {"hdd"}, "application/x-virtualbox-ova" => {"ova"}, "application/x-virtualbox-ovf" => {"ovf"}, "application/x-virtualbox-vbox" => {"vbox"}, "application/x-virtualbox-vbox-extpack" => {"vbox-extpack"}, "application/x-virtualbox-vdi" => {"vdi"}, "application/x-virtualbox-vhd" => {"vhd", "vpc"}, "application/x-virtualbox-vhdx" => {"vhdx"}, "application/x-virtualbox-vmdk" => {"vmdk"}, "application/x-vmdk-disk" => {"vmdk"}, "application/x-vnd.kde.kexi" => {"kexi"}, "application/x-wais-source" => {"src"}, "application/x-wbfs" => {"iso"}, "application/x-web-app-manifest+json" => {"webapp"}, "application/x-wia" => {"iso"}, "application/x-wii-iso-image" => {"iso"}, "application/x-wii-rom" => {"iso"}, "application/x-wii-wad" => {"wad"}, "application/x-win-lnk" => {"lnk"}, "application/x-windows-themepack" => {"themepack"}, "application/x-wmf" => {"wmf"}, "application/x-wonderswan-color-rom" => {"wsc"}, "application/x-wonderswan-rom" => {"ws"}, "application/x-wordperfect" => {"wp", "wp4", "wp5", "wp6", "wpd", "wpp"}, "application/x-wpg" => {"wpg"}, "application/x-wwf" => {"wwf"}, "application/x-x509-ca-cert" => {"der", "crt", "pem", "cert"}, "application/x-xar" => {"xar", "pkg"}, "application/x-xbel" => {"xbel"}, "application/x-xfig" => {"fig"}, "application/x-xliff" => {"xlf", "xliff"}, "application/x-xliff+xml" => {"xlf"}, "application/x-xpinstall" => {"xpi"}, "application/x-xspf+xml" => {"xspf"}, "application/x-xz" => {"xz"}, "application/x-xz-compressed-tar" => {"tar.xz", "txz"}, "application/x-xzpdf" => {"pdf.xz"}, "application/x-yaml" => {"yaml", "yml"}, "application/x-zip" => {"zip", "zipx"}, "application/x-zip-compressed" => {"zip", "zipx"}, "application/x-zip-compressed-fb2" => {"fb2.zip"}, "application/x-zmachine" => {"z1", "z2", "z3", "z4", "z5", "z6", "z7", "z8"}, "application/x-zoo" => {"zoo"}, "application/x-zpaq" => {"zpaq"}, "application/x-zstd-compressed-tar" => {"tar.zst", "tzst"}, "application/xaml+xml" => {"xaml"}, "application/xcap-att+xml" => {"xav"}, "application/xcap-caps+xml" => {"xca"}, "application/xcap-diff+xml" => {"xdf"}, "application/xcap-el+xml" => {"xel"}, "application/xcap-error+xml" => {"xer"}, "application/xcap-ns+xml" => {"xns"}, "application/xenc+xml" => {"xenc"}, "application/xfdf" => {"xfdf"}, "application/xhtml+xml" => {"xhtml", "xht", "html", "htm"}, "application/xliff+xml" => {"xlf", "xliff"}, "application/xml" => {"xml", "xsl", "xsd", "rng", "xbl"}, "application/xml-dtd" => {"dtd"}, "application/xml-external-parsed-entity" => {"ent"}, "application/xop+xml" => {"xop"}, "application/xproc+xml" => {"xpl"}, "application/xps" => {"xps"}, "application/xslt+xml" => {"xsl", "xslt"}, "application/xspf+xml" => {"xspf"}, "application/xv+xml" => {"mxml", "xhvml", "xvml", "xvm"}, "application/yaml" => {"yaml", "yml"}, "application/yang" => {"yang"}, "application/yin+xml" => {"yin"}, "application/zip" => {"zip", "zipx"}, "application/zlib" => {"zz"}, "application/zstd" => {"zst"}, "audio/3gpp" => {"3gpp", "3gp", "3ga"}, "audio/3gpp-encrypted" => {"3gp", "3gpp", "3ga"}, "audio/3gpp2" => {"3g2", "3gp2", "3gpp2"}, "audio/aac" => {"aac", "adts", "ass"}, "audio/ac3" => {"ac3"}, "audio/adpcm" => {"adp"}, "audio/amr" => {"amr"}, "audio/amr-encrypted" => {"amr"}, "audio/amr-wb" => {"awb"}, "audio/amr-wb-encrypted" => {"awb"}, "audio/annodex" => {"axa"}, "audio/basic" => {"au", "snd"}, "audio/dff" => {"dff"}, "audio/dsd" => {"dsf"}, "audio/dsf" => {"dsf"}, "audio/flac" => {"flac"}, "audio/imelody" => {"imy", "ime"}, "audio/m3u" => {"m3u", "m3u8", "vlc"}, "audio/m4a" => {"m4a", "f4a"}, "audio/midi" => {"mid", "midi", "kar", "rmi"}, "audio/mobile-xmf" => {"mxmf"}, "audio/mp2" => {"mp2"}, "audio/mp3" => {"mp3", "mpga"}, "audio/mp4" => {"m4a", "mp4a", "f4a"}, "audio/mpeg" => {"mp3", "mpga", "mp2", "mp2a", "m2a", "m3a"}, "audio/mpegurl" => {"m3u", "m3u8", "vlc"}, "audio/ogg" => {"ogg", "oga", "spx", "opus"}, "audio/prs.sid" => {"sid", "psid"}, "audio/s3m" => {"s3m"}, "audio/scpls" => {"pls"}, "audio/silk" => {"sil"}, "audio/tta" => {"tta"}, "audio/usac" => {"loas", "xhe"}, "audio/vnd.audible" => {"aa", "aax"}, "audio/vnd.audible.aax" => {"aax"}, "audio/vnd.audible.aaxc" => {"aaxc"}, "audio/vnd.dece.audio" => {"uva", "uvva"}, "audio/vnd.digital-winds" => {"eol"}, "audio/vnd.dra" => {"dra"}, "audio/vnd.dts" => {"dts"}, "audio/vnd.dts.hd" => {"dtshd"}, "audio/vnd.lucent.voice" => {"lvp"}, "audio/vnd.m-realaudio" => {"ra", "rax"}, "audio/vnd.ms-playready.media.pya" => {"pya"}, "audio/vnd.nokia.mobile-xmf" => {"mxmf"}, "audio/vnd.nuera.ecelp4800" => {"ecelp4800"}, "audio/vnd.nuera.ecelp7470" => {"ecelp7470"}, "audio/vnd.nuera.ecelp9600" => {"ecelp9600"}, "audio/vnd.rip" => {"rip"}, "audio/vnd.rn-realaudio" => {"ra", "rax"}, "audio/vnd.wave" => {"wav"}, "audio/vorbis" => {"oga", "ogg"}, "audio/wav" => {"wav"}, "audio/wave" => {"wav"}, "audio/webm" => {"weba"}, "audio/wma" => {"wma"}, "audio/x-aac" => {"aac", "adts", "ass"}, "audio/x-aifc" => {"aifc", "aiffc"}, "audio/x-aiff" => {"aif", "aiff", "aifc"}, "audio/x-aiffc" => {"aifc", "aiffc"}, "audio/x-amzxml" => {"amz"}, "audio/x-annodex" => {"axa"}, "audio/x-ape" => {"ape"}, "audio/x-caf" => {"caf"}, "audio/x-dff" => {"dff"}, "audio/x-dsd" => {"dsf"}, "audio/x-dsf" => {"dsf"}, "audio/x-dts" => {"dts"}, "audio/x-dtshd" => {"dtshd"}, "audio/x-flac" => {"flac"}, "audio/x-flac+ogg" => {"oga", "ogg"}, "audio/x-gsm" => {"gsm"}, "audio/x-hx-aac-adts" => {"aac", "adts", "ass"}, "audio/x-imelody" => {"imy", "ime"}, "audio/x-iriver-pla" => {"pla"}, "audio/x-it" => {"it"}, "audio/x-m3u" => {"m3u", "m3u8", "vlc"}, "audio/x-m4a" => {"m4a", "f4a"}, "audio/x-m4b" => {"m4b", "f4b"}, "audio/x-m4r" => {"m4r"}, "audio/x-matroska" => {"mka"}, "audio/x-midi" => {"mid", "midi", "kar"}, "audio/x-minipsf" => {"minipsf"}, "audio/x-mo3" => {"mo3"}, "audio/x-mod" => {"mod", "ult", "uni", "m15", "mtm", "669", "med"}, "audio/x-mp2" => {"mp2"}, "audio/x-mp3" => {"mp3", "mpga"}, "audio/x-mp3-playlist" => {"m3u", "m3u8", "vlc"}, "audio/x-mpeg" => {"mp3", "mpga"}, "audio/x-mpegurl" => {"m3u", "m3u8", "vlc"}, "audio/x-mpg" => {"mp3", "mpga"}, "audio/x-ms-asx" => {"asx", "wax", "wvx", "wmx"}, "audio/x-ms-wax" => {"wax"}, "audio/x-ms-wma" => {"wma"}, "audio/x-ms-wmv" => {"wmv"}, "audio/x-musepack" => {"mpc", "mpp", "mp+"}, "audio/x-ogg" => {"oga", "ogg", "opus"}, "audio/x-oggflac" => {"oga", "ogg"}, "audio/x-opus+ogg" => {"opus"}, "audio/x-pn-audibleaudio" => {"aa", "aax"}, "audio/x-pn-realaudio" => {"ram", "ra", "rax"}, "audio/x-pn-realaudio-plugin" => {"rmp"}, "audio/x-psf" => {"psf"}, "audio/x-psflib" => {"psflib"}, "audio/x-realaudio" => {"ra"}, "audio/x-rn-3gpp-amr" => {"3gp", "3gpp", "3ga"}, "audio/x-rn-3gpp-amr-encrypted" => {"3gp", "3gpp", "3ga"}, "audio/x-rn-3gpp-amr-wb" => {"3gp", "3gpp", "3ga"}, "audio/x-rn-3gpp-amr-wb-encrypted" => {"3gp", "3gpp", "3ga"}, "audio/x-s3m" => {"s3m"}, "audio/x-scpls" => {"pls"}, "audio/x-shorten" => {"shn"}, "audio/x-speex" => {"spx"}, "audio/x-speex+ogg" => {"oga", "ogg", "spx"}, "audio/x-stm" => {"stm"}, "audio/x-tak" => {"tak"}, "audio/x-tta" => {"tta"}, "audio/x-voc" => {"voc"}, "audio/x-vorbis" => {"oga", "ogg"}, "audio/x-vorbis+ogg" => {"oga", "ogg"}, "audio/x-wav" => {"wav"}, "audio/x-wavpack" => {"wv", "wvp"}, "audio/x-wavpack-correction" => {"wvc"}, "audio/x-xi" => {"xi"}, "audio/x-xm" => {"xm"}, "audio/x-xmf" => {"xmf"}, "audio/xm" => {"xm"}, "audio/xmf" => {"xmf"}, "chemical/x-cdx" => {"cdx"}, "chemical/x-cif" => {"cif"}, "chemical/x-cmdf" => {"cmdf"}, "chemical/x-cml" => {"cml"}, "chemical/x-csml" => {"csml"}, "chemical/x-pdb" => {"pdb", "brk"}, "chemical/x-xyz" => {"xyz"}, "flv-application/octet-stream" => {"flv"}, "font/collection" => {"ttc"}, "font/otf" => {"otf"}, "font/ttf" => {"ttf"}, "font/woff" => {"woff"}, "font/woff2" => {"woff2"}, "image/aces" => {"exr"}, "image/apng" => {"apng", "png"}, "image/astc" => {"astc"}, "image/avci" => {"avci"}, "image/avcs" => {"avcs"}, "image/avif" => {"avif", "avifs"}, "image/avif-sequence" => {"avif", "avifs"}, "image/bmp" => {"bmp", "dib"}, "image/cdr" => {"cdr"}, "image/cgm" => {"cgm"}, "image/dicom-rle" => {"drle"}, "image/dpx" => {"dpx"}, "image/emf" => {"emf"}, "image/fax-g3" => {"g3"}, "image/fits" => {"fits", "fit", "fts"}, "image/g3fax" => {"g3"}, "image/gif" => {"gif"}, "image/heic" => {"heic", "heif", "hif"}, "image/heic-sequence" => {"heics", "heic", "heif", "hif"}, "image/heif" => {"heif", "heic", "hif"}, "image/heif-sequence" => {"heifs", "heic", "heif", "hif"}, "image/hej2k" => {"hej2"}, "image/hsj2" => {"hsj2"}, "image/ico" => {"ico"}, "image/icon" => {"ico"}, "image/ief" => {"ief"}, "image/jls" => {"jls"}, "image/jp2" => {"jp2", "jpg2"}, "image/jpeg" => {"jpg", "jpeg", "jpe", "jfif"}, "image/jpeg2000" => {"jp2", "jpg2"}, "image/jpeg2000-image" => {"jp2", "jpg2"}, "image/jph" => {"jph"}, "image/jphc" => {"jhc"}, "image/jpm" => {"jpm", "jpgm"}, "image/jpx" => {"jpx", "jpf"}, "image/jxl" => {"jxl"}, "image/jxr" => {"jxr", "hdp", "wdp"}, "image/jxra" => {"jxra"}, "image/jxrs" => {"jxrs"}, "image/jxs" => {"jxs"}, "image/jxsc" => {"jxsc"}, "image/jxsi" => {"jxsi"}, "image/jxss" => {"jxss"}, "image/ktx" => {"ktx"}, "image/ktx2" => {"ktx2"}, "image/openraster" => {"ora"}, "image/pdf" => {"pdf"}, "image/photoshop" => {"psd"}, "image/pjpeg" => {"jpg", "jpeg", "jpe", "jfif"}, "image/png" => {"png"}, "image/prs.btif" => {"btif", "btf"}, "image/prs.pti" => {"pti"}, "image/psd" => {"psd"}, "image/qoi" => {"qoi"}, "image/rle" => {"rle"}, "image/sgi" => {"sgi"}, "image/svg" => {"svg"}, "image/svg+xml" => {"svg", "svgz"}, "image/svg+xml-compressed" => {"svgz", "svg.gz"}, "image/t38" => {"t38"}, "image/targa" => {"tga", "icb", "tpic", "vda", "vst"}, "image/tga" => {"tga", "icb", "tpic", "vda", "vst"}, "image/tiff" => {"tif", "tiff"}, "image/tiff-fx" => {"tfx"}, "image/vnd.adobe.photoshop" => {"psd"}, "image/vnd.airzip.accelerator.azv" => {"azv"}, "image/vnd.dece.graphic" => {"uvi", "uvvi", "uvg", "uvvg"}, "image/vnd.djvu" => {"djvu", "djv"}, "image/vnd.djvu+multipage" => {"djvu", "djv"}, "image/vnd.dvb.subtitle" => {"sub"}, "image/vnd.dwg" => {"dwg"}, "image/vnd.dxf" => {"dxf"}, "image/vnd.fastbidsheet" => {"fbs"}, "image/vnd.fpx" => {"fpx"}, "image/vnd.fst" => {"fst"}, "image/vnd.fujixerox.edmics-mmr" => {"mmr"}, "image/vnd.fujixerox.edmics-rlc" => {"rlc"}, "image/vnd.microsoft.icon" => {"ico"}, "image/vnd.mozilla.apng" => {"apng", "png"}, "image/vnd.ms-dds" => {"dds"}, "image/vnd.ms-modi" => {"mdi"}, "image/vnd.ms-photo" => {"wdp", "jxr", "hdp"}, "image/vnd.net-fpx" => {"npx"}, "image/vnd.pco.b16" => {"b16"}, "image/vnd.radiance" => {"hdr", "pic", "rgbe", "xyze"}, "image/vnd.rn-realpix" => {"rp"}, "image/vnd.tencent.tap" => {"tap"}, "image/vnd.valve.source.texture" => {"vtf"}, "image/vnd.wap.wbmp" => {"wbmp"}, "image/vnd.xiff" => {"xif"}, "image/vnd.zbrush.pcx" => {"pcx"}, "image/webp" => {"webp"}, "image/wmf" => {"wmf"}, "image/x-3ds" => {"3ds"}, "image/x-adobe-dng" => {"dng"}, "image/x-applix-graphics" => {"ag"}, "image/x-bmp" => {"bmp", "dib"}, "image/x-bzeps" => {"eps.bz2", "epsi.bz2", "epsf.bz2"}, "image/x-canon-cr2" => {"cr2"}, "image/x-canon-cr3" => {"cr3"}, "image/x-canon-crw" => {"crw"}, "image/x-cdr" => {"cdr"}, "image/x-cmu-raster" => {"ras"}, "image/x-cmx" => {"cmx"}, "image/x-compressed-xcf" => {"xcf.gz", "xcf.bz2"}, "image/x-dds" => {"dds"}, "image/x-djvu" => {"djvu", "djv"}, "image/x-emf" => {"emf"}, "image/x-eps" => {"eps", "epsi", "epsf"}, "image/x-exr" => {"exr"}, "image/x-fits" => {"fits", "fit", "fts"}, "image/x-fpx" => {"fpx"}, "image/x-freehand" => {"fh", "fhc", "fh4", "fh5", "fh7"}, "image/x-fuji-raf" => {"raf"}, "image/x-gimp-gbr" => {"gbr"}, "image/x-gimp-gih" => {"gih"}, "image/x-gimp-pat" => {"pat"}, "image/x-gzeps" => {"eps.gz", "epsi.gz", "epsf.gz"}, "image/x-icb" => {"tga", "icb", "tpic", "vda", "vst"}, "image/x-icns" => {"icns"}, "image/x-ico" => {"ico"}, "image/x-icon" => {"ico"}, "image/x-iff" => {"iff", "ilbm", "lbm"}, "image/x-ilbm" => {"iff", "ilbm", "lbm"}, "image/x-jng" => {"jng"}, "image/x-jp2-codestream" => {"j2c", "j2k", "jpc"}, "image/x-jpeg2000-image" => {"jp2", "jpg2"}, "image/x-kiss-cel" => {"cel", "kcf"}, "image/x-kodak-dcr" => {"dcr"}, "image/x-kodak-k25" => {"k25"}, "image/x-kodak-kdc" => {"kdc"}, "image/x-lwo" => {"lwo", "lwob"}, "image/x-lws" => {"lws"}, "image/x-macpaint" => {"pntg"}, "image/x-minolta-mrw" => {"mrw"}, "image/x-mrsid-image" => {"sid"}, "image/x-ms-bmp" => {"bmp", "dib"}, "image/x-msod" => {"msod"}, "image/x-nikon-nef" => {"nef"}, "image/x-nikon-nrw" => {"nrw"}, "image/x-olympus-orf" => {"orf"}, "image/x-panasonic-raw" => {"raw"}, "image/x-panasonic-raw2" => {"rw2"}, "image/x-panasonic-rw" => {"raw"}, "image/x-panasonic-rw2" => {"rw2"}, "image/x-pcx" => {"pcx"}, "image/x-pentax-pef" => {"pef"}, "image/x-pfm" => {"pfm"}, "image/x-phm" => {"phm"}, "image/x-photo-cd" => {"pcd"}, "image/x-photoshop" => {"psd"}, "image/x-pict" => {"pic", "pct", "pict", "pict1", "pict2"}, "image/x-portable-anymap" => {"pnm"}, "image/x-portable-bitmap" => {"pbm"}, "image/x-portable-graymap" => {"pgm"}, "image/x-portable-pixmap" => {"ppm"}, "image/x-psd" => {"psd"}, "image/x-pxr" => {"pxr"}, "image/x-quicktime" => {"qtif", "qif"}, "image/x-rgb" => {"rgb"}, "image/x-sct" => {"sct"}, "image/x-sgi" => {"sgi"}, "image/x-sigma-x3f" => {"x3f"}, "image/x-skencil" => {"sk", "sk1"}, "image/x-sony-arw" => {"arw"}, "image/x-sony-sr2" => {"sr2"}, "image/x-sony-srf" => {"srf"}, "image/x-sun-raster" => {"sun"}, "image/x-targa" => {"tga", "icb", "tpic", "vda", "vst"}, "image/x-tga" => {"tga", "icb", "tpic", "vda", "vst"}, "image/x-win-bitmap" => {"cur"}, "image/x-win-metafile" => {"wmf"}, "image/x-wmf" => {"wmf"}, "image/x-xbitmap" => {"xbm"}, "image/x-xcf" => {"xcf"}, "image/x-xfig" => {"fig"}, "image/x-xpixmap" => {"xpm"}, "image/x-xpm" => {"xpm"}, "image/x-xwindowdump" => {"xwd"}, "image/x.djvu" => {"djvu", "djv"}, "message/disposition-notification" => {"disposition-notification"}, "message/global" => {"u8msg"}, "message/global-delivery-status" => {"u8dsn"}, "message/global-disposition-notification" => {"u8mdn"}, "message/global-headers" => {"u8hdr"}, "message/rfc822" => {"eml", "mime"}, "message/vnd.wfa.wsc" => {"wsc"}, "model/3mf" => {"3mf"}, "model/gltf+json" => {"gltf"}, "model/gltf-binary" => {"glb"}, "model/iges" => {"igs", "iges"}, "model/jt" => {"jt"}, "model/mesh" => {"msh", "mesh", "silo"}, "model/mtl" => {"mtl"}, "model/obj" => {"obj"}, "model/prc" => {"prc"}, "model/step" => {"step", "stp"}, "model/step+xml" => {"stpx"}, "model/step+zip" => {"stpz"}, "model/step-xml+zip" => {"stpxz"}, "model/stl" => {"stl"}, "model/u3d" => {"u3d"}, "model/vnd.bary" => {"bary"}, "model/vnd.cld" => {"cld"}, "model/vnd.collada+xml" => {"dae"}, "model/vnd.dwf" => {"dwf"}, "model/vnd.gdl" => {"gdl"}, "model/vnd.gtw" => {"gtw"}, "model/vnd.mts" => {"mts"}, "model/vnd.opengex" => {"ogex"}, "model/vnd.parasolid.transmit.binary" => {"x_b"}, "model/vnd.parasolid.transmit.text" => {"x_t"}, "model/vnd.pytha.pyox" => {"pyo", "pyox"}, "model/vnd.sap.vds" => {"vds"}, "model/vnd.usda" => {"usda"}, "model/vnd.usdz+zip" => {"usdz"}, "model/vnd.valve.source.compiled-map" => {"bsp"}, "model/vnd.vtu" => {"vtu"}, "model/vrml" => {"wrl", "vrml", "vrm"}, "model/x.stl-ascii" => {"stl"}, "model/x.stl-binary" => {"stl"}, "model/x3d+binary" => {"x3db", "x3dbz"}, "model/x3d+fastinfoset" => {"x3db"}, "model/x3d+vrml" => {"x3dv", "x3dvz"}, "model/x3d+xml" => {"x3d", "x3dz"}, "model/x3d-vrml" => {"x3dv"}, "text/cache-manifest" => {"appcache", "manifest"}, "text/calendar" => {"ics", "ifb", "vcs", "icalendar"}, "text/coffeescript" => {"coffee", "litcoffee"}, "text/crystal" => {"cr"}, "text/css" => {"css"}, "text/csv" => {"csv"}, "text/csv-schema" => {"csvs"}, "text/directory" => {"vcard", "vcf", "vct", "gcrd"}, "text/ecmascript" => {"es"}, "text/gedcom" => {"ged", "gedcom"}, "text/google-video-pointer" => {"gvp"}, "text/html" => {"html", "htm", "shtml"}, "text/ico" => {"ico"}, "text/jade" => {"jade"}, "text/javascript" => {"js", "mjs", "cjs", "jsm"}, "text/jscript" => {"cjs", "js", "jsm", "mjs"}, "text/jscript.encode" => {"jse"}, "text/jsx" => {"jsx"}, "text/julia" => {"jl"}, "text/less" => {"less"}, "text/markdown" => {"md", "markdown", "mkd"}, "text/mathml" => {"mml"}, "text/mdx" => {"mdx"}, "text/n3" => {"n3"}, "text/org" => {"org"}, "text/plain" => {"txt", "text", "conf", "def", "list", "log", "in", "ini", "asc"}, "text/prs.lines.tag" => {"dsc"}, "text/rdf" => {"rdf", "rdfs", "owl"}, "text/richtext" => {"rtx"}, "text/rss" => {"rss"}, "text/rtf" => {"rtf"}, "text/rust" => {"rs"}, "text/sgml" => {"sgml", "sgm"}, "text/shex" => {"shex"}, "text/slim" => {"slim", "slm"}, "text/spdx" => {"spdx"}, "text/spreadsheet" => {"sylk", "slk"}, "text/stylus" => {"stylus", "styl"}, "text/tab-separated-values" => {"tsv"}, "text/tcl" => {"tcl", "tk"}, "text/troff" => {"t", "tr", "roff", "man", "me", "ms"}, "text/turtle" => {"ttl"}, "text/uri-list" => {"uri", "uris", "urls"}, "text/vbs" => {"vbs"}, "text/vbscript" => {"vbs"}, "text/vbscript.encode" => {"vbe"}, "text/vcard" => {"vcard", "vcf", "vct", "gcrd"}, "text/vnd.curl" => {"curl"}, "text/vnd.curl.dcurl" => {"dcurl"}, "text/vnd.curl.mcurl" => {"mcurl"}, "text/vnd.curl.scurl" => {"scurl"}, "text/vnd.dvb.subtitle" => {"sub"}, "text/vnd.familysearch.gedcom" => {"ged", "gedcom"}, "text/vnd.fly" => {"fly"}, "text/vnd.fmi.flexstor" => {"flx"}, "text/vnd.graphviz" => {"gv", "dot"}, "text/vnd.in3d.3dml" => {"3dml"}, "text/vnd.in3d.spot" => {"spot"}, "text/vnd.qt.linguist" => {"ts"}, "text/vnd.rn-realtext" => {"rt"}, "text/vnd.senx.warpscript" => {"mc2"}, "text/vnd.sun.j2me.app-descriptor" => {"jad"}, "text/vnd.trolltech.linguist" => {"ts"}, "text/vnd.typst" => {"typ"}, "text/vnd.wap.wml" => {"wml"}, "text/vnd.wap.wmlscript" => {"wmls"}, "text/vtt" => {"vtt"}, "text/wgsl" => {"wgsl"}, "text/x-adasrc" => {"adb", "ads"}, "text/x-asm" => {"s", "asm"}, "text/x-basic" => {"bas"}, "text/x-bibtex" => {"bib"}, "text/x-blueprint" => {"blp"}, "text/x-c" => {"c", "cc", "cxx", "cpp", "h", "hh", "dic"}, "text/x-c++hdr" => {"hh", "hp", "hpp", "h++", "hxx"}, "text/x-c++src" => {"cpp", "cxx", "cc", "C", "c++"}, "text/x-chdr" => {"h"}, "text/x-cmake" => {"cmake"}, "text/x-cobol" => {"cbl", "cob"}, "text/x-comma-separated-values" => {"csv"}, "text/x-common-lisp" => {"asd", "fasl", "lisp", "ros"}, "text/x-component" => {"htc"}, "text/x-crystal" => {"cr"}, "text/x-csharp" => {"cs"}, "text/x-csrc" => {"c"}, "text/x-csv" => {"csv"}, "text/x-cython" => {"pxd", "pxi", "pyx"}, "text/x-dart" => {"dart"}, "text/x-dbus-service" => {"service"}, "text/x-dcl" => {"dcl"}, "text/x-devicetree-binary" => {"dtb"}, "text/x-devicetree-source" => {"dts", "dtsi"}, "text/x-diff" => {"diff", "patch"}, "text/x-dockerfile" => {"Dockerfile"}, "text/x-dsl" => {"dsl"}, "text/x-dsrc" => {"d", "di"}, "text/x-dtd" => {"dtd"}, "text/x-eiffel" => {"e", "eif"}, "text/x-elixir" => {"ex", "exs"}, "text/x-emacs-lisp" => {"el"}, "text/x-erlang" => {"erl"}, "text/x-fish" => {"fish"}, "text/x-fortran" => {"f", "for", "f77", "f90", "f95"}, "text/x-gcode-gx" => {"gx"}, "text/x-genie" => {"gs"}, "text/x-gettext-translation" => {"po"}, "text/x-gettext-translation-template" => {"pot"}, "text/x-gherkin" => {"feature"}, "text/x-go" => {"go"}, "text/x-google-video-pointer" => {"gvp"}, "text/x-gradle" => {"gradle"}, "text/x-groovy" => {"groovy", "gvy", "gy", "gsh"}, "text/x-handlebars-template" => {"hbs"}, "text/x-haskell" => {"hs"}, "text/x-idl" => {"idl"}, "text/x-imelody" => {"imy", "ime"}, "text/x-iptables" => {"iptables"}, "text/x-java" => {"java"}, "text/x-java-source" => {"java"}, "text/x-kaitai-struct" => {"ksy"}, "text/x-kotlin" => {"kt"}, "text/x-ldif" => {"ldif"}, "text/x-lilypond" => {"ly"}, "text/x-literate-haskell" => {"lhs"}, "text/x-log" => {"log"}, "text/x-lua" => {"lua"}, "text/x-lyx" => {"lyx"}, "text/x-makefile" => {"mk", "mak"}, "text/x-markdown" => {"md", "mkd", "markdown"}, "text/x-matlab" => {"m"}, "text/x-microdvd" => {"sub"}, "text/x-moc" => {"moc"}, "text/x-modelica" => {"mo"}, "text/x-mof" => {"mof"}, "text/x-mpl2" => {"mpl"}, "text/x-mpsub" => {"sub"}, "text/x-mrml" => {"mrml", "mrl"}, "text/x-ms-regedit" => {"reg"}, "text/x-mup" => {"mup", "not"}, "text/x-nfo" => {"nfo"}, "text/x-nim" => {"nim"}, "text/x-nimscript" => {"nims", "nimble"}, "text/x-nix" => {"nix"}, "text/x-nu" => {"nu"}, "text/x-nushell" => {"nu"}, "text/x-objc++src" => {"mm"}, "text/x-objcsrc" => {"m"}, "text/x-ocaml" => {"ml", "mli"}, "text/x-ocl" => {"ocl"}, "text/x-octave" => {"m"}, "text/x-ooc" => {"ooc"}, "text/x-opencl-src" => {"cl"}, "text/x-opml" => {"opml"}, "text/x-opml+xml" => {"opml"}, "text/x-org" => {"org"}, "text/x-pascal" => {"p", "pas"}, "text/x-patch" => {"diff", "patch"}, "text/x-perl" => {"pl", "PL", "pm", "al", "perl", "pod", "t"}, "text/x-po" => {"po"}, "text/x-pot" => {"pot"}, "text/x-processing" => {"pde"}, "text/x-python" => {"py", "wsgi"}, "text/x-python2" => {"py", "py2"}, "text/x-python3" => {"py", "py3", "pyi"}, "text/x-qml" => {"qml", "qmltypes", "qmlproject"}, "text/x-reject" => {"rej"}, "text/x-rpm-spec" => {"spec"}, "text/x-rst" => {"rst"}, "text/x-sagemath" => {"sage"}, "text/x-sass" => {"sass"}, "text/x-scala" => {"scala", "sc"}, "text/x-scheme" => {"scm", "ss"}, "text/x-scss" => {"scss"}, "text/x-setext" => {"etx"}, "text/x-sfv" => {"sfv"}, "text/x-sh" => {"sh"}, "text/x-sql" => {"sql"}, "text/x-ssa" => {"ssa", "ass"}, "text/x-ssh-public-key" => {"pub"}, "text/x-subviewer" => {"sub"}, "text/x-suse-ymp" => {"ymp"}, "text/x-svhdr" => {"svh"}, "text/x-svsrc" => {"sv"}, "text/x-systemd-unit" => {"automount", "device", "mount", "path", "scope", "service", "slice", "socket", "swap", "target", "timer"}, "text/x-tcl" => {"tcl", "tk"}, "text/x-tex" => {"tex", "ltx", "sty", "cls", "dtx", "ins", "latex"}, "text/x-texinfo" => {"texi", "texinfo"}, "text/x-troff" => {"tr", "roff", "t"}, "text/x-troff-me" => {"me"}, "text/x-troff-mm" => {"mm"}, "text/x-troff-ms" => {"ms"}, "text/x-twig" => {"twig"}, "text/x-txt2tags" => {"t2t"}, "text/x-typst" => {"typ"}, "text/x-uil" => {"uil"}, "text/x-uuencode" => {"uu", "uue"}, "text/x-vala" => {"vala", "vapi"}, "text/x-vb" => {"vb"}, "text/x-vcalendar" => {"vcs", "ics", "ifb", "icalendar"}, "text/x-vcard" => {"vcf", "vcard", "vct", "gcrd"}, "text/x-verilog" => {"v"}, "text/x-vhdl" => {"vhd", "vhdl"}, "text/x-xmi" => {"xmi"}, "text/x-xslfo" => {"fo", "xslfo"}, "text/x-yaml" => {"yaml", "yml"}, "text/x.gcode" => {"gcode"}, "text/xml" => {"xml", "xbl", "xsd", "rng"}, "text/xml-external-parsed-entity" => {"ent"}, "text/yaml" => {"yaml", "yml"}, "video/3gp" => {"3gp", "3gpp", "3ga"}, "video/3gpp" => {"3gp", "3gpp", "3ga"}, "video/3gpp-encrypted" => {"3gp", "3gpp", "3ga"}, "video/3gpp2" => {"3g2", "3gp2", "3gpp2"}, "video/annodex" => {"axv"}, "video/avi" => {"avi", "avf", "divx"}, "video/divx" => {"avi", "avf", "divx"}, "video/dv" => {"dv"}, "video/fli" => {"fli", "flc"}, "video/flv" => {"flv"}, "video/h261" => {"h261"}, "video/h263" => {"h263"}, "video/h264" => {"h264"}, "video/iso.segment" => {"m4s"}, "video/jpeg" => {"jpgv"}, "video/jpm" => {"jpm", "jpgm"}, "video/mj2" => {"mj2", "mjp2"}, "video/mp2t" => {"ts", "m2t", "m2ts", "mts", "cpi", "clpi", "mpl", "mpls", "bdm", "bdmv"}, "video/mp4" => {"mp4", "mp4v", "mpg4", "m4v", "f4v", "lrv", "lrf"}, "video/mp4v-es" => {"mp4", "m4v", "f4v", "lrv", "lrf"}, "video/mpeg" => {"mpeg", "mpg", "mpe", "m1v", "m2v", "mp2", "vob"}, "video/mpeg-system" => {"mpeg", "mpg", "mp2", "mpe", "vob"}, "video/mpg4" => {"mpg4"}, "video/msvideo" => {"avi", "avf", "divx"}, "video/ogg" => {"ogv", "ogg"}, "video/quicktime" => {"mov", "qt", "moov", "qtvr"}, "video/vivo" => {"viv", "vivo"}, "video/vnd.avi" => {"avi", "avf", "divx"}, "video/vnd.dece.hd" => {"uvh", "uvvh"}, "video/vnd.dece.mobile" => {"uvm", "uvvm"}, "video/vnd.dece.pd" => {"uvp", "uvvp"}, "video/vnd.dece.sd" => {"uvs", "uvvs"}, "video/vnd.dece.video" => {"uvv", "uvvv"}, "video/vnd.divx" => {"avi", "avf", "divx"}, "video/vnd.dvb.file" => {"dvb"}, "video/vnd.fvt" => {"fvt"}, "video/vnd.mpegurl" => {"mxu", "m4u", "m1u"}, "video/vnd.ms-playready.media.pyv" => {"pyv"}, "video/vnd.radgamettools.bink" => {"bik", "bk2"}, "video/vnd.radgamettools.smacker" => {"smk"}, "video/vnd.rn-realvideo" => {"rv", "rvx"}, "video/vnd.uvvu.mp4" => {"uvu", "uvvu"}, "video/vnd.vivo" => {"viv", "vivo"}, "video/vnd.youtube.yt" => {"yt"}, "video/webm" => {"webm"}, "video/x-anim" => {"anim[1-9j]", "anim2", "anim3", "anim4", "anim5", "anim6", "anim7", "anim8", "anim9", "animj"}, "video/x-annodex" => {"axv"}, "video/x-avi" => {"avi", "avf", "divx"}, "video/x-f4v" => {"f4v"}, "video/x-fli" => {"fli", "flc"}, "video/x-flic" => {"fli", "flc"}, "video/x-flv" => {"flv"}, "video/x-javafx" => {"fxm"}, "video/x-m4v" => {"m4v", "mp4", "f4v", "lrv", "lrf"}, "video/x-matroska" => {"mkv", "mk3d", "mks"}, "video/x-matroska-3d" => {"mk3d"}, "video/x-mjpeg" => {"mjpeg", "mjpg"}, "video/x-mng" => {"mng"}, "video/x-mpeg" => {"mpeg", "mpg", "mp2", "mpe", "vob"}, "video/x-mpeg-system" => {"mpeg", "mpg", "mp2", "mpe", "vob"}, "video/x-mpeg2" => {"mpeg", "mpg", "mp2", "mpe", "vob"}, "video/x-mpegurl" => {"m1u", "m4u", "mxu"}, "video/x-ms-asf" => {"asf", "asx"}, "video/x-ms-asf-plugin" => {"asf"}, "video/x-ms-vob" => {"vob"}, "video/x-ms-wax" => {"asx", "wax", "wvx", "wmx"}, "video/x-ms-wm" => {"wm", "asf"}, "video/x-ms-wmv" => {"wmv"}, "video/x-ms-wmx" => {"wmx", "asx", "wax", "wvx"}, "video/x-ms-wvx" => {"wvx", "asx", "wax", "wmx"}, "video/x-msvideo" => {"avi", "avf", "divx"}, "video/x-nsv" => {"nsv"}, "video/x-ogg" => {"ogv", "ogg"}, "video/x-ogm" => {"ogm"}, "video/x-ogm+ogg" => {"ogm"}, "video/x-real-video" => {"rv", "rvx"}, "video/x-sgi-movie" => {"movie"}, "video/x-smv" => {"smv"}, "video/x-theora" => {"ogg"}, "video/x-theora+ogg" => {"ogg"}, "x-conference/x-cooltalk" => {"ice"}, "x-epoc/x-sisx-app" => {"sisx"}, "zz-application/zz-winassoc-123" => {"123", "wk1", "wk3", "wk4", "wks"}, "zz-application/zz-winassoc-cab" => {"cab"}, "zz-application/zz-winassoc-cdr" => {"cdr"}, "zz-application/zz-winassoc-doc" => {"doc"}, "zz-application/zz-winassoc-hlp" => {"hlp"}, "zz-application/zz-winassoc-mdb" => {"mdb"}, "zz-application/zz-winassoc-uu" => {"uue"}, "zz-application/zz-winassoc-xls" => {"xls", "xlc", "xll", "xlm", "xlw", "xla", "xlt", "xld"}, } private REVERSE_MAP = { "123" => {"application/lotus123", "application/vnd.lotus-1-2-3", "application/wk1", "application/x-123", "application/x-lotus123", "zz-application/zz-winassoc-123"}, "1km" => {"application/vnd.1000minds.decision-model+xml"}, "32x" => {"application/x-genesis-32x-rom"}, "3dml" => {"text/vnd.in3d.3dml"}, "3ds" => {"application/x-nintendo-3ds-rom", "image/x-3ds"}, "3dsx" => {"application/x-nintendo-3ds-executable"}, "3g2" => {"audio/3gpp2", "video/3gpp2"}, "3ga" => {"audio/3gpp", "audio/3gpp-encrypted", "audio/x-rn-3gpp-amr", "audio/x-rn-3gpp-amr-encrypted", "audio/x-rn-3gpp-amr-wb", "audio/x-rn-3gpp-amr-wb-encrypted", "video/3gp", "video/3gpp", "video/3gpp-encrypted"}, "3gp" => {"audio/3gpp", "audio/3gpp-encrypted", "audio/x-rn-3gpp-amr", "audio/x-rn-3gpp-amr-encrypted", "audio/x-rn-3gpp-amr-wb", "audio/x-rn-3gpp-amr-wb-encrypted", "video/3gp", "video/3gpp", "video/3gpp-encrypted"}, "3gp2" => {"audio/3gpp2", "video/3gpp2"}, "3gpp" => {"audio/3gpp", "audio/3gpp-encrypted", "audio/x-rn-3gpp-amr", "audio/x-rn-3gpp-amr-encrypted", "audio/x-rn-3gpp-amr-wb", "audio/x-rn-3gpp-amr-wb-encrypted", "video/3gp", "video/3gpp", "video/3gpp-encrypted"}, "3gpp2" => {"audio/3gpp2", "video/3gpp2"}, "3mf" => {"application/vnd.ms-3mfdocument", "model/3mf"}, "602" => {"application/x-t602"}, "669" => {"audio/x-mod"}, "7z" => {"application/x-7z-compressed"}, "7z.001" => {"application/x-7z-compressed"}, "C" => {"text/x-c++src"}, "Dockerfile" => {"text/x-dockerfile"}, "PAR2" => {"application/x-par2"}, "PL" => {"application/x-perl", "text/x-perl"}, "Z" => {"application/x-compress"}, "[1-9]" => {"application/x-troff-man"}, "a" => {"application/x-archive"}, "a26" => {"application/x-atari-2600-rom"}, "a78" => {"application/x-atari-7800-rom"}, "aa" => {"audio/vnd.audible", "audio/x-pn-audibleaudio"}, "aab" => {"application/x-authorware-bin"}, "aac" => {"audio/aac", "audio/x-aac", "audio/x-hx-aac-adts"}, "aam" => {"application/x-authorware-map"}, "aas" => {"application/x-authorware-seg"}, "aax" => {"audio/vnd.audible", "audio/vnd.audible.aax", "audio/x-pn-audibleaudio"}, "aaxc" => {"audio/vnd.audible.aaxc"}, "abw" => {"application/x-abiword"}, "abw.CRASHED" => {"application/x-abiword"}, "abw.gz" => {"application/x-abiword"}, "ac" => {"application/pkix-attr-cert", "application/vnd.nokia.n-gage.ac+xml"}, "ac3" => {"audio/ac3"}, "acc" => {"application/vnd.americandynamics.acc"}, "ace" => {"application/x-ace", "application/x-ace-compressed"}, "acu" => {"application/vnd.acucobol"}, "acutc" => {"application/vnd.acucorp"}, "adb" => {"text/x-adasrc"}, "adf" => {"application/x-amiga-disk-format"}, "adp" => {"audio/adpcm"}, "ads" => {"text/x-adasrc"}, "adts" => {"audio/aac", "audio/x-aac", "audio/x-hx-aac-adts"}, "aep" => {"application/vnd.audiograph"}, "afm" => {"application/x-font-afm", "application/x-font-type1"}, "afp" => {"application/vnd.ibm.modcap"}, "ag" => {"image/x-applix-graphics"}, "agb" => {"application/x-gba-rom"}, "age" => {"application/vnd.age"}, "ahead" => {"application/vnd.ahead.space"}, "ai" => {"application/illustrator", "application/postscript", "application/vnd.adobe.illustrator"}, "aif" => {"audio/x-aiff"}, "aifc" => {"audio/x-aifc", "audio/x-aiff", "audio/x-aiffc"}, "aiff" => {"audio/x-aiff"}, "aiffc" => {"audio/x-aifc", "audio/x-aiffc"}, "air" => {"application/vnd.adobe.air-application-installer-package+zip"}, "ait" => {"application/vnd.dvb.ait"}, "al" => {"application/x-perl", "text/x-perl"}, "alz" => {"application/x-alz"}, "ami" => {"application/vnd.amiga.ami"}, "aml" => {"application/automationml-aml+xml"}, "amlx" => {"application/automationml-amlx+zip"}, "amr" => {"audio/amr", "audio/amr-encrypted"}, "amz" => {"audio/x-amzxml"}, "ani" => {"application/x-navi-animation"}, "anim2" => {"video/x-anim"}, "anim3" => {"video/x-anim"}, "anim4" => {"video/x-anim"}, "anim5" => {"video/x-anim"}, "anim6" => {"video/x-anim"}, "anim7" => {"video/x-anim"}, "anim8" => {"video/x-anim"}, "anim9" => {"video/x-anim"}, "anim[1-9j]" => {"video/x-anim"}, "animj" => {"video/x-anim"}, "anx" => {"application/annodex", "application/x-annodex"}, "ape" => {"audio/x-ape"}, "apk" => {"application/vnd.android.package-archive"}, "apng" => {"image/apng", "image/vnd.mozilla.apng"}, "appcache" => {"text/cache-manifest"}, "appimage" => {"application/vnd.appimage", "application/x-iso9660-appimage"}, "appinstaller" => {"application/appinstaller"}, "application" => {"application/x-ms-application"}, "appx" => {"application/appx"}, "appxbundle" => {"application/appxbundle"}, "apr" => {"application/vnd.lotus-approach"}, "ar" => {"application/x-archive"}, "arc" => {"application/x-freearc"}, "arj" => {"application/x-arj"}, "arw" => {"image/x-sony-arw"}, "as" => {"application/x-applix-spreadsheet"}, "asar" => {"application/x-asar"}, "asc" => {"application/pgp", "application/pgp-encrypted", "application/pgp-keys", "application/pgp-signature", "text/plain"}, "asd" => {"text/x-common-lisp"}, "asf" => {"application/vnd.ms-asf", "video/x-ms-asf", "video/x-ms-asf-plugin", "video/x-ms-wm"}, "asice" => {"application/vnd.etsi.asic-e+zip"}, "asm" => {"text/x-asm"}, "aso" => {"application/vnd.accpac.simply.aso"}, "asp" => {"application/x-asp"}, "ass" => {"audio/aac", "audio/x-aac", "audio/x-hx-aac-adts", "text/x-ssa"}, "astc" => {"image/astc"}, "asx" => {"application/x-ms-asx", "audio/x-ms-asx", "video/x-ms-asf", "video/x-ms-wax", "video/x-ms-wmx", "video/x-ms-wvx"}, "atc" => {"application/vnd.acucorp"}, "atom" => {"application/atom+xml"}, "atomcat" => {"application/atomcat+xml"}, "atomdeleted" => {"application/atomdeleted+xml"}, "atomsvc" => {"application/atomsvc+xml"}, "atx" => {"application/vnd.antix.game-component"}, "au" => {"audio/basic"}, "automount" => {"text/x-systemd-unit"}, "avci" => {"image/avci"}, "avcs" => {"image/avcs"}, "avf" => {"video/avi", "video/divx", "video/msvideo", "video/vnd.avi", "video/vnd.divx", "video/x-avi", "video/x-msvideo"}, "avi" => {"video/avi", "video/divx", "video/msvideo", "video/vnd.avi", "video/vnd.divx", "video/x-avi", "video/x-msvideo"}, "avif" => {"image/avif", "image/avif-sequence"}, "avifs" => {"image/avif", "image/avif-sequence"}, "aw" => {"application/applixware", "application/x-applix-word"}, "awb" => {"audio/amr-wb", "audio/amr-wb-encrypted"}, "awk" => {"application/x-awk"}, "axa" => {"audio/annodex", "audio/x-annodex"}, "axv" => {"video/annodex", "video/x-annodex"}, "azf" => {"application/vnd.airzip.filesecure.azf"}, "azs" => {"application/vnd.airzip.filesecure.azs"}, "azv" => {"image/vnd.airzip.accelerator.azv"}, "azw" => {"application/vnd.amazon.ebook"}, "azw3" => {"application/vnd.amazon.mobi8-ebook", "application/x-mobi8-ebook"}, "b16" => {"image/vnd.pco.b16"}, "bak" => {"application/x-trash"}, "bary" => {"model/vnd.bary"}, "bas" => {"text/x-basic"}, "bat" => {"application/bat", "application/x-bat", "application/x-msdownload"}, "bcpio" => {"application/x-bcpio"}, "bdf" => {"application/x-font-bdf"}, "bdm" => {"application/vnd.syncml.dm+wbxml", "video/mp2t"}, "bdmv" => {"video/mp2t"}, "bdo" => {"application/vnd.nato.bindingdataobject+xml"}, "bdoc" => {"application/bdoc", "application/x-bdoc"}, "bed" => {"application/vnd.realvnc.bed"}, "bh2" => {"application/vnd.fujitsu.oasysprs"}, "bib" => {"text/x-bibtex"}, "bik" => {"video/vnd.radgamettools.bink"}, "bin" => {"application/octet-stream"}, "bk2" => {"video/vnd.radgamettools.bink"}, "blb" => {"application/x-blorb"}, "blend" => {"application/x-blender"}, "blender" => {"application/x-blender"}, "blorb" => {"application/x-blorb"}, "blp" => {"text/x-blueprint"}, "bmi" => {"application/vnd.bmi"}, "bmml" => {"application/vnd.balsamiq.bmml+xml"}, "bmp" => {"image/bmp", "image/x-bmp", "image/x-ms-bmp"}, "book" => {"application/vnd.framemaker"}, "box" => {"application/vnd.previewsystems.box"}, "boz" => {"application/x-bzip2"}, "bps" => {"application/x-bps-patch"}, "brk" => {"chemical/x-pdb"}, "bsdiff" => {"application/x-bsdiff"}, "bsp" => {"model/vnd.valve.source.compiled-map"}, "bst" => {"application/buildstream+yaml"}, "btf" => {"image/prs.btif"}, "btif" => {"image/prs.btif"}, "bz" => {"application/bzip2", "application/x-bzip", "application/x-bzip1"}, "bz2" => {"application/x-bz2", "application/bzip2", "application/x-bzip", "application/x-bzip2"}, "bz3" => {"application/x-bzip3"}, "c" => {"text/x-c", "text/x-csrc"}, "c++" => {"text/x-c++src"}, "c11amc" => {"application/vnd.cluetrust.cartomobile-config"}, "c11amz" => {"application/vnd.cluetrust.cartomobile-config-pkg"}, "c4d" => {"application/vnd.clonk.c4group"}, "c4f" => {"application/vnd.clonk.c4group"}, "c4g" => {"application/vnd.clonk.c4group"}, "c4p" => {"application/vnd.clonk.c4group"}, "c4u" => {"application/vnd.clonk.c4group"}, "cab" => {"application/vnd.ms-cab-compressed", "zz-application/zz-winassoc-cab"}, "caf" => {"audio/x-caf"}, "cap" => {"application/pcap", "application/vnd.tcpdump.pcap", "application/x-pcap"}, "car" => {"application/vnd.curl.car"}, "cat" => {"application/vnd.ms-pki.seccat"}, "cb7" => {"application/x-cb7", "application/x-cbr"}, "cba" => {"application/x-cbr"}, "cbl" => {"text/x-cobol"}, "cbor" => {"application/cbor"}, "cbr" => {"application/vnd.comicbook-rar", "application/x-cbr"}, "cbt" => {"application/x-cbr", "application/x-cbt"}, "cbz" => {"application/vnd.comicbook+zip", "application/x-cbr", "application/x-cbz"}, "cc" => {"text/x-c", "text/x-c++src"}, "cci" => {"application/x-nintendo-3ds-rom"}, "ccmx" => {"application/x-ccmx"}, "cco" => {"application/x-cocoa"}, "cct" => {"application/x-director"}, "ccxml" => {"application/ccxml+xml"}, "cdbcmsg" => {"application/vnd.contact.cmsg"}, "cdf" => {"application/x-netcdf"}, "cdfx" => {"application/cdfx+xml"}, "cdi" => {"application/x-discjuggler-cd-image"}, "cdkey" => {"application/vnd.mediastation.cdkey"}, "cdmia" => {"application/cdmi-capability"}, "cdmic" => {"application/cdmi-container"}, "cdmid" => {"application/cdmi-domain"}, "cdmio" => {"application/cdmi-object"}, "cdmiq" => {"application/cdmi-queue"}, "cdr" => {"application/cdr", "application/coreldraw", "application/vnd.corel-draw", "application/x-cdr", "application/x-coreldraw", "image/cdr", "image/x-cdr", "zz-application/zz-winassoc-cdr"}, "cdx" => {"chemical/x-cdx"}, "cdxml" => {"application/vnd.chemdraw+xml"}, "cdy" => {"application/vnd.cinderella"}, "cel" => {"image/x-kiss-cel"}, "cer" => {"application/pkix-cert"}, "cert" => {"application/x-x509-ca-cert"}, "cfs" => {"application/x-cfs-compressed"}, "cgb" => {"application/x-gameboy-color-rom"}, "cgm" => {"image/cgm"}, "chat" => {"application/x-chat"}, "chd" => {"application/x-mame-chd"}, "chm" => {"application/vnd.ms-htmlhelp", "application/x-chm"}, "chrt" => {"application/vnd.kde.kchart", "application/x-kchart"}, "cif" => {"chemical/x-cif"}, "cii" => {"application/vnd.anser-web-certificate-issue-initiation"}, "cil" => {"application/vnd.ms-artgalry"}, "cjs" => {"application/javascript", "application/node", "application/x-javascript", "text/javascript", "text/jscript"}, "cl" => {"text/x-opencl-src"}, "cla" => {"application/vnd.claymore"}, "class" => {"application/java", "application/java-byte-code", "application/java-vm", "application/x-java", "application/x-java-class", "application/x-java-vm"}, "cld" => {"model/vnd.cld"}, "clkk" => {"application/vnd.crick.clicker.keyboard"}, "clkp" => {"application/vnd.crick.clicker.palette"}, "clkt" => {"application/vnd.crick.clicker.template"}, "clkw" => {"application/vnd.crick.clicker.wordbank"}, "clkx" => {"application/vnd.crick.clicker"}, "clp" => {"application/x-msclip"}, "clpi" => {"video/mp2t"}, "cls" => {"application/x-tex", "text/x-tex"}, "cmake" => {"text/x-cmake"}, "cmc" => {"application/vnd.cosmocaller"}, "cmdf" => {"chemical/x-cmdf"}, "cml" => {"chemical/x-cml"}, "cmp" => {"application/vnd.yellowriver-custom-menu"}, "cmx" => {"image/x-cmx"}, "cob" => {"text/x-cobol"}, "cod" => {"application/vnd.rim.cod"}, "coffee" => {"application/vnd.coffeescript", "text/coffeescript"}, "com" => {"application/x-msdownload"}, "conf" => {"text/plain"}, "cpi" => {"video/mp2t"}, "cpio" => {"application/x-cpio"}, "cpio.gz" => {"application/x-cpio-compressed"}, "cpl" => {"application/cpl+xml", "application/vnd.microsoft.portable-executable", "application/x-ms-dos-executable", "application/x-ms-ne-executable", "application/x-msdownload"}, "cpp" => {"text/x-c", "text/x-c++src"}, "cpt" => {"application/mac-compactpro"}, "cr" => {"text/crystal", "text/x-crystal"}, "cr2" => {"image/x-canon-cr2"}, "cr3" => {"image/x-canon-cr3"}, "crd" => {"application/x-mscardfile"}, "crdownload" => {"application/x-partial-download"}, "crl" => {"application/pkix-crl"}, "crt" => {"application/x-x509-ca-cert"}, "crw" => {"image/x-canon-crw"}, "crx" => {"application/x-chrome-extension"}, "cryptonote" => {"application/vnd.rig.cryptonote"}, "cs" => {"text/x-csharp"}, "csh" => {"application/x-csh"}, "csl" => {"application/vnd.citationstyles.style+xml"}, "csml" => {"chemical/x-csml"}, "cso" => {"application/x-compressed-iso"}, "csp" => {"application/vnd.commonspace"}, "css" => {"text/css"}, "cst" => {"application/x-director"}, "csv" => {"text/csv", "application/csv", "text/x-comma-separated-values", "text/x-csv"}, "csvs" => {"text/csv-schema"}, "cts" => {"application/typescript"}, "cu" => {"application/cu-seeme"}, "cue" => {"application/x-cue"}, "cur" => {"image/x-win-bitmap"}, "curl" => {"text/vnd.curl"}, "cwk" => {"application/x-appleworks-document"}, "cwl" => {"application/cwl"}, "cww" => {"application/prs.cww"}, "cxt" => {"application/x-director"}, "cxx" => {"text/x-c", "text/x-c++src"}, "d" => {"text/x-dsrc"}, "dae" => {"model/vnd.collada+xml"}, "daf" => {"application/vnd.mobius.daf"}, "dar" => {"application/x-dar"}, "dart" => {"application/vnd.dart", "text/x-dart"}, "dataless" => {"application/vnd.fdsn.seed"}, "davmount" => {"application/davmount+xml"}, "dbf" => {"application/dbase", "application/dbf", "application/vnd.dbf", "application/x-dbase", "application/x-dbf"}, "dbk" => {"application/docbook+xml", "application/vnd.oasis.docbook+xml", "application/x-docbook+xml"}, "dc" => {"application/x-dc-rom"}, "dcl" => {"text/x-dcl"}, "dcm" => {"application/dicom"}, "dcr" => {"application/x-director", "image/x-kodak-dcr"}, "dcurl" => {"text/vnd.curl.dcurl"}, "dd2" => {"application/vnd.oma.dd2+xml"}, "ddd" => {"application/vnd.fujixerox.ddd"}, "ddf" => {"application/vnd.syncml.dmddf+xml"}, "dds" => {"image/vnd.ms-dds", "image/x-dds"}, "deb" => {"application/vnd.debian.binary-package", "application/x-deb", "application/x-debian-package"}, "def" => {"text/plain"}, "der" => {"application/x-x509-ca-cert"}, "desktop" => {"application/x-desktop", "application/x-gnome-app-info"}, "device" => {"text/x-systemd-unit"}, "dfac" => {"application/vnd.dreamfactory"}, "dff" => {"audio/dff", "audio/x-dff"}, "dgc" => {"application/x-dgc-compressed"}, "di" => {"text/x-dsrc"}, "dia" => {"application/x-dia-diagram"}, "dib" => {"image/bmp", "image/x-bmp", "image/x-ms-bmp"}, "dic" => {"text/x-c"}, "diff" => {"text/x-diff", "text/x-patch"}, "dir" => {"application/x-director"}, "dis" => {"application/vnd.mobius.dis"}, "disposition-notification" => {"message/disposition-notification"}, "divx" => {"video/avi", "video/divx", "video/msvideo", "video/vnd.avi", "video/vnd.divx", "video/x-avi", "video/x-msvideo"}, "djv" => {"image/vnd.djvu", "image/vnd.djvu+multipage", "image/x-djvu", "image/x.djvu"}, "djvu" => {"image/vnd.djvu", "image/vnd.djvu+multipage", "image/x-djvu", "image/x.djvu"}, "dll" => {"application/vnd.microsoft.portable-executable", "application/x-ms-dos-executable", "application/x-ms-ne-executable", "application/x-msdownload"}, "dmg" => {"application/x-apple-diskimage"}, "dmp" => {"application/pcap", "application/vnd.tcpdump.pcap", "application/x-pcap"}, "dna" => {"application/vnd.dna"}, "dng" => {"image/x-adobe-dng"}, "doc" => {"application/msword", "application/vnd.ms-word", "application/x-msword", "zz-application/zz-winassoc-doc"}, "docbook" => {"application/docbook+xml", "application/vnd.oasis.docbook+xml", "application/x-docbook+xml"}, "docm" => {"application/vnd.ms-word.document.macroenabled.12"}, "docx" => {"application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, "dot" => {"application/msword", "application/msword-template", "text/vnd.graphviz"}, "dotm" => {"application/vnd.ms-word.template.macroenabled.12"}, "dotx" => {"application/vnd.openxmlformats-officedocument.wordprocessingml.template"}, "dp" => {"application/vnd.osgi.dp"}, "dpg" => {"application/vnd.dpgraph"}, "dpx" => {"image/dpx"}, "dra" => {"audio/vnd.dra"}, "drl" => {"application/x-excellon"}, "drle" => {"image/dicom-rle"}, "drv" => {"application/vnd.microsoft.portable-executable", "application/x-ms-dos-executable", "application/x-ms-ne-executable", "application/x-msdownload"}, "dsc" => {"text/prs.lines.tag"}, "dsf" => {"audio/dsd", "audio/dsf", "audio/x-dsd", "audio/x-dsf"}, "dsl" => {"text/x-dsl"}, "dssc" => {"application/dssc+der"}, "dtb" => {"application/x-dtbook+xml", "text/x-devicetree-binary"}, "dtd" => {"application/xml-dtd", "text/x-dtd"}, "dts" => {"audio/vnd.dts", "audio/x-dts", "text/x-devicetree-source"}, "dtshd" => {"audio/vnd.dts.hd", "audio/x-dtshd"}, "dtsi" => {"text/x-devicetree-source"}, "dtx" => {"application/x-tex", "text/x-tex"}, "dv" => {"video/dv"}, "dvb" => {"video/vnd.dvb.file"}, "dvi" => {"application/x-dvi"}, "dvi.bz2" => {"application/x-bzdvi"}, "dvi.gz" => {"application/x-gzdvi"}, "dwd" => {"application/atsc-dwd+xml"}, "dwf" => {"model/vnd.dwf"}, "dwg" => {"image/vnd.dwg"}, "dxf" => {"image/vnd.dxf"}, "dxp" => {"application/vnd.spotfire.dxp"}, "dxr" => {"application/x-director"}, "e" => {"text/x-eiffel"}, "ear" => {"application/java-archive"}, "ecelp4800" => {"audio/vnd.nuera.ecelp4800"}, "ecelp7470" => {"audio/vnd.nuera.ecelp7470"}, "ecelp9600" => {"audio/vnd.nuera.ecelp9600"}, "ecma" => {"application/ecmascript"}, "edm" => {"application/vnd.novadigm.edm"}, "edx" => {"application/vnd.novadigm.edx"}, "efi" => {"application/vnd.microsoft.portable-executable"}, "efif" => {"application/vnd.picsel"}, "egon" => {"application/x-egon"}, "ei6" => {"application/vnd.pg.osasli"}, "eif" => {"text/x-eiffel"}, "el" => {"text/x-emacs-lisp"}, "emf" => {"application/emf", "application/x-emf", "application/x-msmetafile", "image/emf", "image/x-emf"}, "eml" => {"message/rfc822"}, "emma" => {"application/emma+xml"}, "emotionml" => {"application/emotionml+xml"}, "emp" => {"application/vnd.emusic-emusic_package"}, "emz" => {"application/x-msmetafile"}, "ent" => {"application/xml-external-parsed-entity", "text/xml-external-parsed-entity"}, "eol" => {"audio/vnd.digital-winds"}, "eot" => {"application/vnd.ms-fontobject"}, "eps" => {"application/postscript", "image/x-eps"}, "eps.bz2" => {"image/x-bzeps"}, "eps.gz" => {"image/x-gzeps"}, "epsf" => {"image/x-eps"}, "epsf.bz2" => {"image/x-bzeps"}, "epsf.gz" => {"image/x-gzeps"}, "epsi" => {"image/x-eps"}, "epsi.bz2" => {"image/x-bzeps"}, "epsi.gz" => {"image/x-gzeps"}, "epub" => {"application/epub+zip"}, "eris" => {"application/x-eris-link+cbor"}, "erl" => {"text/x-erlang"}, "es" => {"application/ecmascript", "text/ecmascript"}, "es3" => {"application/vnd.eszigno3+xml"}, "esa" => {"application/vnd.osgi.subsystem"}, "escn" => {"application/x-godot-scene"}, "esf" => {"application/vnd.epson.esf"}, "et3" => {"application/vnd.eszigno3+xml"}, "etheme" => {"application/x-e-theme"}, "etx" => {"text/x-setext"}, "eva" => {"application/x-eva"}, "evy" => {"application/x-envoy"}, "ex" => {"text/x-elixir"}, "exe" => {"application/vnd.microsoft.portable-executable", "application/x-dosexec", "application/x-ms-dos-executable", "application/x-ms-ne-executable", "application/x-msdos-program", "application/x-msdownload"}, "exi" => {"application/exi"}, "exp" => {"application/express"}, "exr" => {"image/aces", "image/x-exr"}, "exs" => {"text/x-elixir"}, "ext" => {"application/vnd.novadigm.ext"}, "ez" => {"application/andrew-inset"}, "ez2" => {"application/vnd.ezpix-album"}, "ez3" => {"application/vnd.ezpix-package"}, "f" => {"text/x-fortran"}, "f4a" => {"audio/m4a", "audio/mp4", "audio/x-m4a"}, "f4b" => {"audio/x-m4b"}, "f4v" => {"video/mp4", "video/mp4v-es", "video/x-f4v", "video/x-m4v"}, "f77" => {"text/x-fortran"}, "f90" => {"text/x-fortran"}, "f95" => {"text/x-fortran"}, "fasl" => {"text/x-common-lisp"}, "fb2" => {"application/x-fictionbook", "application/x-fictionbook+xml"}, "fb2.zip" => {"application/x-zip-compressed-fb2"}, "fbs" => {"image/vnd.fastbidsheet"}, "fcdt" => {"application/vnd.adobe.formscentral.fcdt"}, "fcs" => {"application/vnd.isac.fcs"}, "fd" => {"application/x-fd-file", "application/x-raw-floppy-disk-image"}, "fdf" => {"application/fdf", "application/vnd.fdf"}, "fds" => {"application/x-fds-disk"}, "fdt" => {"application/fdt+xml"}, "fe_launch" => {"application/vnd.denovo.fcselayout-link"}, "feature" => {"text/x-gherkin"}, "fg5" => {"application/vnd.fujitsu.oasysgp"}, "fgd" => {"application/x-director"}, "fh" => {"image/x-freehand"}, "fh4" => {"image/x-freehand"}, "fh5" => {"image/x-freehand"}, "fh7" => {"image/x-freehand"}, "fhc" => {"image/x-freehand"}, "fig" => {"application/x-xfig", "image/x-xfig"}, "fish" => {"application/x-fishscript", "text/x-fish"}, "fit" => {"application/fits", "image/fits", "image/x-fits"}, "fits" => {"application/fits", "image/fits", "image/x-fits"}, "fl" => {"application/x-fluid"}, "flac" => {"audio/flac", "audio/x-flac"}, "flatpak" => {"application/vnd.flatpak", "application/vnd.xdgapp"}, "flatpakref" => {"application/vnd.flatpak.ref"}, "flatpakrepo" => {"application/vnd.flatpak.repo"}, "flc" => {"video/fli", "video/x-fli", "video/x-flic"}, "fli" => {"video/fli", "video/x-fli", "video/x-flic"}, "flo" => {"application/vnd.micrografx.flo"}, "flv" => {"video/x-flv", "application/x-flash-video", "flv-application/octet-stream", "video/flv"}, "flw" => {"application/vnd.kde.kivio", "application/x-kivio"}, "flx" => {"text/vnd.fmi.flexstor"}, "fly" => {"text/vnd.fly"}, "fm" => {"application/vnd.framemaker", "application/x-frame"}, "fnc" => {"application/vnd.frogans.fnc"}, "fo" => {"application/vnd.software602.filler.form+xml", "text/x-xslfo"}, "fodg" => {"application/vnd.oasis.opendocument.graphics-flat-xml"}, "fodp" => {"application/vnd.oasis.opendocument.presentation-flat-xml"}, "fods" => {"application/vnd.oasis.opendocument.spreadsheet-flat-xml"}, "fodt" => {"application/vnd.oasis.opendocument.text-flat-xml"}, "for" => {"text/x-fortran"}, "fpx" => {"image/vnd.fpx", "image/x-fpx"}, "frame" => {"application/vnd.framemaker"}, "fsc" => {"application/vnd.fsc.weblaunch"}, "fst" => {"image/vnd.fst"}, "ftc" => {"application/vnd.fluxtime.clip"}, "fti" => {"application/vnd.anser-web-funds-transfer-initiation"}, "fts" => {"application/fits", "image/fits", "image/x-fits"}, "fvt" => {"video/vnd.fvt"}, "fxm" => {"video/x-javafx"}, "fxp" => {"application/vnd.adobe.fxp"}, "fxpl" => {"application/vnd.adobe.fxp"}, "fzs" => {"application/vnd.fuzzysheet"}, "g2w" => {"application/vnd.geoplan"}, "g3" => {"image/fax-g3", "image/g3fax"}, "g3w" => {"application/vnd.geospace"}, "gac" => {"application/vnd.groove-account"}, "gam" => {"application/x-tads"}, "gb" => {"application/x-gameboy-rom"}, "gba" => {"application/x-gba-rom"}, "gbc" => {"application/x-gameboy-color-rom"}, "gbr" => {"application/rpki-ghostbusters", "application/vnd.gerber", "application/x-gerber", "image/x-gimp-gbr"}, "gbrjob" => {"application/x-gerber-job"}, "gca" => {"application/x-gca-compressed"}, "gcode" => {"text/x.gcode"}, "gcrd" => {"text/directory", "text/vcard", "text/x-vcard"}, "gd" => {"application/x-gdscript"}, "gdi" => {"application/x-gd-rom-cue"}, "gdl" => {"model/vnd.gdl"}, "gdoc" => {"application/vnd.google-apps.document"}, "gdshader" => {"application/x-godot-shader"}, "ged" => {"application/x-gedcom", "text/gedcom", "text/vnd.familysearch.gedcom"}, "gedcom" => {"application/x-gedcom", "text/gedcom", "text/vnd.familysearch.gedcom"}, "gem" => {"application/x-gtar", "application/x-tar"}, "gen" => {"application/x-genesis-rom"}, "geo" => {"application/vnd.dynageo"}, "geo.json" => {"application/geo+json", "application/vnd.geo+json"}, "geojson" => {"application/geo+json", "application/vnd.geo+json"}, "gex" => {"application/vnd.geometry-explorer"}, "gf" => {"application/x-tex-gf"}, "gg" => {"application/x-gamegear-rom"}, "ggb" => {"application/vnd.geogebra.file"}, "ggs" => {"application/vnd.geogebra.slides"}, "ggt" => {"application/vnd.geogebra.tool"}, "ghf" => {"application/vnd.groove-help"}, "gif" => {"image/gif"}, "gih" => {"image/x-gimp-gih"}, "gim" => {"application/vnd.groove-identity-message"}, "glade" => {"application/x-glade"}, "glb" => {"model/gltf-binary"}, "gltf" => {"model/gltf+json"}, "gml" => {"application/gml+xml"}, "gmo" => {"application/x-gettext-translation"}, "gmx" => {"application/vnd.gmx"}, "gnc" => {"application/x-gnucash"}, "gnd" => {"application/gnunet-directory"}, "gnucash" => {"application/x-gnucash"}, "gnumeric" => {"application/x-gnumeric"}, "gnuplot" => {"application/x-gnuplot"}, "go" => {"text/x-go"}, "gp" => {"application/x-gnuplot"}, "gpg" => {"application/pgp", "application/pgp-encrypted", "application/pgp-keys", "application/pgp-signature"}, "gph" => {"application/vnd.flographit"}, "gplt" => {"application/x-gnuplot"}, "gpx" => {"application/gpx", "application/gpx+xml", "application/x-gpx", "application/x-gpx+xml"}, "gqf" => {"application/vnd.grafeq"}, "gqs" => {"application/vnd.grafeq"}, "gra" => {"application/x-graphite"}, "gradle" => {"text/x-gradle"}, "gram" => {"application/srgs"}, "gramps" => {"application/x-gramps-xml"}, "gre" => {"application/vnd.geometry-explorer"}, "groovy" => {"text/x-groovy"}, "grv" => {"application/vnd.groove-injector"}, "grxml" => {"application/srgs+xml"}, "gs" => {"text/x-genie"}, "gsf" => {"application/x-font-ghostscript", "application/x-font-type1"}, "gsh" => {"text/x-groovy"}, "gsheet" => {"application/vnd.google-apps.spreadsheet"}, "gslides" => {"application/vnd.google-apps.presentation"}, "gsm" => {"audio/x-gsm"}, "gtar" => {"application/x-gtar", "application/x-tar"}, "gtm" => {"application/vnd.groove-tool-message"}, "gtw" => {"model/vnd.gtw"}, "gv" => {"text/vnd.graphviz"}, "gvp" => {"text/google-video-pointer", "text/x-google-video-pointer"}, "gvy" => {"text/x-groovy"}, "gx" => {"text/x-gcode-gx"}, "gxf" => {"application/gxf"}, "gxt" => {"application/vnd.geonext"}, "gy" => {"text/x-groovy"}, "gz" => {"application/x-gzip", "application/gzip"}, "h" => {"text/x-c", "text/x-chdr"}, "h++" => {"text/x-c++hdr"}, "h261" => {"video/h261"}, "h263" => {"video/h263"}, "h264" => {"video/h264"}, "h4" => {"application/x-hdf"}, "h5" => {"application/x-hdf"}, "hal" => {"application/vnd.hal+xml"}, "hbci" => {"application/vnd.hbci"}, "hbs" => {"text/x-handlebars-template"}, "hdd" => {"application/x-virtualbox-hdd"}, "hdf" => {"application/x-hdf"}, "hdf4" => {"application/x-hdf"}, "hdf5" => {"application/x-hdf"}, "hdp" => {"image/jxr", "image/vnd.ms-photo"}, "hdr" => {"image/vnd.radiance"}, "heic" => {"image/heic", "image/heic-sequence", "image/heif", "image/heif-sequence"}, "heics" => {"image/heic-sequence"}, "heif" => {"image/heic", "image/heic-sequence", "image/heif", "image/heif-sequence"}, "heifs" => {"image/heif-sequence"}, "hej2" => {"image/hej2k"}, "held" => {"application/atsc-held+xml"}, "hfe" => {"application/x-hfe-file", "application/x-hfe-floppy-image"}, "hh" => {"text/x-c", "text/x-c++hdr"}, "hif" => {"image/heic", "image/heic-sequence", "image/heif", "image/heif-sequence"}, "hjson" => {"application/hjson"}, "hlp" => {"application/winhlp", "zz-application/zz-winassoc-hlp"}, "hp" => {"text/x-c++hdr"}, "hpgl" => {"application/vnd.hp-hpgl"}, "hpid" => {"application/vnd.hp-hpid"}, "hpp" => {"text/x-c++hdr"}, "hps" => {"application/vnd.hp-hps"}, "hqx" => {"application/stuffit", "application/mac-binhex40"}, "hs" => {"text/x-haskell"}, "hsj2" => {"image/hsj2"}, "hta" => {"application/hta"}, "htc" => {"text/x-component"}, "htke" => {"application/vnd.kenameaapp"}, "htm" => {"text/html", "application/xhtml+xml"}, "html" => {"text/html", "application/xhtml+xml"}, "hvd" => {"application/vnd.yamaha.hv-dic"}, "hvp" => {"application/vnd.yamaha.hv-voice"}, "hvs" => {"application/vnd.yamaha.hv-script"}, "hwp" => {"application/vnd.haansoft-hwp", "application/x-hwp"}, "hwt" => {"application/vnd.haansoft-hwt", "application/x-hwt"}, "hxx" => {"text/x-c++hdr"}, "i2g" => {"application/vnd.intergeo"}, "ica" => {"application/x-ica"}, "icalendar" => {"application/ics", "text/calendar", "text/x-vcalendar"}, "icb" => {"application/tga", "application/x-targa", "application/x-tga", "image/targa", "image/tga", "image/x-icb", "image/x-targa", "image/x-tga"}, "icc" => {"application/vnd.iccprofile"}, "ice" => {"x-conference/x-cooltalk"}, "icm" => {"application/vnd.iccprofile"}, "icns" => {"image/x-icns"}, "ico" => {"application/ico", "image/ico", "image/icon", "image/vnd.microsoft.icon", "image/x-ico", "image/x-icon", "text/ico"}, "ics" => {"application/ics", "text/calendar", "text/x-vcalendar"}, "idl" => {"text/x-idl"}, "ief" => {"image/ief"}, "ifb" => {"application/ics", "text/calendar", "text/x-vcalendar"}, "iff" => {"image/x-iff", "image/x-ilbm"}, "ifm" => {"application/vnd.shana.informed.formdata"}, "iges" => {"model/iges"}, "igl" => {"application/vnd.igloader"}, "igm" => {"application/vnd.insors.igm"}, "igs" => {"model/iges"}, "igx" => {"application/vnd.micrografx.igx"}, "iif" => {"application/vnd.shana.informed.interchange"}, "ilbm" => {"image/x-iff", "image/x-ilbm"}, "ime" => {"audio/imelody", "audio/x-imelody", "text/x-imelody"}, "img" => {"application/vnd.efi.img", "application/x-raw-disk-image"}, "img.xz" => {"application/x-raw-disk-image-xz-compressed"}, "imp" => {"application/vnd.accpac.simply.imp"}, "ims" => {"application/vnd.ms-ims"}, "imy" => {"audio/imelody", "audio/x-imelody", "text/x-imelody"}, "in" => {"text/plain"}, "ini" => {"text/plain"}, "ink" => {"application/inkml+xml"}, "inkml" => {"application/inkml+xml"}, "ins" => {"application/x-tex", "text/x-tex"}, "install" => {"application/x-install-instructions"}, "iota" => {"application/vnd.astraea-software.iota"}, "ipfix" => {"application/ipfix"}, "ipk" => {"application/vnd.shana.informed.package"}, "ips" => {"application/x-ips-patch"}, "iptables" => {"text/x-iptables"}, "ipynb" => {"application/x-ipynb+json"}, "irm" => {"application/vnd.ibm.rights-management"}, "irp" => {"application/vnd.irepository.package+xml"}, "iso" => {"application/vnd.efi.iso", "application/x-cd-image", "application/x-dreamcast-rom", "application/x-gamecube-iso-image", "application/x-gamecube-rom", "application/x-iso9660-image", "application/x-saturn-rom", "application/x-sega-cd-rom", "application/x-sega-pico-rom", "application/x-wbfs", "application/x-wia", "application/x-wii-iso-image", "application/x-wii-rom"}, "iso9660" => {"application/vnd.efi.iso", "application/x-cd-image", "application/x-iso9660-image"}, "it" => {"audio/x-it"}, "it87" => {"application/x-it87"}, "itp" => {"application/vnd.shana.informed.formtemplate"}, "its" => {"application/its+xml"}, "ivp" => {"application/vnd.immervision-ivp"}, "ivu" => {"application/vnd.immervision-ivu"}, "j2c" => {"image/x-jp2-codestream"}, "j2k" => {"image/x-jp2-codestream"}, "jad" => {"text/vnd.sun.j2me.app-descriptor"}, "jade" => {"text/jade"}, "jam" => {"application/vnd.jam"}, "jar" => {"application/x-java-archive", "application/java-archive", "application/x-jar"}, "jardiff" => {"application/x-java-archive-diff"}, "java" => {"text/x-java", "text/x-java-source"}, "jceks" => {"application/x-java-jce-keystore"}, "jfif" => {"image/jpeg", "image/pjpeg"}, "jhc" => {"image/jphc"}, "jisp" => {"application/vnd.jisp"}, "jks" => {"application/x-java-keystore"}, "jl" => {"text/julia"}, "jls" => {"image/jls"}, "jlt" => {"application/vnd.hp-jlyt"}, "jng" => {"image/x-jng"}, "jnlp" => {"application/x-java-jnlp-file"}, "joda" => {"application/vnd.joost.joda-archive"}, "jp2" => {"image/jp2", "image/jpeg2000", "image/jpeg2000-image", "image/x-jpeg2000-image"}, "jpc" => {"image/x-jp2-codestream"}, "jpe" => {"image/jpeg", "image/pjpeg"}, "jpeg" => {"image/jpeg", "image/pjpeg"}, "jpf" => {"image/jpx"}, "jpg" => {"image/jpeg", "image/pjpeg"}, "jpg2" => {"image/jp2", "image/jpeg2000", "image/jpeg2000-image", "image/x-jpeg2000-image"}, "jpgm" => {"image/jpm", "video/jpm"}, "jpgv" => {"video/jpeg"}, "jph" => {"image/jph"}, "jpm" => {"image/jpm", "video/jpm"}, "jpr" => {"application/x-jbuilder-project"}, "jpx" => {"application/x-jbuilder-project", "image/jpx"}, "jrd" => {"application/jrd+json"}, "js" => {"text/javascript", "application/javascript", "application/x-javascript", "text/jscript"}, "jse" => {"text/jscript.encode"}, "jsm" => {"application/javascript", "application/x-javascript", "text/javascript", "text/jscript"}, "json" => {"application/json", "application/schema+json"}, "json-patch" => {"application/json-patch+json"}, "json5" => {"application/json5"}, "jsonld" => {"application/ld+json"}, "jsonml" => {"application/jsonml+json"}, "jsx" => {"text/jsx"}, "jt" => {"model/jt"}, "jxl" => {"image/jxl"}, "jxr" => {"image/jxr", "image/vnd.ms-photo"}, "jxra" => {"image/jxra"}, "jxrs" => {"image/jxrs"}, "jxs" => {"image/jxs"}, "jxsc" => {"image/jxsc"}, "jxsi" => {"image/jxsi"}, "jxss" => {"image/jxss"}, "k25" => {"image/x-kodak-k25"}, "k7" => {"application/x-thomson-cassette"}, "kar" => {"audio/midi", "audio/x-midi"}, "karbon" => {"application/vnd.kde.karbon", "application/x-karbon"}, "kcf" => {"image/x-kiss-cel"}, "kdbx" => {"application/x-keepass2"}, "kdc" => {"image/x-kodak-kdc"}, "kdelnk" => {"application/x-desktop", "application/x-gnome-app-info"}, "kexi" => {"application/x-kexiproject-sqlite", "application/x-kexiproject-sqlite2", "application/x-kexiproject-sqlite3", "application/x-vnd.kde.kexi"}, "kexic" => {"application/x-kexi-connectiondata"}, "kexis" => {"application/x-kexiproject-shortcut"}, "key" => {"application/vnd.apple.keynote", "application/pgp-keys", "application/x-iwork-keynote-sffkey"}, "keynote" => {"application/vnd.apple.keynote"}, "kfo" => {"application/vnd.kde.kformula", "application/x-kformula"}, "kfx" => {"application/vnd.amazon.mobi8-ebook", "application/x-mobi8-ebook"}, "kia" => {"application/vnd.kidspiration"}, "kil" => {"application/x-killustrator"}, "kino" => {"application/smil", "application/smil+xml"}, "kml" => {"application/vnd.google-earth.kml+xml"}, "kmz" => {"application/vnd.google-earth.kmz"}, "kne" => {"application/vnd.kinar"}, "knp" => {"application/vnd.kinar"}, "kon" => {"application/vnd.kde.kontour", "application/x-kontour"}, "kpm" => {"application/x-kpovmodeler"}, "kpr" => {"application/vnd.kde.kpresenter", "application/x-kpresenter"}, "kpt" => {"application/vnd.kde.kpresenter", "application/x-kpresenter"}, "kpxx" => {"application/vnd.ds-keypoint"}, "kra" => {"application/x-krita"}, "krz" => {"application/x-krita"}, "ks" => {"application/x-java-keystore"}, "ksp" => {"application/vnd.kde.kspread", "application/x-kspread"}, "ksy" => {"text/x-kaitai-struct"}, "kt" => {"text/x-kotlin"}, "ktr" => {"application/vnd.kahootz"}, "ktx" => {"image/ktx"}, "ktx2" => {"image/ktx2"}, "ktz" => {"application/vnd.kahootz"}, "kud" => {"application/x-kugar"}, "kwd" => {"application/vnd.kde.kword", "application/x-kword"}, "kwt" => {"application/vnd.kde.kword", "application/x-kword"}, "la" => {"application/x-shared-library-la"}, "lasxml" => {"application/vnd.las.las+xml"}, "latex" => {"application/x-latex", "application/x-tex", "text/x-tex"}, "lbd" => {"application/vnd.llamagraphics.life-balance.desktop"}, "lbe" => {"application/vnd.llamagraphics.life-balance.exchange+xml"}, "lbm" => {"image/x-iff", "image/x-ilbm"}, "ldif" => {"text/x-ldif"}, "les" => {"application/vnd.hhe.lesson-player"}, "less" => {"text/less"}, "lgr" => {"application/lgr+xml"}, "lha" => {"application/x-lha", "application/x-lzh-compressed"}, "lhs" => {"text/x-literate-haskell"}, "lhz" => {"application/x-lhz"}, "lib" => {"application/vnd.microsoft.portable-executable", "application/x-archive"}, "link66" => {"application/vnd.route66.link66+xml"}, "lisp" => {"text/x-common-lisp"}, "list" => {"text/plain"}, "list3820" => {"application/vnd.ibm.modcap"}, "listafp" => {"application/vnd.ibm.modcap"}, "litcoffee" => {"text/coffeescript"}, "lmdb" => {"application/x-lmdb"}, "lnk" => {"application/x-ms-shortcut", "application/x-win-lnk"}, "lnx" => {"application/x-atari-lynx-rom"}, "loas" => {"audio/usac"}, "log" => {"text/plain", "text/x-log"}, "lostxml" => {"application/lost+xml"}, "lrf" => {"application/x-sony-bbeb", "video/mp4", "video/mp4v-es", "video/x-m4v"}, "lrm" => {"application/vnd.ms-lrm"}, "lrv" => {"video/mp4", "video/mp4v-es", "video/x-m4v"}, "lrz" => {"application/x-lrzip"}, "ltf" => {"application/vnd.frogans.ltf"}, "ltx" => {"application/x-tex", "text/x-tex"}, "lua" => {"text/x-lua"}, "luac" => {"application/x-lua-bytecode"}, "lvp" => {"audio/vnd.lucent.voice"}, "lwo" => {"image/x-lwo"}, "lwob" => {"image/x-lwo"}, "lwp" => {"application/vnd.lotus-wordpro"}, "lws" => {"image/x-lws"}, "ly" => {"text/x-lilypond"}, "lyx" => {"application/x-lyx", "text/x-lyx"}, "lz" => {"application/x-lzip"}, "lz4" => {"application/x-lz4"}, "lzh" => {"application/x-lha", "application/x-lzh-compressed"}, "lzma" => {"application/x-lzma"}, "lzo" => {"application/x-lzop"}, "m" => {"text/x-matlab", "text/x-objcsrc", "text/x-octave"}, "m13" => {"application/x-msmediaview"}, "m14" => {"application/x-msmediaview"}, "m15" => {"audio/x-mod"}, "m1u" => {"video/vnd.mpegurl", "video/x-mpegurl"}, "m1v" => {"video/mpeg"}, "m21" => {"application/mp21"}, "m2a" => {"audio/mpeg"}, "m2t" => {"video/mp2t"}, "m2ts" => {"video/mp2t"}, "m2v" => {"video/mpeg"}, "m3a" => {"audio/mpeg"}, "m3u" => {"audio/x-mpegurl", "application/m3u", "application/vnd.apple.mpegurl", "audio/m3u", "audio/mpegurl", "audio/x-m3u", "audio/x-mp3-playlist"}, "m3u8" => {"application/m3u", "application/vnd.apple.mpegurl", "audio/m3u", "audio/mpegurl", "audio/x-m3u", "audio/x-mp3-playlist", "audio/x-mpegurl"}, "m4" => {"application/x-m4"}, "m4a" => {"audio/mp4", "audio/m4a", "audio/x-m4a"}, "m4b" => {"audio/x-m4b"}, "m4p" => {"application/mp4"}, "m4r" => {"audio/x-m4r"}, "m4s" => {"video/iso.segment"}, "m4u" => {"video/vnd.mpegurl", "video/x-mpegurl"}, "m4v" => {"video/mp4", "video/mp4v-es", "video/x-m4v"}, "m7" => {"application/x-thomson-cartridge-memo7"}, "ma" => {"application/mathematica"}, "mab" => {"application/x-markaby"}, "mads" => {"application/mads+xml"}, "maei" => {"application/mmt-aei+xml"}, "mag" => {"application/vnd.ecowin.chart"}, "mak" => {"text/x-makefile"}, "maker" => {"application/vnd.framemaker"}, "man" => {"application/x-troff-man", "text/troff"}, "manifest" => {"text/cache-manifest"}, "map" => {"application/json"}, "markdown" => {"text/markdown", "text/x-markdown"}, "mathml" => {"application/mathml+xml"}, "mb" => {"application/mathematica"}, "mbk" => {"application/vnd.mobius.mbk"}, "mbox" => {"application/mbox"}, "mc1" => {"application/vnd.medcalcdata"}, "mc2" => {"text/vnd.senx.warpscript"}, "mcd" => {"application/vnd.mcd"}, "mcurl" => {"text/vnd.curl.mcurl"}, "md" => {"text/markdown", "text/x-markdown", "application/x-genesis-rom"}, "mdb" => {"application/x-msaccess", "application/mdb", "application/msaccess", "application/vnd.ms-access", "application/vnd.msaccess", "application/x-lmdb", "application/x-mdb", "zz-application/zz-winassoc-mdb"}, "mdi" => {"image/vnd.ms-modi"}, "mdx" => {"application/x-genesis-32x-rom", "text/mdx"}, "me" => {"text/troff", "text/x-troff-me"}, "med" => {"audio/x-mod"}, "mesh" => {"model/mesh"}, "meta4" => {"application/metalink4+xml"}, "metalink" => {"application/metalink+xml"}, "mets" => {"application/mets+xml"}, "mfm" => {"application/vnd.mfmp"}, "mft" => {"application/rpki-manifest"}, "mgp" => {"application/vnd.osgeo.mapguide.package", "application/x-magicpoint"}, "mgz" => {"application/vnd.proteus.magazine"}, "mht" => {"application/x-mimearchive"}, "mhtml" => {"application/x-mimearchive"}, "mid" => {"audio/midi", "audio/x-midi"}, "midi" => {"audio/midi", "audio/x-midi"}, "mie" => {"application/x-mie"}, "mif" => {"application/vnd.mif", "application/x-mif"}, "mime" => {"message/rfc822"}, "minipsf" => {"audio/x-minipsf"}, "mj2" => {"video/mj2"}, "mjp2" => {"video/mj2"}, "mjpeg" => {"video/x-mjpeg"}, "mjpg" => {"video/x-mjpeg"}, "mjs" => {"application/javascript", "application/x-javascript", "text/javascript", "text/jscript"}, "mk" => {"text/x-makefile"}, "mk3d" => {"video/x-matroska", "video/x-matroska-3d"}, "mka" => {"audio/x-matroska"}, "mkd" => {"text/markdown", "text/x-markdown"}, "mks" => {"video/x-matroska"}, "mkv" => {"video/x-matroska"}, "ml" => {"text/x-ocaml"}, "mli" => {"text/x-ocaml"}, "mlp" => {"application/vnd.dolby.mlp"}, "mm" => {"text/x-objc++src", "text/x-troff-mm"}, "mmd" => {"application/vnd.chipnuts.karaoke-mmd"}, "mmf" => {"application/vnd.smaf", "application/x-smaf"}, "mml" => {"application/mathml+xml", "text/mathml"}, "mmr" => {"image/vnd.fujixerox.edmics-mmr"}, "mng" => {"video/x-mng"}, "mny" => {"application/x-msmoney"}, "mo" => {"application/x-gettext-translation", "text/x-modelica"}, "mo3" => {"audio/x-mo3"}, "mobi" => {"application/x-mobipocket-ebook"}, "moc" => {"text/x-moc"}, "mod" => {"application/x-object", "audio/x-mod"}, "mods" => {"application/mods+xml"}, "mof" => {"text/x-mof"}, "moov" => {"video/quicktime"}, "mount" => {"text/x-systemd-unit"}, "mov" => {"video/quicktime"}, "movie" => {"video/x-sgi-movie"}, "mp+" => {"audio/x-musepack"}, "mp2" => {"audio/mp2", "audio/mpeg", "audio/x-mp2", "video/mpeg", "video/mpeg-system", "video/x-mpeg", "video/x-mpeg-system", "video/x-mpeg2"}, "mp21" => {"application/mp21"}, "mp2a" => {"audio/mpeg"}, "mp3" => {"audio/mpeg", "audio/mp3", "audio/x-mp3", "audio/x-mpeg", "audio/x-mpg"}, "mp4" => {"video/mp4", "application/mp4", "video/mp4v-es", "video/x-m4v"}, "mp4a" => {"audio/mp4"}, "mp4s" => {"application/mp4"}, "mp4v" => {"video/mp4"}, "mpc" => {"application/vnd.mophun.certificate", "audio/x-musepack"}, "mpd" => {"application/dash+xml"}, "mpe" => {"video/mpeg", "video/mpeg-system", "video/x-mpeg", "video/x-mpeg-system", "video/x-mpeg2"}, "mpeg" => {"video/mpeg", "video/mpeg-system", "video/x-mpeg", "video/x-mpeg-system", "video/x-mpeg2"}, "mpf" => {"application/media-policy-dataset+xml"}, "mpg" => {"video/mpeg", "video/mpeg-system", "video/x-mpeg", "video/x-mpeg-system", "video/x-mpeg2"}, "mpg4" => {"video/mpg4", "application/mp4", "video/mp4"}, "mpga" => {"audio/mp3", "audio/mpeg", "audio/x-mp3", "audio/x-mpeg", "audio/x-mpg"}, "mpkg" => {"application/vnd.apple.installer+xml"}, "mpl" => {"text/x-mpl2", "video/mp2t"}, "mpls" => {"video/mp2t"}, "mpm" => {"application/vnd.blueice.multipass"}, "mpn" => {"application/vnd.mophun.application"}, "mpp" => {"application/dash-patch+xml", "application/vnd.ms-project", "audio/x-musepack"}, "mpt" => {"application/vnd.ms-project"}, "mpy" => {"application/vnd.ibm.minipay"}, "mqy" => {"application/vnd.mobius.mqy"}, "mrc" => {"application/marc"}, "mrcx" => {"application/marcxml+xml"}, "mrl" => {"text/x-mrml"}, "mrml" => {"text/x-mrml"}, "mrpack" => {"application/x-modrinth-modpack+zip"}, "mrw" => {"image/x-minolta-mrw"}, "ms" => {"text/troff", "text/x-troff-ms"}, "mscml" => {"application/mediaservercontrol+xml"}, "mseed" => {"application/vnd.fdsn.mseed"}, "mseq" => {"application/vnd.mseq"}, "msf" => {"application/vnd.epson.msf"}, "msg" => {"application/vnd.ms-outlook"}, "msh" => {"model/mesh"}, "msi" => {"application/x-msdownload", "application/x-msi"}, "msix" => {"application/msix"}, "msixbundle" => {"application/msixbundle"}, "msl" => {"application/vnd.mobius.msl"}, "msod" => {"image/x-msod"}, "msp" => {"application/microsoftpatch"}, "msty" => {"application/vnd.muvee.style"}, "msu" => {"application/microsoftupdate"}, "msx" => {"application/x-msx-rom"}, "mtl" => {"model/mtl"}, "mtm" => {"audio/x-mod"}, "mts" => {"application/typescript", "model/vnd.mts", "video/mp2t"}, "mup" => {"text/x-mup"}, "mus" => {"application/vnd.musician"}, "musd" => {"application/mmt-usd+xml"}, "musicxml" => {"application/vnd.recordare.musicxml+xml"}, "mvb" => {"application/x-msmediaview"}, "mvt" => {"application/vnd.mapbox-vector-tile"}, "mwf" => {"application/vnd.mfer"}, "mxf" => {"application/mxf"}, "mxl" => {"application/vnd.recordare.musicxml"}, "mxmf" => {"audio/mobile-xmf", "audio/vnd.nokia.mobile-xmf"}, "mxml" => {"application/xv+xml"}, "mxs" => {"application/vnd.triscape.mxs"}, "mxu" => {"video/vnd.mpegurl", "video/x-mpegurl"}, "n-gage" => {"application/vnd.nokia.n-gage.symbian.install"}, "n3" => {"text/n3"}, "n64" => {"application/x-n64-rom"}, "nb" => {"application/mathematica", "application/x-mathematica"}, "nbp" => {"application/vnd.wolfram.player"}, "nc" => {"application/x-netcdf"}, "ncx" => {"application/x-dtbncx+xml"}, "nds" => {"application/x-nintendo-ds-rom"}, "nef" => {"image/x-nikon-nef"}, "nes" => {"application/x-nes-rom"}, "nez" => {"application/x-nes-rom"}, "nfo" => {"text/x-nfo"}, "ngc" => {"application/x-neo-geo-pocket-color-rom"}, "ngdat" => {"application/vnd.nokia.n-gage.data"}, "ngp" => {"application/x-neo-geo-pocket-rom"}, "nim" => {"text/x-nim"}, "nimble" => {"text/x-nimscript"}, "nims" => {"text/x-nimscript"}, "nitf" => {"application/vnd.nitf"}, "nix" => {"text/x-nix"}, "nlu" => {"application/vnd.neurolanguage.nlu"}, "nml" => {"application/vnd.enliven"}, "nnd" => {"application/vnd.noblenet-directory"}, "nns" => {"application/vnd.noblenet-sealer"}, "nnw" => {"application/vnd.noblenet-web"}, "not" => {"text/x-mup"}, "npx" => {"image/vnd.net-fpx"}, "nq" => {"application/n-quads"}, "nrw" => {"image/x-nikon-nrw"}, "nsc" => {"application/x-conference", "application/x-netshow-channel"}, "nsf" => {"application/vnd.lotus-notes"}, "nsv" => {"video/x-nsv"}, "nt" => {"application/n-triples"}, "ntar" => {"application/x-pcapng"}, "ntf" => {"application/vnd.nitf"}, "nu" => {"application/x-nuscript", "text/x-nu", "text/x-nushell"}, "numbers" => {"application/vnd.apple.numbers", "application/x-iwork-numbers-sffnumbers"}, "nzb" => {"application/x-nzb"}, "o" => {"application/x-object"}, "oa2" => {"application/vnd.fujitsu.oasys2"}, "oa3" => {"application/vnd.fujitsu.oasys3"}, "oas" => {"application/vnd.fujitsu.oasys"}, "obd" => {"application/x-msbinder"}, "obgx" => {"application/vnd.openblox.game+xml"}, "obj" => {"application/prs.wavefront-obj", "application/x-tgif", "model/obj"}, "ocl" => {"text/x-ocl"}, "ocx" => {"application/vnd.microsoft.portable-executable"}, "oda" => {"application/oda"}, "odb" => {"application/vnd.oasis.opendocument.base", "application/vnd.oasis.opendocument.database", "application/vnd.sun.xml.base"}, "odc" => {"application/vnd.oasis.opendocument.chart"}, "odf" => {"application/vnd.oasis.opendocument.formula"}, "odft" => {"application/vnd.oasis.opendocument.formula-template"}, "odg" => {"application/vnd.oasis.opendocument.graphics"}, "odi" => {"application/vnd.oasis.opendocument.image"}, "odm" => {"application/vnd.oasis.opendocument.text-master"}, "odp" => {"application/vnd.oasis.opendocument.presentation"}, "ods" => {"application/vnd.oasis.opendocument.spreadsheet"}, "odt" => {"application/vnd.oasis.opendocument.text"}, "oga" => {"audio/ogg", "audio/vorbis", "audio/x-flac+ogg", "audio/x-ogg", "audio/x-oggflac", "audio/x-speex+ogg", "audio/x-vorbis", "audio/x-vorbis+ogg"}, "ogex" => {"model/vnd.opengex"}, "ogg" => {"audio/ogg", "audio/vorbis", "audio/x-flac+ogg", "audio/x-ogg", "audio/x-oggflac", "audio/x-speex+ogg", "audio/x-vorbis", "audio/x-vorbis+ogg", "video/ogg", "video/x-ogg", "video/x-theora", "video/x-theora+ogg"}, "ogm" => {"video/x-ogm", "video/x-ogm+ogg"}, "ogv" => {"video/ogg", "video/x-ogg"}, "ogx" => {"application/ogg", "application/x-ogg"}, "old" => {"application/x-trash"}, "oleo" => {"application/x-oleo"}, "omdoc" => {"application/omdoc+xml"}, "onepkg" => {"application/onenote"}, "onetmp" => {"application/onenote"}, "onetoc" => {"application/onenote"}, "onetoc2" => {"application/onenote"}, "ooc" => {"text/x-ooc"}, "openvpn" => {"application/x-openvpn-profile"}, "opf" => {"application/oebps-package+xml"}, "opml" => {"text/x-opml", "text/x-opml+xml"}, "oprc" => {"application/vnd.palm", "application/x-palm-database"}, "opus" => {"audio/ogg", "audio/x-ogg", "audio/x-opus+ogg"}, "ora" => {"image/openraster"}, "orf" => {"image/x-olympus-orf"}, "org" => {"application/vnd.lotus-organizer", "text/org", "text/x-org"}, "osf" => {"application/vnd.yamaha.openscoreformat"}, "osfpvg" => {"application/vnd.yamaha.openscoreformat.osfpvg+xml"}, "osm" => {"application/vnd.openstreetmap.data+xml"}, "otc" => {"application/vnd.oasis.opendocument.chart-template"}, "otf" => {"application/vnd.oasis.opendocument.formula-template", "application/x-font-otf", "font/otf"}, "otg" => {"application/vnd.oasis.opendocument.graphics-template"}, "oth" => {"application/vnd.oasis.opendocument.text-web"}, "oti" => {"application/vnd.oasis.opendocument.image-template"}, "otm" => {"application/vnd.oasis.opendocument.text-master-template"}, "otp" => {"application/vnd.oasis.opendocument.presentation-template"}, "ots" => {"application/vnd.oasis.opendocument.spreadsheet-template"}, "ott" => {"application/vnd.oasis.opendocument.text-template"}, "ova" => {"application/ovf", "application/x-virtualbox-ova"}, "ovf" => {"application/x-virtualbox-ovf"}, "ovpn" => {"application/x-openvpn-profile"}, "owl" => {"application/rdf+xml", "text/rdf"}, "owx" => {"application/owl+xml"}, "oxps" => {"application/oxps"}, "oxt" => {"application/vnd.openofficeorg.extension"}, "p" => {"text/x-pascal"}, "p10" => {"application/pkcs10"}, "p12" => {"application/pkcs12", "application/x-pkcs12"}, "p65" => {"application/x-pagemaker"}, "p7b" => {"application/x-pkcs7-certificates"}, "p7c" => {"application/pkcs7-mime"}, "p7m" => {"application/pkcs7-mime"}, "p7r" => {"application/x-pkcs7-certreqresp"}, "p7s" => {"application/pkcs7-signature"}, "p8" => {"application/pkcs8"}, "p8e" => {"application/pkcs8-encrypted"}, "pac" => {"application/x-ns-proxy-autoconfig"}, "pack" => {"application/x-java-pack200"}, "pages" => {"application/vnd.apple.pages", "application/x-iwork-pages-sffpages"}, "pak" => {"application/x-pak"}, "par2" => {"application/x-par2"}, "parquet" => {"application/vnd.apache.parquet", "application/x-parquet"}, "part" => {"application/x-partial-download"}, "pas" => {"text/x-pascal"}, "pat" => {"image/x-gimp-pat"}, "patch" => {"text/x-diff", "text/x-patch"}, "path" => {"text/x-systemd-unit"}, "paw" => {"application/vnd.pawaafile"}, "pbd" => {"application/vnd.powerbuilder6"}, "pbm" => {"image/x-portable-bitmap"}, "pcap" => {"application/pcap", "application/vnd.tcpdump.pcap", "application/x-pcap"}, "pcapng" => {"application/x-pcapng"}, "pcd" => {"image/x-photo-cd"}, "pce" => {"application/x-pc-engine-rom"}, "pcf" => {"application/x-cisco-vpn-settings", "application/x-font-pcf"}, "pcf.Z" => {"application/x-font-pcf"}, "pcf.gz" => {"application/x-font-pcf"}, "pcl" => {"application/vnd.hp-pcl"}, "pclxl" => {"application/vnd.hp-pclxl"}, "pct" => {"image/x-pict"}, "pcurl" => {"application/vnd.curl.pcurl"}, "pcx" => {"image/vnd.zbrush.pcx", "image/x-pcx"}, "pdb" => {"application/vnd.palm", "application/x-aportisdoc", "application/x-ms-pdb", "application/x-palm-database", "application/x-pilot", "chemical/x-pdb"}, "pdc" => {"application/x-aportisdoc"}, "pde" => {"text/x-processing"}, "pdf" => {"application/pdf", "application/acrobat", "application/nappdf", "application/x-pdf", "image/pdf"}, "pdf.bz2" => {"application/x-bzpdf"}, "pdf.gz" => {"application/x-gzpdf"}, "pdf.lz" => {"application/x-lzpdf"}, "pdf.xz" => {"application/x-xzpdf"}, "pef" => {"image/x-pentax-pef"}, "pem" => {"application/x-x509-ca-cert"}, "perl" => {"application/x-perl", "text/x-perl"}, "pfa" => {"application/x-font-type1"}, "pfb" => {"application/x-font-type1"}, "pfm" => {"application/x-font-type1", "image/x-pfm"}, "pfr" => {"application/font-tdpfr", "application/vnd.truedoc"}, "pfx" => {"application/pkcs12", "application/x-pkcs12"}, "pgm" => {"image/x-portable-graymap"}, "pgn" => {"application/vnd.chess-pgn", "application/x-chess-pgn"}, "pgp" => {"application/pgp", "application/pgp-encrypted", "application/pgp-keys", "application/pgp-signature"}, "phm" => {"image/x-phm"}, "php" => {"application/x-php", "application/x-httpd-php"}, "php3" => {"application/x-php"}, "php4" => {"application/x-php"}, "php5" => {"application/x-php"}, "phps" => {"application/x-php"}, "pic" => {"image/vnd.radiance", "image/x-pict"}, "pict" => {"image/x-pict"}, "pict1" => {"image/x-pict"}, "pict2" => {"image/x-pict"}, "pk" => {"application/x-tex-pk"}, "pkg" => {"application/x-xar"}, "pki" => {"application/pkixcmp"}, "pkipath" => {"application/pkix-pkipath"}, "pkpass" => {"application/vnd.apple.pkpass"}, "pkpasses" => {"application/vnd.apple.pkpasses"}, "pkr" => {"application/pgp-keys"}, "pl" => {"application/x-perl", "text/x-perl"}, "pla" => {"audio/x-iriver-pla"}, "plb" => {"application/vnd.3gpp.pic-bw-large"}, "plc" => {"application/vnd.mobius.plc"}, "plf" => {"application/vnd.pocketlearn"}, "pln" => {"application/x-planperfect"}, "pls" => {"application/pls", "application/pls+xml", "audio/scpls", "audio/x-scpls"}, "pm" => {"application/x-pagemaker", "application/x-perl", "text/x-perl"}, "pm6" => {"application/x-pagemaker"}, "pmd" => {"application/x-pagemaker"}, "pml" => {"application/vnd.ctc-posml"}, "png" => {"image/png", "image/apng", "image/vnd.mozilla.apng"}, "pnm" => {"image/x-portable-anymap"}, "pntg" => {"image/x-macpaint"}, "po" => {"application/x-gettext", "text/x-gettext-translation", "text/x-po"}, "pod" => {"application/x-perl", "text/x-perl"}, "por" => {"application/x-spss-por"}, "portpkg" => {"application/vnd.macports.portpkg"}, "pot" => {"application/mspowerpoint", "application/powerpoint", "application/vnd.ms-powerpoint", "application/x-mspowerpoint", "text/x-gettext-translation-template", "text/x-pot"}, "potm" => {"application/vnd.ms-powerpoint.template.macroenabled.12"}, "potx" => {"application/vnd.openxmlformats-officedocument.presentationml.template"}, "ppam" => {"application/vnd.ms-powerpoint.addin.macroenabled.12"}, "ppd" => {"application/vnd.cups-ppd"}, "ppm" => {"image/x-portable-pixmap"}, "pps" => {"application/mspowerpoint", "application/powerpoint", "application/vnd.ms-powerpoint", "application/x-mspowerpoint"}, "ppsm" => {"application/vnd.ms-powerpoint.slideshow.macroenabled.12"}, "ppsx" => {"application/vnd.openxmlformats-officedocument.presentationml.slideshow"}, "ppt" => {"application/vnd.ms-powerpoint", "application/mspowerpoint", "application/powerpoint", "application/x-mspowerpoint"}, "pptm" => {"application/vnd.ms-powerpoint.presentation.macroenabled.12"}, "pptx" => {"application/vnd.openxmlformats-officedocument.presentationml.presentation"}, "ppz" => {"application/mspowerpoint", "application/powerpoint", "application/vnd.ms-powerpoint", "application/x-mspowerpoint"}, "pqa" => {"application/vnd.palm", "application/x-palm-database"}, "prc" => {"application/vnd.palm", "application/x-mobipocket-ebook", "application/x-palm-database", "application/x-pilot", "model/prc"}, "pre" => {"application/vnd.lotus-freelance"}, "prf" => {"application/pics-rules"}, "provx" => {"application/provenance+xml"}, "ps" => {"application/postscript"}, "ps.bz2" => {"application/x-bzpostscript"}, "ps.gz" => {"application/x-gzpostscript"}, "ps1" => {"application/x-powershell"}, "psb" => {"application/vnd.3gpp.pic-bw-small"}, "psd" => {"application/photoshop", "application/x-photoshop", "image/photoshop", "image/psd", "image/vnd.adobe.photoshop", "image/x-photoshop", "image/x-psd"}, "psf" => {"application/x-font-linux-psf", "audio/x-psf"}, "psf.gz" => {"application/x-gz-font-linux-psf"}, "psflib" => {"audio/x-psflib"}, "psid" => {"audio/prs.sid"}, "pskcxml" => {"application/pskc+xml"}, "psw" => {"application/x-pocket-word"}, "pti" => {"image/prs.pti"}, "ptid" => {"application/vnd.pvi.ptid1"}, "pub" => {"application/vnd.ms-publisher", "application/x-mspublisher", "text/x-ssh-public-key"}, "pvb" => {"application/vnd.3gpp.pic-bw-var"}, "pw" => {"application/x-pw"}, "pwn" => {"application/vnd.3m.post-it-notes"}, "pxd" => {"text/x-cython"}, "pxi" => {"text/x-cython"}, "pxr" => {"image/x-pxr"}, "py" => {"text/x-python", "text/x-python2", "text/x-python3"}, "py2" => {"text/x-python2"}, "py3" => {"text/x-python3"}, "pya" => {"audio/vnd.ms-playready.media.pya"}, "pyc" => {"application/x-python-bytecode"}, "pyi" => {"text/x-python3"}, "pyo" => {"application/x-python-bytecode", "model/vnd.pytha.pyox"}, "pyox" => {"model/vnd.pytha.pyox"}, "pys" => {"application/x-pyspread-bz-spreadsheet"}, "pysu" => {"application/x-pyspread-spreadsheet"}, "pyv" => {"video/vnd.ms-playready.media.pyv"}, "pyx" => {"text/x-cython"}, "qam" => {"application/vnd.epson.quickanime"}, "qbo" => {"application/vnd.intu.qbo"}, "qbrew" => {"application/x-qbrew"}, "qcow" => {"application/x-qemu-disk"}, "qcow2" => {"application/x-qemu-disk"}, "qd" => {"application/x-fd-file", "application/x-raw-floppy-disk-image"}, "qed" => {"application/x-qed-disk"}, "qfx" => {"application/vnd.intu.qfx"}, "qif" => {"application/x-qw", "image/x-quicktime"}, "qml" => {"text/x-qml"}, "qmlproject" => {"text/x-qml"}, "qmltypes" => {"text/x-qml"}, "qoi" => {"image/qoi"}, "qp" => {"application/x-qpress"}, "qps" => {"application/vnd.publishare-delta-tree"}, "qpw" => {"application/x-quattropro"}, "qs" => {"application/sparql-query"}, "qt" => {"video/quicktime"}, "qti" => {"application/x-qtiplot"}, "qti.gz" => {"application/x-qtiplot"}, "qtif" => {"image/x-quicktime"}, "qtl" => {"application/x-quicktime-media-link", "application/x-quicktimeplayer"}, "qtvr" => {"video/quicktime"}, "qwd" => {"application/vnd.quark.quarkxpress"}, "qwt" => {"application/vnd.quark.quarkxpress"}, "qxb" => {"application/vnd.quark.quarkxpress"}, "qxd" => {"application/vnd.quark.quarkxpress"}, "qxl" => {"application/vnd.quark.quarkxpress"}, "qxp" => {"application/vnd.quark.quarkxpress"}, "qxt" => {"application/vnd.quark.quarkxpress"}, "ra" => {"audio/vnd.m-realaudio", "audio/vnd.rn-realaudio", "audio/x-pn-realaudio", "audio/x-realaudio"}, "raf" => {"image/x-fuji-raf"}, "ram" => {"application/ram", "audio/x-pn-realaudio"}, "raml" => {"application/raml+yaml"}, "rapd" => {"application/route-apd+xml"}, "rar" => {"application/x-rar-compressed", "application/vnd.rar", "application/x-rar"}, "ras" => {"image/x-cmu-raster"}, "raw" => {"image/x-panasonic-raw", "image/x-panasonic-rw"}, "raw-disk-image" => {"application/vnd.efi.img", "application/x-raw-disk-image"}, "raw-disk-image.xz" => {"application/x-raw-disk-image-xz-compressed"}, "rax" => {"audio/vnd.m-realaudio", "audio/vnd.rn-realaudio", "audio/x-pn-realaudio"}, "rb" => {"application/x-ruby"}, "rcprofile" => {"application/vnd.ipunplugged.rcprofile"}, "rdf" => {"application/rdf+xml", "text/rdf"}, "rdfs" => {"application/rdf+xml", "text/rdf"}, "rdz" => {"application/vnd.data-vision.rdz"}, "reg" => {"text/x-ms-regedit"}, "rej" => {"application/x-reject", "text/x-reject"}, "relo" => {"application/p2p-overlay+xml"}, "rep" => {"application/vnd.businessobjects"}, "res" => {"application/x-dtbresource+xml", "application/x-godot-resource"}, "rgb" => {"image/x-rgb"}, "rgbe" => {"image/vnd.radiance"}, "rif" => {"application/reginfo+xml"}, "rip" => {"audio/vnd.rip"}, "ris" => {"application/x-research-info-systems"}, "rl" => {"application/resource-lists+xml"}, "rlc" => {"image/vnd.fujixerox.edmics-rlc"}, "rld" => {"application/resource-lists-diff+xml"}, "rle" => {"image/rle"}, "rm" => {"application/vnd.rn-realmedia", "application/vnd.rn-realmedia-vbr"}, "rmi" => {"audio/midi"}, "rmj" => {"application/vnd.rn-realmedia", "application/vnd.rn-realmedia-vbr"}, "rmm" => {"application/vnd.rn-realmedia", "application/vnd.rn-realmedia-vbr"}, "rmp" => {"audio/x-pn-realaudio-plugin"}, "rms" => {"application/vnd.jcp.javame.midlet-rms", "application/vnd.rn-realmedia", "application/vnd.rn-realmedia-vbr"}, "rmvb" => {"application/vnd.rn-realmedia", "application/vnd.rn-realmedia-vbr"}, "rmx" => {"application/vnd.rn-realmedia", "application/vnd.rn-realmedia-vbr"}, "rnc" => {"application/relax-ng-compact-syntax", "application/x-rnc"}, "rng" => {"application/xml", "text/xml"}, "roa" => {"application/rpki-roa"}, "roff" => {"application/x-troff", "text/troff", "text/x-troff"}, "ros" => {"text/x-common-lisp"}, "rp" => {"image/vnd.rn-realpix"}, "rp9" => {"application/vnd.cloanto.rp9"}, "rpm" => {"application/x-redhat-package-manager", "application/x-rpm"}, "rpss" => {"application/vnd.nokia.radio-presets"}, "rpst" => {"application/vnd.nokia.radio-preset"}, "rq" => {"application/sparql-query"}, "rs" => {"application/rls-services+xml", "text/rust"}, "rsat" => {"application/atsc-rsat+xml"}, "rsd" => {"application/rsd+xml"}, "rsheet" => {"application/urc-ressheet+xml"}, "rss" => {"application/rss+xml", "text/rss"}, "rst" => {"text/x-rst"}, "rt" => {"text/vnd.rn-realtext"}, "rtf" => {"application/rtf", "text/rtf"}, "rtx" => {"text/richtext"}, "run" => {"application/x-makeself"}, "rusd" => {"application/route-usd+xml"}, "rv" => {"video/vnd.rn-realvideo", "video/x-real-video"}, "rvx" => {"video/vnd.rn-realvideo", "video/x-real-video"}, "rw2" => {"image/x-panasonic-raw2", "image/x-panasonic-rw2"}, "rz" => {"application/x-rzip"}, "s" => {"text/x-asm"}, "s3m" => {"audio/s3m", "audio/x-s3m"}, "saf" => {"application/vnd.yamaha.smaf-audio"}, "sage" => {"text/x-sagemath"}, "sam" => {"application/x-amipro"}, "sami" => {"application/x-sami"}, "sap" => {"application/x-sap-file", "application/x-thomson-sap-image"}, "sass" => {"text/x-sass"}, "sav" => {"application/x-spss-sav", "application/x-spss-savefile"}, "sbml" => {"application/sbml+xml"}, "sc" => {"application/vnd.ibm.secure-container", "text/x-scala"}, "scala" => {"text/x-scala"}, "scap" => {"application/x-pcapng"}, "scd" => {"application/x-msschedule"}, "scm" => {"application/vnd.lotus-screencam", "text/x-scheme"}, "scn" => {"application/x-godot-scene"}, "scope" => {"text/x-systemd-unit"}, "scq" => {"application/scvp-cv-request"}, "scr" => {"application/vnd.microsoft.portable-executable", "application/x-ms-dos-executable", "application/x-ms-ne-executable", "application/x-msdownload"}, "scs" => {"application/scvp-cv-response"}, "scss" => {"text/x-scss"}, "sct" => {"image/x-sct"}, "scurl" => {"text/vnd.curl.scurl"}, "sda" => {"application/vnd.stardivision.draw", "application/x-stardraw"}, "sdc" => {"application/vnd.stardivision.calc", "application/x-starcalc"}, "sdd" => {"application/vnd.stardivision.impress", "application/x-starimpress"}, "sdkd" => {"application/vnd.solent.sdkm+xml"}, "sdkm" => {"application/vnd.solent.sdkm+xml"}, "sdm" => {"application/vnd.stardivision.mail"}, "sdp" => {"application/sdp", "application/vnd.sdp", "application/vnd.stardivision.impress-packed", "application/x-sdp"}, "sds" => {"application/vnd.stardivision.chart", "application/x-starchart"}, "sdw" => {"application/vnd.stardivision.writer", "application/x-starwriter"}, "sea" => {"application/x-sea"}, "see" => {"application/vnd.seemail"}, "seed" => {"application/vnd.fdsn.seed"}, "sema" => {"application/vnd.sema"}, "semd" => {"application/vnd.semd"}, "semf" => {"application/vnd.semf"}, "senmlx" => {"application/senml+xml"}, "sensmlx" => {"application/sensml+xml"}, "ser" => {"application/java-serialized-object"}, "service" => {"text/x-dbus-service", "text/x-systemd-unit"}, "setpay" => {"application/set-payment-initiation"}, "setreg" => {"application/set-registration-initiation"}, "sfc" => {"application/vnd.nintendo.snes.rom", "application/x-snes-rom"}, "sfd-hdstx" => {"application/vnd.hydrostatix.sof-data"}, "sfs" => {"application/vnd.spotfire.sfs", "application/vnd.squashfs"}, "sfv" => {"text/x-sfv"}, "sg" => {"application/x-sg1000-rom"}, "sgb" => {"application/x-gameboy-rom"}, "sgd" => {"application/x-genesis-rom"}, "sgf" => {"application/x-go-sgf"}, "sgi" => {"image/sgi", "image/x-sgi"}, "sgl" => {"application/vnd.stardivision.writer-global", "application/x-starwriter-global"}, "sgm" => {"text/sgml"}, "sgml" => {"text/sgml"}, "sh" => {"application/x-sh", "application/x-shellscript", "text/x-sh"}, "shape" => {"application/x-dia-shape"}, "shar" => {"application/x-shar"}, "shex" => {"text/shex"}, "shf" => {"application/shf+xml"}, "shn" => {"application/x-shorten", "audio/x-shorten"}, "shtml" => {"text/html"}, "siag" => {"application/x-siag"}, "sid" => {"audio/prs.sid", "image/x-mrsid-image"}, "sieve" => {"application/sieve"}, "sig" => {"application/pgp-signature"}, "sik" => {"application/x-trash"}, "sil" => {"audio/silk"}, "silo" => {"model/mesh"}, "sis" => {"application/vnd.symbian.install"}, "sisx" => {"application/vnd.symbian.install", "x-epoc/x-sisx-app"}, "sit" => {"application/x-stuffit", "application/stuffit", "application/x-sit"}, "sitx" => {"application/x-sitx", "application/x-stuffitx"}, "siv" => {"application/sieve"}, "sk" => {"image/x-skencil"}, "sk1" => {"image/x-skencil"}, "skd" => {"application/vnd.koan"}, "skm" => {"application/vnd.koan"}, "skp" => {"application/vnd.koan"}, "skr" => {"application/pgp-keys"}, "skt" => {"application/vnd.koan"}, "sldm" => {"application/vnd.ms-powerpoint.slide.macroenabled.12"}, "sldx" => {"application/vnd.openxmlformats-officedocument.presentationml.slide"}, "slice" => {"text/x-systemd-unit"}, "slim" => {"text/slim"}, "slk" => {"application/x-sylk", "text/spreadsheet"}, "slm" => {"text/slim"}, "sls" => {"application/route-s-tsid+xml"}, "slt" => {"application/vnd.epson.salt"}, "sm" => {"application/vnd.stepmania.stepchart"}, "smaf" => {"application/vnd.smaf", "application/x-smaf"}, "smc" => {"application/vnd.nintendo.snes.rom", "application/x-snes-rom"}, "smd" => {"application/x-genesis-rom", "application/x-starmail"}, "smf" => {"application/vnd.stardivision.math", "application/x-starmath"}, "smi" => {"application/smil", "application/smil+xml", "application/x-sami"}, "smil" => {"application/smil", "application/smil+xml"}, "smk" => {"video/vnd.radgamettools.smacker"}, "sml" => {"application/smil", "application/smil+xml"}, "sms" => {"application/x-sms-rom"}, "smv" => {"video/x-smv"}, "smzip" => {"application/vnd.stepmania.package"}, "snap" => {"application/vnd.snap"}, "snd" => {"audio/basic"}, "snf" => {"application/x-font-snf"}, "so" => {"application/x-sharedlib"}, "so.[0-9]*" => {"application/x-sharedlib"}, "socket" => {"text/x-systemd-unit"}, "spc" => {"application/x-pkcs7-certificates"}, "spd" => {"application/x-font-speedo"}, "spdx" => {"text/spdx"}, "spec" => {"text/x-rpm-spec"}, "spf" => {"application/vnd.yamaha.smaf-phrase"}, "spl" => {"application/futuresplash", "application/vnd.adobe.flash.movie", "application/x-futuresplash", "application/x-shockwave-flash"}, "spm" => {"application/x-source-rpm"}, "spot" => {"text/vnd.in3d.spot"}, "spp" => {"application/scvp-vp-response"}, "spq" => {"application/scvp-vp-request"}, "spx" => {"application/x-apple-systemprofiler+xml", "audio/ogg", "audio/x-speex", "audio/x-speex+ogg"}, "sqfs" => {"application/vnd.squashfs"}, "sql" => {"application/sql", "application/x-sql", "text/x-sql"}, "sqlite2" => {"application/x-sqlite2"}, "sqlite3" => {"application/vnd.sqlite3", "application/x-sqlite3"}, "sqsh" => {"application/vnd.squashfs"}, "squashfs" => {"application/vnd.squashfs"}, "sr2" => {"image/x-sony-sr2"}, "src" => {"application/x-wais-source"}, "src.rpm" => {"application/x-source-rpm"}, "srf" => {"image/x-sony-srf"}, "srt" => {"application/x-srt", "application/x-subrip"}, "sru" => {"application/sru+xml"}, "srx" => {"application/sparql-results+xml"}, "ss" => {"text/x-scheme"}, "ssa" => {"text/x-ssa"}, "ssdl" => {"application/ssdl+xml"}, "sse" => {"application/vnd.kodak-descriptor"}, "ssf" => {"application/vnd.epson.ssf"}, "ssml" => {"application/ssml+xml"}, "st" => {"application/vnd.sailingtracker.track"}, "stc" => {"application/vnd.sun.xml.calc.template"}, "std" => {"application/vnd.sun.xml.draw.template"}, "step" => {"model/step"}, "stf" => {"application/vnd.wt.stf"}, "sti" => {"application/vnd.sun.xml.impress.template"}, "stk" => {"application/hyperstudio"}, "stl" => {"application/vnd.ms-pki.stl", "model/stl", "model/x.stl-ascii", "model/x.stl-binary"}, "stm" => {"audio/x-stm"}, "stp" => {"model/step"}, "stpx" => {"model/step+xml"}, "stpxz" => {"model/step-xml+zip"}, "stpz" => {"model/step+zip"}, "str" => {"application/vnd.pg.format"}, "stw" => {"application/vnd.sun.xml.writer.template"}, "sty" => {"application/x-tex", "text/x-tex"}, "styl" => {"text/stylus"}, "stylus" => {"text/stylus"}, "sub" => {"image/vnd.dvb.subtitle", "text/vnd.dvb.subtitle", "text/x-microdvd", "text/x-mpsub", "text/x-subviewer"}, "sun" => {"image/x-sun-raster"}, "sus" => {"application/vnd.sus-calendar"}, "susp" => {"application/vnd.sus-calendar"}, "sv" => {"text/x-svsrc"}, "sv4cpio" => {"application/x-sv4cpio"}, "sv4crc" => {"application/x-sv4crc"}, "svc" => {"application/vnd.dvb.service"}, "svd" => {"application/vnd.svd"}, "svg" => {"image/svg+xml", "image/svg"}, "svg.gz" => {"image/svg+xml-compressed"}, "svgz" => {"image/svg+xml", "image/svg+xml-compressed"}, "svh" => {"text/x-svhdr"}, "swa" => {"application/x-director"}, "swap" => {"text/x-systemd-unit"}, "swf" => {"application/futuresplash", "application/vnd.adobe.flash.movie", "application/x-shockwave-flash"}, "swi" => {"application/vnd.aristanetworks.swi"}, "swidtag" => {"application/swid+xml"}, "swm" => {"application/x-ms-wim"}, "sxc" => {"application/vnd.sun.xml.calc"}, "sxd" => {"application/vnd.sun.xml.draw"}, "sxg" => {"application/vnd.sun.xml.writer.global"}, "sxi" => {"application/vnd.sun.xml.impress"}, "sxm" => {"application/vnd.sun.xml.math"}, "sxw" => {"application/vnd.sun.xml.writer"}, "sylk" => {"application/x-sylk", "text/spreadsheet"}, "sys" => {"application/vnd.microsoft.portable-executable"}, "t" => {"application/x-perl", "application/x-troff", "text/troff", "text/x-perl", "text/x-troff"}, "t2t" => {"text/x-txt2tags"}, "t3" => {"application/x-t3vm-image"}, "t38" => {"image/t38"}, "taglet" => {"application/vnd.mynfc"}, "tak" => {"audio/x-tak"}, "tao" => {"application/vnd.tao.intent-module-archive"}, "tap" => {"image/vnd.tencent.tap"}, "tar" => {"application/x-tar", "application/x-gtar"}, "tar.Z" => {"application/x-tarz"}, "tar.bz" => {"application/x-bzip1-compressed-tar"}, "tar.bz2" => {"application/x-bzip-compressed-tar", "application/x-bzip2-compressed-tar"}, "tar.bz3" => {"application/x-bzip3-compressed-tar"}, "tar.gz" => {"application/x-compressed-tar"}, "tar.lrz" => {"application/x-lrzip-compressed-tar"}, "tar.lz" => {"application/x-lzip-compressed-tar"}, "tar.lz4" => {"application/x-lz4-compressed-tar"}, "tar.lzma" => {"application/x-lzma-compressed-tar"}, "tar.lzo" => {"application/x-tzo"}, "tar.rz" => {"application/x-rzip-compressed-tar"}, "tar.xz" => {"application/x-xz-compressed-tar"}, "tar.zst" => {"application/x-zstd-compressed-tar"}, "target" => {"text/x-systemd-unit"}, "taz" => {"application/x-tarz"}, "tb2" => {"application/x-bzip-compressed-tar", "application/x-bzip2-compressed-tar"}, "tbz" => {"application/x-bzip1-compressed-tar"}, "tbz2" => {"application/x-bzip-compressed-tar", "application/x-bzip2-compressed-tar"}, "tbz3" => {"application/x-bzip3-compressed-tar"}, "tcap" => {"application/vnd.3gpp2.tcap"}, "tcl" => {"application/x-tcl", "text/tcl", "text/x-tcl"}, "td" => {"application/urc-targetdesc+xml"}, "teacher" => {"application/vnd.smart.teacher"}, "tei" => {"application/tei+xml"}, "teicorpus" => {"application/tei+xml"}, "tex" => {"application/x-tex", "text/x-tex"}, "texi" => {"application/x-texinfo", "text/x-texinfo"}, "texinfo" => {"application/x-texinfo", "text/x-texinfo"}, "text" => {"text/plain"}, "tfi" => {"application/thraud+xml"}, "tfm" => {"application/x-tex-tfm"}, "tfx" => {"image/tiff-fx"}, "tga" => {"application/tga", "application/x-targa", "application/x-tga", "image/targa", "image/tga", "image/x-icb", "image/x-targa", "image/x-tga"}, "tgz" => {"application/x-compressed-tar"}, "theme" => {"application/x-theme"}, "themepack" => {"application/x-windows-themepack"}, "thmx" => {"application/vnd.ms-officetheme"}, "tif" => {"image/tiff"}, "tiff" => {"image/tiff"}, "timer" => {"text/x-systemd-unit"}, "tk" => {"application/x-tcl", "text/tcl", "text/x-tcl"}, "tlrz" => {"application/x-lrzip-compressed-tar"}, "tlz" => {"application/x-lzma-compressed-tar"}, "tmo" => {"application/vnd.tmobile-livetv"}, "tmx" => {"application/x-tiled-tmx"}, "tnef" => {"application/ms-tnef", "application/vnd.ms-tnef"}, "tnf" => {"application/ms-tnef", "application/vnd.ms-tnef"}, "toc" => {"application/x-cdrdao-toc"}, "toml" => {"application/toml"}, "torrent" => {"application/x-bittorrent"}, "tpic" => {"application/tga", "application/x-targa", "application/x-tga", "image/targa", "image/tga", "image/x-icb", "image/x-targa", "image/x-tga"}, "tpl" => {"application/vnd.groove-tool-template"}, "tpt" => {"application/vnd.trid.tpt"}, "tr" => {"application/x-troff", "text/troff", "text/x-troff"}, "tra" => {"application/vnd.trueapp"}, "tres" => {"application/x-godot-resource"}, "trig" => {"application/trig", "application/x-trig"}, "trm" => {"application/x-msterminal"}, "trz" => {"application/x-rzip-compressed-tar"}, "ts" => {"application/typescript", "application/x-linguist", "text/vnd.qt.linguist", "text/vnd.trolltech.linguist", "video/mp2t"}, "tscn" => {"application/x-godot-scene"}, "tsd" => {"application/timestamped-data"}, "tsv" => {"text/tab-separated-values"}, "tsx" => {"application/x-tiled-tsx"}, "tta" => {"audio/tta", "audio/x-tta"}, "ttc" => {"font/collection"}, "ttf" => {"application/x-font-truetype", "application/x-font-ttf", "font/ttf"}, "ttl" => {"text/turtle"}, "ttml" => {"application/ttml+xml"}, "ttx" => {"application/x-font-ttx"}, "twd" => {"application/vnd.simtech-mindmapper"}, "twds" => {"application/vnd.simtech-mindmapper"}, "twig" => {"text/x-twig"}, "txd" => {"application/vnd.genomatix.tuxedo"}, "txf" => {"application/vnd.mobius.txf"}, "txt" => {"text/plain"}, "txz" => {"application/x-xz-compressed-tar"}, "typ" => {"text/vnd.typst", "text/x-typst"}, "tzo" => {"application/x-tzo"}, "tzst" => {"application/x-zstd-compressed-tar"}, "u32" => {"application/x-authorware-bin"}, "u3d" => {"model/u3d"}, "u8dsn" => {"message/global-delivery-status"}, "u8hdr" => {"message/global-headers"}, "u8mdn" => {"message/global-disposition-notification"}, "u8msg" => {"message/global"}, "ubj" => {"application/ubjson"}, "udeb" => {"application/vnd.debian.binary-package", "application/x-deb", "application/x-debian-package"}, "ufd" => {"application/vnd.ufdl"}, "ufdl" => {"application/vnd.ufdl"}, "ufraw" => {"application/x-ufraw"}, "ui" => {"application/x-designer", "application/x-gtk-builder"}, "uil" => {"text/x-uil"}, "ult" => {"audio/x-mod"}, "ulx" => {"application/x-glulx"}, "umj" => {"application/vnd.umajin"}, "unf" => {"application/x-nes-rom"}, "uni" => {"audio/x-mod"}, "unif" => {"application/x-nes-rom"}, "unityweb" => {"application/vnd.unity"}, "uo" => {"application/vnd.uoml+xml"}, "uoml" => {"application/vnd.uoml+xml"}, "uri" => {"text/uri-list"}, "uris" => {"text/uri-list"}, "url" => {"application/x-mswinurl"}, "urls" => {"text/uri-list"}, "usda" => {"model/vnd.usda"}, "usdz" => {"model/vnd.usdz+zip"}, "ustar" => {"application/x-ustar"}, "utz" => {"application/vnd.uiq.theme"}, "uu" => {"text/x-uuencode"}, "uue" => {"text/x-uuencode", "zz-application/zz-winassoc-uu"}, "uva" => {"audio/vnd.dece.audio"}, "uvd" => {"application/vnd.dece.data"}, "uvf" => {"application/vnd.dece.data"}, "uvg" => {"image/vnd.dece.graphic"}, "uvh" => {"video/vnd.dece.hd"}, "uvi" => {"image/vnd.dece.graphic"}, "uvm" => {"video/vnd.dece.mobile"}, "uvp" => {"video/vnd.dece.pd"}, "uvs" => {"video/vnd.dece.sd"}, "uvt" => {"application/vnd.dece.ttml+xml"}, "uvu" => {"video/vnd.uvvu.mp4"}, "uvv" => {"video/vnd.dece.video"}, "uvva" => {"audio/vnd.dece.audio"}, "uvvd" => {"application/vnd.dece.data"}, "uvvf" => {"application/vnd.dece.data"}, "uvvg" => {"image/vnd.dece.graphic"}, "uvvh" => {"video/vnd.dece.hd"}, "uvvi" => {"image/vnd.dece.graphic"}, "uvvm" => {"video/vnd.dece.mobile"}, "uvvp" => {"video/vnd.dece.pd"}, "uvvs" => {"video/vnd.dece.sd"}, "uvvt" => {"application/vnd.dece.ttml+xml"}, "uvvu" => {"video/vnd.uvvu.mp4"}, "uvvv" => {"video/vnd.dece.video"}, "uvvx" => {"application/vnd.dece.unspecified"}, "uvvz" => {"application/vnd.dece.zip"}, "uvx" => {"application/vnd.dece.unspecified"}, "uvz" => {"application/vnd.dece.zip"}, "v" => {"text/x-verilog"}, "v64" => {"application/x-n64-rom"}, "vala" => {"text/x-vala"}, "vapi" => {"text/x-vala"}, "vb" => {"application/x-virtual-boy-rom", "text/x-vb"}, "vbe" => {"text/vbscript.encode"}, "vbox" => {"application/x-virtualbox-vbox"}, "vbox-extpack" => {"application/x-virtualbox-vbox-extpack"}, "vbs" => {"text/vbs", "text/vbscript"}, "vcard" => {"text/directory", "text/vcard", "text/x-vcard"}, "vcd" => {"application/x-cdlink"}, "vcf" => {"text/x-vcard", "text/directory", "text/vcard"}, "vcg" => {"application/vnd.groove-vcard"}, "vcs" => {"application/ics", "text/calendar", "text/x-vcalendar"}, "vct" => {"text/directory", "text/vcard", "text/x-vcard"}, "vcx" => {"application/vnd.vcx"}, "vda" => {"application/tga", "application/x-targa", "application/x-tga", "image/targa", "image/tga", "image/x-icb", "image/x-targa", "image/x-tga"}, "vdi" => {"application/x-vdi-disk", "application/x-virtualbox-vdi"}, "vds" => {"model/vnd.sap.vds"}, "vhd" => {"application/x-vhd-disk", "application/x-virtualbox-vhd", "text/x-vhdl"}, "vhdl" => {"text/x-vhdl"}, "vhdx" => {"application/x-vhdx-disk", "application/x-virtualbox-vhdx"}, "vis" => {"application/vnd.visionary"}, "viv" => {"video/vivo", "video/vnd.vivo"}, "vivo" => {"video/vivo", "video/vnd.vivo"}, "vlc" => {"application/m3u", "audio/m3u", "audio/mpegurl", "audio/x-m3u", "audio/x-mp3-playlist", "audio/x-mpegurl"}, "vmdk" => {"application/x-virtualbox-vmdk", "application/x-vmdk-disk"}, "vob" => {"video/mpeg", "video/mpeg-system", "video/x-mpeg", "video/x-mpeg-system", "video/x-mpeg2", "video/x-ms-vob"}, "voc" => {"audio/x-voc"}, "vor" => {"application/vnd.stardivision.writer", "application/x-starwriter"}, "vox" => {"application/x-authorware-bin"}, "vpc" => {"application/x-vhd-disk", "application/x-virtualbox-vhd"}, "vrm" => {"model/vrml"}, "vrml" => {"model/vrml"}, "vsd" => {"application/vnd.visio"}, "vsdm" => {"application/vnd.ms-visio.drawing.macroenabled.main+xml"}, "vsdx" => {"application/vnd.ms-visio.drawing.main+xml"}, "vsf" => {"application/vnd.vsf"}, "vss" => {"application/vnd.visio"}, "vssm" => {"application/vnd.ms-visio.stencil.macroenabled.main+xml"}, "vssx" => {"application/vnd.ms-visio.stencil.main+xml"}, "vst" => {"application/tga", "application/vnd.visio", "application/x-targa", "application/x-tga", "image/targa", "image/tga", "image/x-icb", "image/x-targa", "image/x-tga"}, "vstm" => {"application/vnd.ms-visio.template.macroenabled.main+xml"}, "vstx" => {"application/vnd.ms-visio.template.main+xml"}, "vsw" => {"application/vnd.visio"}, "vtf" => {"image/vnd.valve.source.texture"}, "vtt" => {"text/vtt"}, "vtu" => {"model/vnd.vtu"}, "vxml" => {"application/voicexml+xml"}, "w3d" => {"application/x-director"}, "wad" => {"application/x-doom", "application/x-doom-wad", "application/x-wii-wad"}, "wadl" => {"application/vnd.sun.wadl+xml"}, "war" => {"application/java-archive"}, "wasm" => {"application/wasm"}, "wav" => {"audio/wav", "audio/vnd.wave", "audio/wave", "audio/x-wav"}, "wax" => {"application/x-ms-asx", "audio/x-ms-asx", "audio/x-ms-wax", "video/x-ms-wax", "video/x-ms-wmx", "video/x-ms-wvx"}, "wb1" => {"application/x-quattropro"}, "wb2" => {"application/x-quattropro"}, "wb3" => {"application/x-quattropro"}, "wbmp" => {"image/vnd.wap.wbmp"}, "wbs" => {"application/vnd.criticaltools.wbs+xml"}, "wbxml" => {"application/vnd.wap.wbxml"}, "wcm" => {"application/vnd.ms-works"}, "wdb" => {"application/vnd.ms-works"}, "wdp" => {"image/jxr", "image/vnd.ms-photo"}, "weba" => {"audio/webm"}, "webapp" => {"application/x-web-app-manifest+json"}, "webm" => {"video/webm"}, "webmanifest" => {"application/manifest+json"}, "webp" => {"image/webp"}, "wg" => {"application/vnd.pmi.widget"}, "wgsl" => {"text/wgsl"}, "wgt" => {"application/widget"}, "wif" => {"application/watcherinfo+xml"}, "wim" => {"application/x-ms-wim"}, "wk1" => {"application/lotus123", "application/vnd.lotus-1-2-3", "application/wk1", "application/x-123", "application/x-lotus123", "zz-application/zz-winassoc-123"}, "wk3" => {"application/lotus123", "application/vnd.lotus-1-2-3", "application/wk1", "application/x-123", "application/x-lotus123", "zz-application/zz-winassoc-123"}, "wk4" => {"application/lotus123", "application/vnd.lotus-1-2-3", "application/wk1", "application/x-123", "application/x-lotus123", "zz-application/zz-winassoc-123"}, "wkdownload" => {"application/x-partial-download"}, "wks" => {"application/lotus123", "application/vnd.lotus-1-2-3", "application/vnd.ms-works", "application/wk1", "application/x-123", "application/x-lotus123", "zz-application/zz-winassoc-123"}, "wm" => {"video/x-ms-wm"}, "wma" => {"audio/x-ms-wma", "audio/wma"}, "wmd" => {"application/x-ms-wmd"}, "wmf" => {"application/wmf", "application/x-msmetafile", "application/x-wmf", "image/wmf", "image/x-win-metafile", "image/x-wmf"}, "wml" => {"text/vnd.wap.wml"}, "wmlc" => {"application/vnd.wap.wmlc"}, "wmls" => {"text/vnd.wap.wmlscript"}, "wmlsc" => {"application/vnd.wap.wmlscriptc"}, "wmv" => {"audio/x-ms-wmv", "video/x-ms-wmv"}, "wmx" => {"application/x-ms-asx", "audio/x-ms-asx", "video/x-ms-wax", "video/x-ms-wmx", "video/x-ms-wvx"}, "wmz" => {"application/x-ms-wmz", "application/x-msmetafile"}, "woff" => {"application/font-woff", "application/x-font-woff", "font/woff"}, "woff2" => {"font/woff2"}, "wp" => {"application/vnd.wordperfect", "application/wordperfect", "application/x-wordperfect"}, "wp4" => {"application/vnd.wordperfect", "application/wordperfect", "application/x-wordperfect"}, "wp5" => {"application/vnd.wordperfect", "application/wordperfect", "application/x-wordperfect"}, "wp6" => {"application/vnd.wordperfect", "application/wordperfect", "application/x-wordperfect"}, "wpd" => {"application/vnd.wordperfect", "application/wordperfect", "application/x-wordperfect"}, "wpg" => {"application/x-wpg"}, "wpl" => {"application/vnd.ms-wpl"}, "wpp" => {"application/vnd.wordperfect", "application/wordperfect", "application/x-wordperfect"}, "wps" => {"application/vnd.ms-works"}, "wqd" => {"application/vnd.wqd"}, "wri" => {"application/x-mswrite"}, "wrl" => {"model/vrml"}, "ws" => {"application/x-wonderswan-rom"}, "wsc" => {"application/x-wonderswan-color-rom", "message/vnd.wfa.wsc"}, "wsdl" => {"application/wsdl+xml"}, "wsgi" => {"text/x-python"}, "wspolicy" => {"application/wspolicy+xml"}, "wtb" => {"application/vnd.webturbo"}, "wv" => {"audio/x-wavpack"}, "wvc" => {"audio/x-wavpack-correction"}, "wvp" => {"audio/x-wavpack"}, "wvx" => {"application/x-ms-asx", "audio/x-ms-asx", "video/x-ms-wax", "video/x-ms-wmx", "video/x-ms-wvx"}, "wwf" => {"application/wwf", "application/x-wwf"}, "x32" => {"application/x-authorware-bin"}, "x3d" => {"model/x3d+xml"}, "x3db" => {"model/x3d+binary", "model/x3d+fastinfoset"}, "x3dbz" => {"model/x3d+binary"}, "x3dv" => {"model/x3d+vrml", "model/x3d-vrml"}, "x3dvz" => {"model/x3d+vrml"}, "x3dz" => {"model/x3d+xml"}, "x3f" => {"image/x-sigma-x3f"}, "x_b" => {"model/vnd.parasolid.transmit.binary"}, "x_t" => {"model/vnd.parasolid.transmit.text"}, "xac" => {"application/x-gnucash"}, "xaml" => {"application/xaml+xml"}, "xap" => {"application/x-silverlight-app"}, "xar" => {"application/vnd.xara", "application/x-xar"}, "xav" => {"application/xcap-att+xml"}, "xbap" => {"application/x-ms-xbap"}, "xbd" => {"application/vnd.fujixerox.docuworks.binder"}, "xbel" => {"application/x-xbel"}, "xbl" => {"application/xml", "text/xml"}, "xbm" => {"image/x-xbitmap"}, "xca" => {"application/xcap-caps+xml"}, "xcf" => {"image/x-xcf"}, "xcf.bz2" => {"image/x-compressed-xcf"}, "xcf.gz" => {"image/x-compressed-xcf"}, "xci" => {"application/x-nintendo-switch-xci", "application/x-nx-xci"}, "xcs" => {"application/calendar+xml"}, "xdcf" => {"application/vnd.gov.sk.xmldatacontainer+xml"}, "xdf" => {"application/mrb-consumer+xml", "application/mrb-publish+xml", "application/xcap-diff+xml"}, "xdgapp" => {"application/vnd.flatpak", "application/vnd.xdgapp"}, "xdm" => {"application/vnd.syncml.dm+xml"}, "xdp" => {"application/vnd.adobe.xdp+xml"}, "xdssc" => {"application/dssc+xml"}, "xdw" => {"application/vnd.fujixerox.docuworks"}, "xel" => {"application/xcap-el+xml"}, "xenc" => {"application/xenc+xml"}, "xer" => {"application/patch-ops-error+xml", "application/xcap-error+xml"}, "xfdf" => {"application/vnd.adobe.xfdf", "application/xfdf"}, "xfdl" => {"application/vnd.xfdl"}, "xhe" => {"audio/usac"}, "xht" => {"application/xhtml+xml"}, "xhtm" => {"application/vnd.pwg-xhtml-print+xml"}, "xhtml" => {"application/xhtml+xml"}, "xhvml" => {"application/xv+xml"}, "xi" => {"audio/x-xi"}, "xif" => {"image/vnd.xiff"}, "xla" => {"application/msexcel", "application/vnd.ms-excel", "application/x-msexcel", "zz-application/zz-winassoc-xls"}, "xlam" => {"application/vnd.ms-excel.addin.macroenabled.12"}, "xlc" => {"application/msexcel", "application/vnd.ms-excel", "application/x-msexcel", "zz-application/zz-winassoc-xls"}, "xld" => {"application/msexcel", "application/vnd.ms-excel", "application/x-msexcel", "zz-application/zz-winassoc-xls"}, "xlf" => {"application/x-xliff", "application/x-xliff+xml", "application/xliff+xml"}, "xliff" => {"application/x-xliff", "application/xliff+xml"}, "xll" => {"application/msexcel", "application/vnd.ms-excel", "application/x-msexcel", "zz-application/zz-winassoc-xls"}, "xlm" => {"application/msexcel", "application/vnd.ms-excel", "application/x-msexcel", "zz-application/zz-winassoc-xls"}, "xlr" => {"application/vnd.ms-works"}, "xls" => {"application/vnd.ms-excel", "application/msexcel", "application/x-msexcel", "zz-application/zz-winassoc-xls"}, "xlsb" => {"application/vnd.ms-excel.sheet.binary.macroenabled.12"}, "xlsm" => {"application/vnd.ms-excel.sheet.macroenabled.12"}, "xlsx" => {"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, "xlt" => {"application/msexcel", "application/vnd.ms-excel", "application/x-msexcel", "zz-application/zz-winassoc-xls"}, "xltm" => {"application/vnd.ms-excel.template.macroenabled.12"}, "xltx" => {"application/vnd.openxmlformats-officedocument.spreadsheetml.template"}, "xlw" => {"application/msexcel", "application/vnd.ms-excel", "application/x-msexcel", "zz-application/zz-winassoc-xls"}, "xm" => {"audio/x-xm", "audio/xm"}, "xmf" => {"audio/x-xmf", "audio/xmf"}, "xmi" => {"text/x-xmi"}, "xml" => {"application/xml", "text/xml"}, "xns" => {"application/xcap-ns+xml"}, "xo" => {"application/vnd.olpc-sugar"}, "xop" => {"application/xop+xml"}, "xpi" => {"application/x-xpinstall"}, "xpl" => {"application/xproc+xml"}, "xpm" => {"image/x-xpixmap", "image/x-xpm"}, "xpr" => {"application/vnd.is-xpr"}, "xps" => {"application/vnd.ms-xpsdocument", "application/xps"}, "xpw" => {"application/vnd.intercon.formnet"}, "xpx" => {"application/vnd.intercon.formnet"}, "xsd" => {"application/xml", "text/xml"}, "xsf" => {"application/prs.xsf+xml"}, "xsl" => {"application/xml", "application/xslt+xml"}, "xslfo" => {"text/x-xslfo"}, "xslt" => {"application/xslt+xml"}, "xsm" => {"application/vnd.syncml+xml"}, "xspf" => {"application/x-xspf+xml", "application/xspf+xml"}, "xul" => {"application/vnd.mozilla.xul+xml"}, "xvm" => {"application/xv+xml"}, "xvml" => {"application/xv+xml"}, "xwd" => {"image/x-xwindowdump"}, "xyz" => {"chemical/x-xyz"}, "xyze" => {"image/vnd.radiance"}, "xz" => {"application/x-xz"}, "yaml" => {"application/yaml", "application/x-yaml", "text/x-yaml", "text/yaml"}, "yang" => {"application/yang"}, "yin" => {"application/yin+xml"}, "yml" => {"application/yaml", "application/x-yaml", "text/x-yaml", "text/yaml"}, "ymp" => {"text/x-suse-ymp"}, "yt" => {"application/vnd.youtube.yt", "video/vnd.youtube.yt"}, "z1" => {"application/x-zmachine"}, "z2" => {"application/x-zmachine"}, "z3" => {"application/x-zmachine"}, "z4" => {"application/x-zmachine"}, "z5" => {"application/x-zmachine"}, "z6" => {"application/x-zmachine"}, "z64" => {"application/x-n64-rom"}, "z7" => {"application/x-zmachine"}, "z8" => {"application/x-zmachine"}, "zabw" => {"application/x-abiword"}, "zaz" => {"application/vnd.zzazz.deck+xml"}, "zim" => {"application/x-openzim"}, "zip" => {"application/zip", "application/x-zip", "application/x-zip-compressed"}, "zipx" => {"application/x-zip", "application/x-zip-compressed", "application/zip"}, "zir" => {"application/vnd.zul"}, "zirz" => {"application/vnd.zul"}, "zmm" => {"application/vnd.handheld-entertainment+xml"}, "zoo" => {"application/x-zoo"}, "zpaq" => {"application/x-zpaq"}, "zsav" => {"application/x-spss-sav", "application/x-spss-savefile"}, "zst" => {"application/zstd"}, "zz" => {"application/zlib"}, } end ================================================ FILE: src/components/mime/src/types.cr ================================================ require "./types/data" require "./types_interface" # Default implementation of `AMIME::TypesInterface`. # # Also supports guessing a MIME type based on a given file path. # Custom guessers can be registered via the `#register_guesser` method. # Custom guessers are always called before any default ones. # # ``` # mime_types = AMIME::Types.new # # mime_types.mime_types "png" # => {"image/png", "image/apng", "image/vnd.mozilla.apng"} # mime_types.extensions "image/png" # => {"png"} # mime_types.guess_mime_type "/path/to/image.png" # => "image/png" # ``` class Athena::MIME::Types include Athena::MIME::TypesInterface # :nodoc: # # Key: MIME Type, Value: Array of extensions alias Map = Hash(String, Array(String)) # Returns/sets the default singleton instance. class_property default : self { new } @extensions = Map.new { |hash, key| hash[key] = [] of String } @mime_types = Map.new { |hash, key| hash[key] = [] of String } @guessers : Array(AMIME::TypesGuesserInterface) = [] of AMIME::TypesGuesserInterface def initialize(map : Hash(String, Enumerable(String)) = Map.new) map.each do |mime_type, extensions| @extensions[mime_type] = extensions.to_a extensions.each do |ext| @mime_types[ext] << mime_type end end self.register_guesser AMIME::NativeTypesGuesser.new self.register_guesser AMIME::MagicTypesGuesser.new end # Registers the provided *guesser*. # The last registered guesser is preferred over previously registered ones. def register_guesser(guesser : AMIME::TypesGuesserInterface) : Nil @guessers.unshift guesser end # :inherit: def extensions(for mime_type : String) : Enumerable(String) extensions = @extensions[mime_type]? || @extensions[lower_case_mime_type = mime_type.downcase]? extensions || MAP[mime_type]? || MAP[lower_case_mime_type || mime_type.downcase]? || [] of String end # :inherit: def mime_types(for extension : String) : Enumerable(String) mime_types = @mime_types[extension]? || @mime_types[lower_case_extension = extension.downcase]? mime_types || REVERSE_MAP[extension]? || REVERSE_MAP[lower_case_extension || extension.downcase]? || [] of String end # :inherit: def supported? : Bool @guessers.any? &.supported? end # :inherit: def guess_mime_type(path : String | Path) : String? @guessers.each do |guesser| next unless guesser.supported? if guess = guesser.guess_mime_type path return guess end end unless self.supported? raise AMIME::Exception::Logic.new "Unable to guess the MIME type as no guessers are available." end nil end end ================================================ FILE: src/components/mime/src/types_guesser_interface.cr ================================================ # Represents a type responsible for guessing the MIME type of a file. module Athena::MIME::TypesGuesserInterface # Returns `true` if this guesser is supported, otherwise `false`. # # The value may be cached on the class level. abstract def supported? : Bool # Returns the guessed MIME type for the file at the provided *path*, # or `nil` if it could not be determined. # # How exactly the MIME type is determined is up to each individual implementation. # # ``` # guesser.guess_mime_type "/path/to/image.png" # => "image/png" # ``` abstract def guess_mime_type(path : String | Path) : String? end ================================================ FILE: src/components/mime/src/types_interface.cr ================================================ require "./types_guesser_interface" # Represents a type responsible for managing MIME types and file extensions. module Athena::MIME::TypesInterface include Athena::MIME::TypesGuesserInterface # Returns the valid file extensions for the provided *mime_type* in decreasing order of preference. # # ``` # types.extensions "image/png" # => {"png"} # ``` abstract def extensions(for mime_type : String) : Enumerable(String) # Returns the valid MIME types for the provided *extension* in decreasing order of preference. # # ``` # types.mime_types "png" # => {"image/png", "image/apng", "image/vnd.mozilla.apng"} # ``` abstract def mime_types(for extension : String) : Enumerable(String) end ================================================ FILE: src/components/negotiation/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/negotiation/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/negotiation/CHANGELOG.md ================================================ # Changelog ## [0.2.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) - Use lowercase `utf-8` within header values ([#417]) (George Dietrich) - Update minimum `crystal` version to `~> 1.13.0` ([#428]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/negotiation/releases/tag/v0.2.0 [#417]: https://github.com/athena-framework/athena/pull/417 [#428]: https://github.com/athena-framework/athena/pull/428 ## [0.1.5] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) [0.1.5]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.5 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.1.4] - 2023-10-09 _Administrative release, no functional changes_ [0.1.4]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.4 ## [0.1.3] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.3 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.1.2] - 2022-05-14 _First release a part of the monorepo._ ### Added - Add `VERSION` constant to `Athena::Negotiation` namespace ([#166]) (George Dietrich) - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Changed - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Fixed - Correct the shard version in `README.md` ([#6]) (syeopite) [0.1.2]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.2 [#6]: https://github.com/athena-framework/negotiation/pull/6 [#166]: https://github.com/athena-framework/athena/pull/166 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.1.1] - 2021-02-04 ### Changed - Migrate documentation to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#4]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.1 [#4]: https://github.com/athena-framework/negotiation/pull/4 ## [0.1.0] - 2020-12-24 _Initial release._ [0.1.0]: https://github.com/athena-framework/negotiation/releases/tag/v0.1.0 ================================================ FILE: src/components/negotiation/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/negotiation/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2020 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/negotiation/README.md ================================================ # Negotiation [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/negotiation.svg)](https://github.com/athena-framework/negotiation/releases) Framework agnostic [content negotiation](https://tools.ietf.org/html/rfc7231#section-5.3) library based on [willdurand/Negotiation](https://github.com/willdurand/Negotiation). ## Getting Started Checkout the [Documentation](https://athenaframework.org/Negotiation). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/negotiation/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.2.0 ### Normalization of Exception types The namespace exception types live in has changed from `ANG::Exceptions` to `ANG::Exception`. Any usages of `negotiation` exception types will need to be updated. If using a `rescue` statement with a parent exception type, either from the `console` component or Crystal stdlib, double check it to ensure it'll still rescue what you are expecting it will. ================================================ FILE: src/components/negotiation/docs/README.md ================================================ The [Athena::Negotiation](/Negotiation/) component allows an application to support [content negotiation](https://tools.ietf.org/html/rfc7231#section-5.3). The component has no dependencies and is framework agnostic; supporting various negotiators. ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-negotiation: github: athena-framework/negotiation version: ~> 0.2.0 ``` ## Usage The main type of [Athena::Negotiation](/Negotiation/) is [ANG::AbstractNegotiator](/Negotiation/AbstractNegotiator/) which is used to implement negotiators for each `Accept*` header. `Athena::Negotiation` exposes class level getters for each negotiator; that return a lazily initialized singleton instance. Each negotiator exposes two methods: [ANG::AbstractNegotiator#best]() and [ANG::AbstractNegotiator#ordered_elements](). ### Media Type ```crystal negotiator = ANG.negotiator accept_header = "text/html, application/xhtml+xml, application/xml;q=0.9" priorities = ["text/html; charset=utf-8", "application/json", "application/xml;q=0.5"] accept = negotiator.best(accept_header, priorities).not_nil! accept.media_range # => "text/html" accept.parameters # => {"charset" => "utf-8"} ``` The [ANG::Negotiator](/Negotiation/Negotiator/) type returns an [ANG::Accept](/Negotiation/Accept/), or `nil` if negotiating the best media type has failed. ### Character Set ```crystal negotiator = ANG.charset_negotiator accept_header = "ISO-8859-1, utf-8; q=0.9" priorities = ["iso-8859-1;q=0.3", "utf-8;q=0.9", "utf-16;q=1.0"] accept = negotiator.best(accept_header, priorities).not_nil! accept.charset # => "utf-8" accept.quality # => 0.9 ``` The [ANG::CharsetNegotiator](/Negotiation/CharsetNegotiator/) type returns an [ANG::AcceptCharset](/Negotiation/AcceptCharset/), or `nil` if negotiating the best character set has failed. ### Encoding ```crystal negotiator = ANG.encoding_negotiator accept_header = "gzip;q=1.0, identity; q=0.5, *;q=0" priorities = ["gzip", "foo"] accept = negotiator.best(accept_header, priorities).not_nil! accept.coding # => "gzip" ``` The [ANG::EncodingNegotiator](/Negotiation/EncodingNegotiator/) type returns an [ANG::AcceptEncoding](/Negotiation/AcceptEncoding/), or `nil` if negotiating the best encoding has failed. ### Language ```crystal negotiator = ANG.language_negotiator accept_header = "en; q=0.1, fr; q=0.4, zh-Hans-CN; q=0.9, de; q=0.2" priorities = ["de", "zh-Hans-CN", "en"] accept = negotiator.best(accept_header, priorities).not_nil! accept.language # => "zh" accept.region # => "cn" accept.script # => "hans" ``` The [ANG::LanguageNegotiator](/Negotiation/LanguageNegotiator/) type returns an [ANG::AcceptLanguage](/Negotiation/AcceptLanguage/), or `nil` if negotiating the best language has failed. ================================================ FILE: src/components/negotiation/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Negotiation site_url: https://athenaframework.org/Negotiation/ repo_url: https://github.com/athena-framework/negotiation nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-negotiation/src/athena-negotiation.cr source_locations: lib/athena-negotiation: https://github.com/athena-framework/negotiation/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/negotiation/shard.yml ================================================ name: athena-negotiation version: 0.2.0 crystal: ~> 1.13 license: MIT repository: https://github.com/athena-framework/negotiation documentation: https://athenaframework.org/Negotiation description: | Framework agnostic content negotiation library. authors: - George Dietrich ================================================ FILE: src/components/negotiation/spec/accept_language_spec.cr ================================================ require "./spec_helper" struct AcceptLanguageTest < ASPEC::TestCase @[DataProvider("accept_value_data_provider")] def test_accept_value(header : String?, expected : String?) : Nil ANG::AcceptLanguage.new(header).accept_value.should eq expected end def accept_value_data_provider : Tuple { {"en;q=0.7", "en"}, {"en-GB;q=0.8", "en-gb"}, {"da", "da"}, {"en-gb;q=0.8", "en-gb"}, {"es;q=0.7", "es"}, {"fr ; q= 0.1", "fr"}, } end @[DataProvider("header_data_provider")] def test_get_value(header : String?, expected : String?) : Nil ANG::AcceptLanguage.new(header).header.should eq expected end def header_data_provider : Tuple { {"en;q=0.7", "en;q=0.7"}, {"en-GB;q=0.8", "en-GB;q=0.8"}, } end @[TestWith( {"en;q=0.7", "en"}, {"en-GB;q=0.8", "en-gb"}, {"zh-Hans-CN;q=0.8", "zh-hans-cn"}, )] def test_language_range(header : String, expected : String) : Nil ANG::AcceptLanguage.new(header).language_range.should eq expected end end ================================================ FILE: src/components/negotiation/spec/accept_match_spec.cr ================================================ require "./spec_helper" struct AcceptMatchTest < ASPEC::TestCase @[DataProvider("compare_data_provider")] def test_compare(match1 : ANG::AcceptMatch, match2 : ANG::AcceptMatch, expected : Int32) : Nil (match1 <=> match2).should eq expected end def compare_data_provider : Tuple { {ANG::AcceptMatch.new(1.0, 110, 1), ANG::AcceptMatch.new(1.0, 111, 1), 0}, {ANG::AcceptMatch.new(0.1, 10, 1), ANG::AcceptMatch.new(0.1, 10, 2), -1}, {ANG::AcceptMatch.new(0.5, 110, 5), ANG::AcceptMatch.new(0.5, 11, 4), 1}, {ANG::AcceptMatch.new(0.4, 110, 1), ANG::AcceptMatch.new(0.6, 111, 3), 1}, {ANG::AcceptMatch.new(0.6, 110, 1), ANG::AcceptMatch.new(0.4, 111, 3), -1}, } end @[DataProvider("reduce_data_provider")] def test_reduce(matches : Hash(Int32, ANG::AcceptMatch), match : ANG::AcceptMatch, expected : Hash(Int32, ANG::AcceptMatch)) : Nil ANG::AcceptMatch.reduce(matches, match).should eq expected end def reduce_data_provider : Tuple { { {1 => ANG::AcceptMatch.new(1.0, 10, 1)}, ANG::AcceptMatch.new(0.5, 111, 1), {1 => ANG::AcceptMatch.new(0.5, 111, 1)}, }, { {1 => ANG::AcceptMatch.new(1.0, 110, 1)}, ANG::AcceptMatch.new(0.5, 11, 1), {1 => ANG::AcceptMatch.new(1.0, 110, 1)}, }, { {0 => ANG::AcceptMatch.new(1.0, 10, 1)}, ANG::AcceptMatch.new(0.5, 111, 1), {0 => ANG::AcceptMatch.new(1.0, 10, 1), 1 => ANG::AcceptMatch.new(0.5, 111, 1)}, }, } end end ================================================ FILE: src/components/negotiation/spec/accept_spec.cr ================================================ require "./spec_helper" struct AcceptTest < ASPEC::TestCase def test_parameters : Nil ANG::Accept.new("foo/bar; q=1; hello=world").parameters["hello"]?.should eq "world" end @[DataProvider("normalized_header_data_provider")] def test_normalized_header(header : String, expected : String) : Nil ANG::Accept.new(header).normalized_header.should eq expected end def normalized_header_data_provider : Tuple { {"text/html ; z=y; a = b; c=d", "text/html; a=b; c=d; z=y"}, {"application/pdf; q=1; param=p", "application/pdf; param=p"}, } end @[DataProvider("media_range_data_provider")] def test_media_range(header : String, expected : String) : Nil ANG::Accept.new(header).media_range.should eq expected end def media_range_data_provider : Tuple { {"text/html;hello=world", "text/html"}, {"application/pdf", "application/pdf"}, {"application/xhtml+xml;q=0.9", "application/xhtml+xml"}, {"text/plain; q=0.5", "text/plain"}, {"text/html;level=2;q=0.4", "text/html"}, {"text/html ; level = 2 ; q = 0.4", "text/html"}, {"text/*", "text/*"}, {"text/* ;q=1 ;level=2", "text/*"}, {"*/*", "*/*"}, {"*", "*/*"}, {"*/* ; param=555", "*/*"}, {"* ; param=555", "*/*"}, {"TEXT/hTmL;leVel=2; Q=0.4", "text/html"}, } end @[DataProvider("header_data_provider")] def test_accept_value(header : String, expected : String) : Nil ANG::Accept.new(header).header.should eq expected end def header_data_provider : Tuple { {"text/html;hello=world ;q=0.5", "text/html;hello=world ;q=0.5"}, {"application/pdf", "application/pdf"}, } end end ================================================ FILE: src/components/negotiation/spec/base_accept_spec.cr ================================================ require "./spec_helper" private struct MockAccept < ANG::BaseAccept; end struct BaseAcceptTest < ASPEC::TestCase @[DataProvider("build_parameters_data_provider")] def test_build_parameters_string(header : String, expected : String) : Nil MockAccept.new(header).normalized_header.should eq expected end def build_parameters_data_provider : Tuple { {"media/type; xxx = 1.0;level=2;foo=bar", "media/type; foo=bar; level=2; xxx=1.0"}, } end @[DataProvider("parameters_data_provider")] def test_parse_parameters(header : String, expected_parameters : Hash(String, String)) : Nil accept = MockAccept.new header parameters = accept.parameters # TODO: Can this be improved? if header.includes? 'q' parameters["q"] = accept.quality.to_s end expected_parameters.size.should eq parameters.size expected_parameters.each do |k, v| parameters.has_key?(k).should be_true parameters[k].should eq v end end def parameters_data_provider : Tuple { { "application/json ;q=1.0; level=2;foo= bar", { "q" => "1.0", "level" => "2", "foo" => "bar", }, }, { "application/json ;q = 1.0; level = 2; FOO = bAr", { "q" => "1.0", "level" => "2", "foo" => "bAr", }, }, { "application/json;q=1.0", { "q" => "1.0", }, }, { "application/json;foo", {} of String => String, }, } end end ================================================ FILE: src/components/negotiation/spec/charset_negotiator_spec.cr ================================================ require "./spec_helper" struct CharsetNegotiatorTest < NegotiatorTestCase @negotiator : ANG::CharsetNegotiator def initialize @negotiator = ANG::CharsetNegotiator.new end def test_best_unmatched_header : Nil @negotiator.best("foo, bar, yo", {"baz"}).should be_nil end def test_best_ignores_missing_content : Nil accept = @negotiator.best "en; q=0.1, fr; q=0.4, bu; q=1.0", {"en", "fr"} accept = accept.should_not be_nil accept.should be_a ANG::AcceptCharset accept.charset.should eq "fr" end def test_best_respects_priorities : Nil accept = @negotiator.best "foo, bar, yo", {"yo"} accept = accept.should_not be_nil accept.should be_a ANG::AcceptCharset accept.charset.should eq "yo" end def test_best_respects_quality : Nil accept = @negotiator.best "utf-8;q=0.5,iso-8859-1", {"iso-8859-1;q=0.3", "utf-8;q=0.9", "utf-16;q=1.0"} accept = accept.should_not be_nil accept.should be_a ANG::AcceptCharset accept.charset.should eq "utf-8" end @[DataProvider("best_data_provider")] def test_best(header : String, priorities : Indexable(String), expected : String?) : Nil accept = @negotiator.best header, priorities if accept.nil? expected.should be_nil else accept.should be_a ANG::AcceptCharset accept.header.should eq expected end end def best_data_provider : Tuple php_pear_charset = "ISO-8859-1, Big5;q=0.6,utf-8;q=0.7, *;q=0.5" php_pear_charset2 = "ISO-8859-1, Big5;q=0.6,utf-8;q=0.7" { {php_pear_charset, {"utf-8", "big5", "iso-8859-1", "shift-jis"}, "iso-8859-1"}, {php_pear_charset, {"utf-8", "big5", "shift-jis"}, "utf-8"}, {php_pear_charset, {"Big5", "shift-jis"}, "Big5"}, {php_pear_charset, {"shift-jis"}, "shift-jis"}, {php_pear_charset2, {"utf-8", "big5", "iso-8859-1", "shift-jis"}, "iso-8859-1"}, {php_pear_charset2, {"utf-8", "big5", "shift-jis"}, "utf-8"}, {php_pear_charset2, {"Big5", "shift-jis"}, "Big5"}, {"utf-8;q=0.6,iso-8859-5;q=0.9", {"iso-8859-5", "utf-8"}, "iso-8859-5"}, {"en, *;q=0.9", {"fr"}, "fr"}, # Quality of source factors {php_pear_charset, {"iso-8859-1;q=0.5", "utf-8", "utf-16;q=1.0"}, "utf-8"}, {php_pear_charset, {"iso-8859-1;q=0.8", "utf-8", "utf-16;q=1.0"}, "iso-8859-1;q=0.8"}, } end end ================================================ FILE: src/components/negotiation/spec/encoding_negotiator_spec.cr ================================================ require "./spec_helper" struct EncodingNegotiatorTest < NegotiatorTestCase @negotiator : ANG::EncodingNegotiator def initialize @negotiator = ANG::EncodingNegotiator.new end def test_best_unmatched_header : Nil @negotiator.best("foo, bar, yo", {"baz"}).should be_nil end def test_best_respects_quality : Nil accept = @negotiator.best "gzip;q=0.7,identity", {"identity;q=0.5", "gzip;q=0.9"} accept = accept.should_not be_nil accept.should be_a ANG::AcceptEncoding accept.coding.should eq "gzip" end @[DataProvider("best_data_provider")] def test_best(header : String, priorities : Indexable(String), expected : String?) : Nil accept = @negotiator.best header, priorities if accept.nil? expected.should be_nil else accept.should be_a ANG::AcceptEncoding accept.header.should eq expected end end def best_data_provider : Tuple { {"gzip;q=1.0, identity; q=0.5, *;q=0", {"identity"}, "identity"}, {"gzip;q=0.5, identity; q=0.5, *;q=0.7", {"bzip", "foo"}, "bzip"}, {"gzip;q=0.7, identity; q=0.5, *;q=0.7", {"gzip", "foo"}, "gzip"}, # Quality of source factors {"gzip;q=0.7,identity", {"identity;q=0.5", "gzip;q=0.9"}, "gzip;q=0.9"}, } end end ================================================ FILE: src/components/negotiation/spec/language_negotiator_spec.cr ================================================ require "./spec_helper" struct LanguageNegotiatorTest < NegotiatorTestCase @negotiator : ANG::LanguageNegotiator def initialize @negotiator = ANG::LanguageNegotiator.new end def test_best_respects_quality : Nil accept = @negotiator.best "en;q=0.5,de", {"de;q=0.3", "en;q=0.9"} accept = accept.should_not be_nil accept.should be_a ANG::AcceptLanguage accept.language.should eq "en" end @[DataProvider("best_data_provider")] def test_best(header : String, priorities : Indexable(String), expected : String?) : Nil accept = @negotiator.best header, priorities if accept.nil? expected.should be_nil else accept.should be_a ANG::AcceptLanguage accept.header.should eq expected end end def best_data_provider : Tuple { {"en, de", {"fr"}, nil}, {"foo, bar, yo", {"baz", "biz"}, nil}, {"fr-FR, en;q=0.8", {"en-US", "de-DE"}, "en-US"}, {"en, *;q=0.9", {"fr"}, "fr"}, {"foo, bar, yo", {"yo"}, "yo"}, {"en; q=0.1, fr; q=0.4, bu; q=1.0", {"en", "fr"}, "fr"}, {"en; q=0.1, fr; q=0.4, fu; q=0.9, de; q=0.2", {"en", "fu"}, "fu"}, {"fr, zh-Hans-CN;q=0.3", {"fr"}, "fr"}, # Quality of source factors {"en;q=0.5,de", {"de;q=0.3", "en;q=0.9"}, "en;q=0.9"}, } end end ================================================ FILE: src/components/negotiation/spec/negotiator_spec.cr ================================================ require "./spec_helper" struct NegotiatorTest < NegotiatorTestCase @negotiator : ANG::Negotiator def initialize @negotiator = ANG::Negotiator.new end def test_best_respects_quality : Nil accept = @negotiator.best "text/html,text/*;q=0.7", {"text/html;q=0.5", "text/plain;q=0.9"} accept = accept.should_not be_nil accept.should be_a ANG::Accept accept.media_range.should eq "text/plain" end def test_best_invalid_unstrict @negotiator.best("/qwer", {"foo/bar"}, false).should be_nil end def test_invalid_media_type : Nil ex = expect_raises ANG::Exception::InvalidMediaType, "Invalid media type: '/qwer'." do @negotiator.best "foo/bar", {"/qwer"} end ex.media_range.should eq "/qwer" end @[DataProvider("best_data_provider")] def test_best(header : String, priorities : Indexable(String), expected : Tuple(String, Hash(String, String) | Nil) | Nil) : Nil begin accept_header = @negotiator.best header, priorities rescue ex ex.should eq expected return end if accept_header.nil? expected.should be_nil return end accept_header.should be_a ANG::Accept expected = expected.should_not be_nil accept_header.media_range.should eq expected[0] accept_header.parameters.should eq(expected[1] || Hash(String, String).new) end def best_data_provider : Tuple rfc_header = "text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5" php_pear_header = "text/html,application/xhtml+xml,application/xml;q=0.9,text/*;q=0.7,*/*,image/gif; q=0.8, image/jpeg; q=0.6, image/*" { {"/qwer", {"f/g"}, nil}, {"text/html", {"application/rss"}, nil}, {rfc_header, {"text/html;q=0.4", "text/plain"}, {"text/plain", nil}}, # See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html. {rfc_header, {"text/html;level=1"}, {"text/html", {"level" => "1"}}}, {rfc_header, {"text/html"}, {"text/html", nil}}, {rfc_header, {"image/jpeg"}, {"image/jpeg", nil}}, {rfc_header, {"text/html;level=2"}, {"text/html", {"level" => "2"}}}, {rfc_header, {"text/html;level=3"}, {"text/html", {"level" => "3"}}}, {"text/*;q=0.7, text/html;q=0.3, */*;q=0.5, image/png;q=0.4", {"text/html", "image/png"}, {"image/png", nil}}, {"image/png;q=0.1, text/plain, audio/ogg;q=0.9", {"image/png", "text/plain", "audio/ogg"}, {"text/plain", nil}}, {"image/png, text/plain, audio/ogg", {"baz/asdf"}, nil}, {"image/png, text/plain, audio/ogg", {"audio/ogg"}, {"audio/ogg", nil}}, {"image/png, text/plain, audio/ogg", {"YO/SuP"}, nil}, {"text/html; charset=utf-8, application/pdf", {"text/html; charset=utf-8"}, {"text/html", {"charset" => "utf-8"}}}, {"text/html; charset=utf-8, application/pdf", {"text/html"}, nil}, {"text/html, application/pdf", {"text/html; charset=utf-8"}, {"text/html", {"charset" => "utf-8"}}}, # PHP"s PEAR HTTP2 assertions I took from the other lib. {php_pear_header, {"image/gif", "image/png", "application/xhtml+xml", "application/xml", "text/html", "image/jpeg", "text/plain"}, {"image/png", nil}}, {php_pear_header, {"image/gif", "application/xhtml+xml", "application/xml", "image/jpeg", "text/plain"}, {"application/xhtml+xml", nil}}, {php_pear_header, {"image/gif", "application/xml", "image/jpeg", "text/plain"}, {"application/xml", nil}}, {php_pear_header, {"image/gif", "image/jpeg", "text/plain"}, {"image/gif", nil}}, {php_pear_header, {"text/plain", "image/png", "image/jpeg"}, {"image/png", nil}}, {php_pear_header, {"image/jpeg", "image/gif"}, {"image/gif", nil}}, {php_pear_header, {"image/png"}, {"image/png", nil}}, {php_pear_header, {"audio/midi"}, {"audio/midi", nil}}, {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", {"application/rss+xml"}, {"application/rss+xml", nil}}, # Case sensitivity {"text/* ; q=0.3, TEXT/html ;Q=0.7, text/html ; level=1, texT/Html ;leVel = 2 ;q=0.4, */* ; q=0.5", {"text/html; level=2"}, {"text/html", {"level" => "2"}}}, {"text/* ; q=0.3, text/html;Q=0.7, text/html ;level=1, text/html; level=2;q=0.4, */*;q=0.5", {"text/HTML; level=3"}, {"text/html", {"level" => "3"}}}, # IE8 {"image/jpeg, application/x-ms-application, image/gif, application/xaml+xml, image/pjpeg, application/x-ms-xbap, */*", {"text/html", "application/xhtml+xml"}, {"text/html", nil}}, # wildcards with `+` {"application/vnd.api+json", {"application/json", "application/*+json"}, {"application/*+json", nil}}, {"application/json;q=0.7, application/*+json;q=0.7", {"application/hal+json", "application/problem+json"}, {"application/hal+json", nil}}, {"application/json;q=0.7, application/problem+*;q=0.7", {"application/hal+xml", "application/problem+xml"}, {"application/problem+xml", nil}}, {php_pear_header, {"application/*+xml"}, {"application/*+xml", nil}}, {"application/hal+json", {"application/ld+json", "application/hal+json", "application/xml", "text/xml", "application/json", "text/html"}, {"application/hal+json", nil}}, } end def test_ordered_elements_exception_handling : Nil expect_raises ArgumentError, "The header string should not be empty." do @negotiator.ordered_elements "" end end @[DataProvider("test_ordered_elements_data_provider")] def test_ordered_elements(header : String, expected : Indexable(String)) : Nil elements = @negotiator.ordered_elements header elements.should be_a Array(ANG::Accept) expected.each_with_index do |element, idx| elements[idx].should be_a ANG::Accept element.should eq elements[idx].header end end def test_ordered_elements_data_provider : Tuple { {"/qwer", [] of String}, # Invalid {"text/html, text/xml", {"text/html", "text/xml"}}, # Ordered as given if no quality modifier {"text/html;q=0.3, text/html;q=0.7", {"text/html;q=0.7", "text/html;q=0.3"}}, # Ordered by quality modifier {"text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5", {"text/html;level=1", "text/html;q=0.7", "*/*;q=0.5", "text/html;level=2;q=0.4", "text/*;q=0.3"}}, # Ordered by quality modifier; one without wins } end end ================================================ FILE: src/components/negotiation/spec/negotiator_test_case.cr ================================================ abstract struct NegotiatorTestCase < ASPEC::TestCase def test_best_exception_handling : Nil expect_raises ArgumentError, "priorities should not be empty." do @negotiator.best "foo/bar", [] of String end expect_raises ArgumentError, "The header string should not be empty." do @negotiator.best "", {"text/html"} end end end ================================================ FILE: src/components/negotiation/spec/spec_helper.cr ================================================ require "spec" require "athena-spec" require "../src/athena-negotiation" require "./negotiator_test_case" include ASPEC::Methods ASPEC.run_all ================================================ FILE: src/components/negotiation/src/abstract_negotiator.cr ================================================ # Base negotiator type. Implements logic common to all negotiators. abstract class Athena::Negotiation::AbstractNegotiator(HeaderType) private record OrderKey, quality : Float32, index : Int32, value : String do include Comparable(self) def <=>(other : self) : Int32 return @index <=> other.index if @quality == other.quality @quality > other.quality ? -1 : 1 end end # Returns the best `HeaderType` based on the provided *header* value and *priorities*. # # If *strict* is `true`, an `ANG::Exception::Exception` will be raised if the *header* contains an invalid value, otherwise it is ignored. # # See `Athena::Negotiation` for examples. def best(header : String, priorities : Indexable(String), strict : Bool = false) : HeaderType? raise ANG::Exception::InvalidArgument.new "priorities should not be empty." if priorities.empty? raise ANG::Exception::InvalidArgument.new "The header string should not be empty." if header.blank? accepted_headers = Array(HeaderType).new self.parse_header(header) do |h| accepted_headers << HeaderType.new h rescue ex raise ex if strict end accepted_priorities = priorities.map { |p| HeaderType.new p } matches = self.find_matches accepted_headers, accepted_priorities specific_matches = matches.reduce({} of Int32 => ANG::AcceptMatch) do |acc, match| ANG::AcceptMatch.reduce acc, match end.values specific_matches.sort! match = specific_matches.shift? match.nil? ? nil : accepted_priorities[match.index] end # Returns an array of `HeaderType` that the provided *header* allows, ordered so that the `#best` match is first. # # ``` # header = "text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5" # # ordered_elements = ANG.negotiator.ordered_elements header # # ordered_elements[0].media_range # => "text/html" # ordered_elements[1].media_range # => "text/html" # ordered_elements[2].media_range # => "*/*" # ordered_elements[3].media_range # => "text/html" # ordered_elements[4].media_range # => "text/*" # ``` def ordered_elements(header : String) : Array(HeaderType) raise ANG::Exception::InvalidArgument.new "The header string should not be empty." if header.blank? elements = Array(HeaderType).new order_keys = Array(OrderKey).new idx = 0 self.parse_header(header) do |h| element = HeaderType.new h elements << element order_keys << OrderKey.new element.quality, idx, element.header rescue ex # skip ensure idx += 1 end order_keys.sort!.map do |ok| elements[ok.index] end end protected def match(header : ANG::BaseAccept, priority : ANG::BaseAccept, index : Int32) : ANG::AcceptMatch? accept_value = header.accept_value priority_value = priority.accept_value equal = accept_value.downcase == priority_value.downcase if equal || accept_value == "*" return ANG::AcceptMatch.new header.quality * priority.quality, 1 * (equal ? 1 : 0), index end nil end private def parse_header(header : String, & : String ->) : Nil header.scan /(?:[^,\"]*+(?:"[^"]*+\")?)+[^,\"]*+/ do |match| yield match[0].strip unless match[0].blank? end end private def find_matches(headers : Array(HeaderType), priorities : Indexable(HeaderType)) : Array(ANG::AcceptMatch) matches = [] of ANG::AcceptMatch priorities.each_with_index do |priority, idx| headers.each do |header| if match = self.match(header, priority, idx) matches << match end end end matches end end ================================================ FILE: src/components/negotiation/src/accept.cr ================================================ require "./base_accept" # Represents an [Accept](https://tools.ietf.org/html/rfc7231#section-5.3.2) header media type. # # ``` # accept = ANG::Accept.new "application/json; q = 0.75; charset = utf-8" # # accept.header # => "application/json; q = 0.75; charset = utf-8" # accept.normalized_header # => "application/json; charset=utf-8" # accept.parameters # => {"charset" => "utf-8"} # accept.quality # => 0.75 # accept.type # => "application" # accept.sub_type # => "json" # ``` struct Athena::Negotiation::Accept < Athena::Negotiation::BaseAccept # Returns the type for this `Accept` header. # E.x. if the `#media_range` is `application/json`, the type would be `application`. getter type : String # Returns the sub type for this `Accept` header. # E.x. if the `#media_range` is `application/json`, the sub type would be `json`. getter sub_type : String def initialize(value : String) super value @accept_value = "*/*" if @accept_value == "*" parts = @accept_value.split '/' if parts.size != 2 || !parts[0].presence || !parts[1].presence raise ANG::Exception::InvalidMediaType.new @accept_value end @type = parts[0] @sub_type = parts[1] end # Returns the media range this `Accept` header represents. # # I.e. `#header` minus the `#quality` and `#parameters`. def media_range : String @accept_value end end ================================================ FILE: src/components/negotiation/src/accept_charset.cr ================================================ require "./base_accept" # Represents an [Accept-Charset](https://tools.ietf.org/html/rfc7231#section-5.3.3) header character set. # # ``` # accept = ANG::AcceptCharset.new "iso-8859-1; q = 0.5; key=value" # # accept.header # => "iso-8859-1; q = 0.5; key=value" # accept.normalized_header # => "iso-8859-1; key=value" # accept.parameters # => {"key" => "value"} # accept.quality # => 0.5 # accept.charset # => "iso-8859-1" # ``` struct Athena::Negotiation::AcceptCharset < Athena::Negotiation::BaseAccept # Returns the character set this `AcceptCharset` header represents. # # I.e. `#header` minus the `#quality` and `#parameters`. def charset : String @accept_value end end ================================================ FILE: src/components/negotiation/src/accept_encoding.cr ================================================ require "./base_accept" # Represents an [Accept-Encoding](https://tools.ietf.org/html/rfc7231#section-5.3.4) header character set. # # ``` # accept = ANG::AcceptEncoding.new "gzip; q = 0.5; key=value" # # accept.header # => "gzip; q = 0.5; key=value" # accept.normalized_header # => "gzip; key=value" # accept.parameters # => {"key" => "value"} # accept.quality # => 0.5 # accept.coding # => "gzip" # ``` struct Athena::Negotiation::AcceptEncoding < Athena::Negotiation::BaseAccept # Returns the content coding this `AcceptEncoding` header represents. # # I.e. `#header` minus the `#quality` and `#parameters`. def coding : String @accept_value end end ================================================ FILE: src/components/negotiation/src/accept_language.cr ================================================ require "./base_accept" # Represents an [Accept-Language](https://tools.ietf.org/html/rfc7231#section-5.3.5) header character set. # # ``` # accept = ANG::AcceptLanguage.new "zh-Hans-CN; q = 0.3; key=value" # # accept.header # => "zh-Hans-CN; q = 0.3; key=value" # accept.normalized_header # => "zh-Hans-CN; key=value" # accept.parameters # => {"key" => "value"} # accept.quality # => 0.3 # accept.language # => "zh" # accept.region # => "cn" # accept.script # => "hans" # ``` struct Athena::Negotiation::AcceptLanguage < Athena::Negotiation::BaseAccept # Returns the language for this `AcceptLanguage` header. # E.x. if the `#language_range` is `zh-Hans-CN`, the language would be `zh`. getter language : String # Returns the region, if any, for this `AcceptLanguage` header. # E.x. if the `#language_range` is `zh-Hans-CN`, the region would be `cn` getter region : String? = nil # Returns the script, if any, for this `AcceptLanguage` header. # E.x. if the `#language_range` is `zh-Hans-CN`, the script would be `hans` getter script : String? = nil def initialize(value : String) super value parts = @accept_value.split '-' case parts.size when 1 @language = parts[0] when 2 @language = parts[0] @region = parts[1] when 3 @language = parts[0] @script = parts[1] @region = parts[2] else raise ANG::Exception::InvalidLanguage.new @accept_value end end # Returns the language range this `AcceptLanguage` header represents. # # I.e. `#header` minus the `#quality` and `#parameters`. def language_range : String @accept_value end end ================================================ FILE: src/components/negotiation/src/accept_match.cr ================================================ # :nodoc: struct Athena::Negotiation::AcceptMatch include Comparable(self) getter quality : Float32 getter score : Int32 getter index : Int32 def self.reduce(matches : Hash(Int32, self), match : self) : Hash(Int32, self) if !matches.has_key?(match.index) || matches[match.index].score < match.score matches[match.index] = match end matches end def initialize(@quality : Float32, @score : Int32, @index : Int32); end def <=>(other : self) : Int32 if @quality != other.quality return @quality > other.quality ? -1 : 1 end if @index != other.index return @index > other.index ? 1 : -1 end 0 end end ================================================ FILE: src/components/negotiation/src/athena-negotiation.cr ================================================ require "./accept" require "./accept_match" require "./accept_charset" require "./accept_encoding" require "./accept_language" require "./charset_negotiator" require "./encoding_negotiator" require "./language_negotiator" require "./negotiator" require "./exception/*" # Convenience alias to make referencing `Athena::Negotiation` types easier. alias ANG = Athena::Negotiation # The `Athena::Negotiation` component allows an application to support [content negotiation](https://tools.ietf.org/html/rfc7231#section-5.3). module Athena::Negotiation VERSION = "0.2.0" # Returns a lazily initialized `ANG::Negotiator` singleton instance. class_getter(negotiator) { ANG::Negotiator.new } # Returns a lazily initialized `ANG::CharsetNegotiator` singleton instance. class_getter(charset_negotiator) { ANG::CharsetNegotiator.new } # Returns a lazily initialized `ANG::EncodingNegotiator` singleton instance. class_getter(encoding_negotiator) { ANG::EncodingNegotiator.new } # Returns a lazily initialized `ANG::LanguageNegotiator` singleton instance. class_getter(language_negotiator) { ANG::LanguageNegotiator.new } # Both acts as a namespace for exceptions related to the `Athena::Negotiation` component, as well as a way to check for exceptions from the component. module Exception; end end ================================================ FILE: src/components/negotiation/src/base_accept.cr ================================================ # Base type for properties/logic all [Accept*](https://tools.ietf.org/html/rfc7231#section-5.3) headers share. abstract struct Athena::Negotiation::BaseAccept # Returns the full unaltered header `self` represents. # E.x. `text/html`, `unicode-1-1;q=0.8`, or `zh-Hans-CN`. getter header : String # Returns a normalized version of the `#header`, excluding the `#quality` parameter. # # This includes removing extraneous whitespace, and alphabetizing the `#parameters`. getter normalized_header : String # Returns any extension parameters included in the header `self` represents. # E.x. `charset=utf-8` or `version=2`. getter parameters : Hash(String, String) = Hash(String, String).new # Returns the [quality value](https://tools.ietf.org/html/rfc7231#section-5.3.1) of the header `self` represents. getter quality : Float32 = 1.0 # Represents the base header value, e.g. `#header` minus the `#quality` and `#parameters`. # This is exposed as a getter on each subtype to have a more descriptive API. protected getter accept_value : String def initialize(@header : String) parts = @header.split ';' @accept_value = parts.shift.strip.downcase parts.each do |part| part = part.split '=' # Skip invalid parameters next unless part.size == 2 @parameters[part[0].strip.downcase] = part[1].strip(" \"") end if quality = @parameters.delete "q" # RFC Only allows max of 3 decimal points. @quality = quality.to_f32.round 3 end @normalized_header = String.build do |io| io << @accept_value unless @parameters.empty? io << "; " @parameters.keys.sort!.join(io, "; ") { |k, join_io| join_io << "#{k}=#{@parameters[k]}" } end end end end ================================================ FILE: src/components/negotiation/src/charset_negotiator.cr ================================================ require "./abstract_negotiator" # A `ANG::AbstractNegotiator` implementation to negotiate `ANG::AcceptCharset` headers. class Athena::Negotiation::CharsetNegotiator < Athena::Negotiation::AbstractNegotiator(Athena::Negotiation::AcceptCharset) end ================================================ FILE: src/components/negotiation/src/encoding_negotiator.cr ================================================ require "./abstract_negotiator" # A `ANG::AbstractNegotiator` implementation to negotiate `ANG::AcceptEncoding` headers. class Athena::Negotiation::EncodingNegotiator < Athena::Negotiation::AbstractNegotiator(Athena::Negotiation::AcceptEncoding) end ================================================ FILE: src/components/negotiation/src/exception/invalid_argument.cr ================================================ class Athena::Negotiation::Exception::InvalidArgument < ArgumentError include Athena::Negotiation::Exception end ================================================ FILE: src/components/negotiation/src/exception/invalid_language.cr ================================================ # Represents an invalid `ANG::AcceptLanguage` header. class Athena::Negotiation::Exception::InvalidLanguage < RuntimeError include Athena::Negotiation::Exception # Returns the invalid language code. getter language : String def initialize(@language : String, cause : ::Exception? = nil) super "Invalid language: '#{@language}'.", cause end end ================================================ FILE: src/components/negotiation/src/exception/invalid_media_type.cr ================================================ # Represents an invalid `ANG::Accept` header. class Athena::Negotiation::Exception::InvalidMediaType < RuntimeError include Athena::Negotiation::Exception # Returns the invalid media range. getter media_range : String def initialize(@media_range : String, cause : ::Exception? = nil) super "Invalid media type: '#{@media_range}'.", cause end end ================================================ FILE: src/components/negotiation/src/language_negotiator.cr ================================================ require "./abstract_negotiator" # A `ANG::AbstractNegotiator` implementation to negotiate `ANG::AcceptLanguage` headers. class Athena::Negotiation::LanguageNegotiator < Athena::Negotiation::AbstractNegotiator(Athena::Negotiation::AcceptLanguage) protected def match(accept : ANG::AcceptLanguage, priority : ANG::AcceptLanguage, index : Int32) : ANG::AcceptMatch? accept_base = accept.language priority_base = priority.language accept_sub = accept.region priority_sub = priority.region base_equal = accept_base.downcase == priority_base.downcase sub_equal = accept_sub.try &.downcase == priority_sub.try &.downcase if (accept_base == "*" || base_equal) && (accept_sub.nil? || sub_equal) score = 10 * (base_equal ? 1 : 0) + (sub_equal ? 1 : 0) return ANG::AcceptMatch.new accept.quality * priority.quality, score, index end nil end end ================================================ FILE: src/components/negotiation/src/negotiator.cr ================================================ require "./abstract_negotiator" # A `ANG::AbstractNegotiator` implementation to negotiate `ANG::Accept` headers. class Athena::Negotiation::Negotiator < Athena::Negotiation::AbstractNegotiator(Athena::Negotiation::Accept) # TODO: Make this method less complex. # # ameba:disable Metrics/CyclomaticComplexity protected def match(accept : ANG::Accept, priority : ANG::Accept, index : Int32) : ANG::AcceptMatch? accept_type = accept.type priority_type = priority.type accept_sub_type = accept.sub_type priority_sub_type = priority.sub_type intersection = accept.parameters.each_with_object({} of String => String) do |(k, v), params| priority.parameters.tap do |pp| params[k] = v if pp.has_key?(k) && pp[k] == v end end type_equals = accept_type.downcase == priority_type.downcase sub_type_equals = accept_sub_type.downcase == priority_sub_type.downcase if (accept_type == "*" || type_equals) && (accept_sub_type == "*" || sub_type_equals) && intersection.size == accept.parameters.size score = 100 * (type_equals ? 1 : 0) + 10 * (sub_type_equals ? 1 : 0) + intersection.size return ANG::AcceptMatch.new accept.quality * priority.quality, score, index end return nil if !accept_sub_type.includes?('+') || !priority_sub_type.includes?('+') accept_sub_type, accept_plus = self.split_sub_type accept_sub_type priority_sub_type, priority_plus = self.split_sub_type priority_sub_type if !(accept_type == "*" || type_equals) || !(accept_sub_type == "*" || priority_sub_type == "*" || accept_plus == "*" || priority_plus == "*") return nil end sub_type_equals = accept_sub_type.downcase == priority_sub_type.downcase plus_equals = accept_plus.downcase == priority_plus.downcase if (accept_sub_type == "*" || priority_sub_type == "*" || sub_type_equals) && (accept_plus == "*" || priority_plus == '*' || plus_equals) && intersection.size == accept.parameters.size score = 100 * (type_equals ? 1 : 0) + 10 * (sub_type_equals ? 1 : 0) + (plus_equals ? 1 : 0) + intersection.size return ANG::AcceptMatch.new accept.quality * priority.quality, score, index end nil end private def split_sub_type(sub_type : String) : Array(String) return [sub_type, ""] unless sub_type.includes? '+' sub_type.split '+', limit: 2 end end ================================================ FILE: src/components/routing/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/routing/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/routing/CHANGELOG.md ================================================ # Changelog ## [0.2.0] - 2026-04-19 ### Changed - **Breaking:** Change `ART::Route#defaults` and matched route parameters return type to `ART::Parameters` ([#652]) (George Dietrich) - **Breaking:** Loosen the type restriction for the `params` parameter of `ART::Generator::Interface#generate` ([#669]) (George Dietrich) ### Added - Allow query-specific parameters within `ART::Generator::URLGenerator` via special `_query` parameter ([#669]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/routing/releases/tag/v0.2.0 [#652]: https://github.com/athena-framework/athena/pull/652 [#669]: https://github.com/athena-framework/athena/pull/669 ## [0.1.12] - 2025-11-01 ### Fixed - Fix Crystal `1.19` incompatibility ([#600]) (George Dietrich) [0.1.12]: https://github.com/athena-framework/routing/releases/tag/v0.1.12 [#600]: https://github.com/athena-framework/athena/pull/600 ## [0.1.11] - 2025-09-04 ### Fixed - Fix linker warning due to duplicate `pcre2-8` linkage ([#560]) (George Dietrich) [0.1.11]: https://github.com/athena-framework/routing/releases/tag/v0.1.11 [#560]: https://github.com/athena-framework/athena/pull/560 ## [0.1.10] - 2025-01-26 ### Changed - Allow having multiple independent compiled route collections ([#468]) (George Dietrich) - Log unhandled `ART::RoutingHandler` exceptions ([#470]) (George Dietrich) ### Fixed - Make `ART::RequestContext.from_uri` more robust ([#498]) (George Dietrich) [0.1.10]: https://github.com/athena-framework/routing/releases/tag/v0.1.10 [#468]: https://github.com/athena-framework/athena/pull/468 [#470]: https://github.com/athena-framework/athena/pull/470 [#498]: https://github.com/athena-framework/athena/pull/498 ## [0.1.9] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) ### Added - **Breaking:** add kwargs overload to `ART::Generator::Interface#generate` ([#375]) (George Dietrich) ### Fixed - Fix compatibility with PCRE2 10.43 ([#362]) (George Dietrich) - Fix error when PCRE2 JIT mode is unavailable ([#381]) (George Dietrich) [0.1.9]: https://github.com/athena-framework/routing/releases/tag/v0.1.9 [#362]: https://github.com/athena-framework/athena/pull/362 [#365]: https://github.com/athena-framework/athena/pull/365 [#375]: https://github.com/athena-framework/athena/pull/375 [#381]: https://github.com/athena-framework/athena/pull/381 ## [0.1.8] - 2023-10-09 ### Added - Internal support for redirecting within an `ART::Matcher::*` ([#307]) (George Dietrich) [0.1.8]: https://github.com/athena-framework/routing/releases/tag/v0.1.8 [#307]: https://github.com/athena-framework/athena/pull/307 ## [0.1.7] - 2023-05-29 ### Changed - **Breaking:** Update minimum `crystal` version to `~> 1.8.0`. Drop support for `PCRE1`. ([#281]) (George Dietrich) [0.1.7]: https://github.com/athena-framework/routing/releases/tag/v0.1.7 [#281]: https://github.com/athena-framework/athena/pull/281 ## [0.1.6] - 2023-03-26 ### Fixed - Fix compatibility with Crystal `1.8.0-dev` ([#272]) (George Dietrich) [0.1.6]: https://github.com/athena-framework/routing/releases/tag/v0.1.6 [#272]: https://github.com/athena-framework/athena/pull/272 ## [0.1.5] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) ### Added - Add additional `ART::Requirement` constants ([#257]) (George Dietrich) ### Fixed - Fix formatting issue in Crystal `1.8-dev` ([#258]) (George Dietrich) [0.1.5]: https://github.com/athena-framework/routing/releases/tag/v0.1.5 [#257]: https://github.com/athena-framework/athena/pull/257 [#258]: https://github.com/athena-framework/athena/pull/258 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.1.4] - 2023-01-07 ### Changed - Change route compilation to be eager ([#207]) (George Dietrich) ### Added - Add ability to bubble up exceptions from `ART::RoutingHandler` ([#206]) (George Dietrich) - Add `ART::Matcher::TraceableURLMatcher` to help with debugging route matches ([#224]) (George Dietrich) - Add `ART::Route#has_scheme?` ([#224]) (George Dietrich) [0.1.4]: https://github.com/athena-framework/routing/releases/tag/v0.1.4 [#207]: https://github.com/athena-framework/athena/pull/207 [#206]: https://github.com/athena-framework/athena/pull/206 [#224]: https://github.com/athena-framework/athena/pull/224 ## [0.1.3] - 2022-09-05 ### Changed - **Breaking:** ensure parameter names defined on interfaces match the implementation ([#188]) (George Dietrich) ### Added - Add an `HTTP::Handler` to add basic routing support to a `HTTP::Server` ([#189]) (George Dietrich) ### Fixed - Fixed slash characters being double escaped in generated URL query params ([#180]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/routing/releases/tag/v0.1.3 [#180]: https://github.com/athena-framework/athena/pull/180 [#188]: https://github.com/athena-framework/athena/pull/188 [#189]: https://github.com/athena-framework/athena/pull/189 ## [0.1.2] - 2022-05-14 ### Changed - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Added - Add getting started documentation to API docs ([#172]) (George Dietrich) - Add common route requirement constants to the [ART::Requirement](https://athenaframework.org/Routing/Requirement/) namespace ([#173]) (George Dietrich) - Add [ART::Requirement::Enum](https://athenaframework.org/Routing/Requirement/Enum/) to make creating [Enum](https://crystal-lang.org/api/Enum.html) based route requirements easier ([#173]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/routing/releases/tag/v0.1.2 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 [#173]: https://github.com/athena-framework/athena/pull/173 ## [0.1.1] - 2022-02-05 _First release a part of the monorepo._ ### Fixed - Fix erroneous mutating of matched route data ([#144]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/routing/releases/tag/v0.1.1 [#144]: https://github.com/athena-framework/athena/pull/144 ## [0.1.0] - 2022-01-10 _Initial release._ [0.1.0]: https://github.com/athena-framework/routing/releases/tag/v0.1.0 ================================================ FILE: src/components/routing/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/routing/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2021 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/routing/README.md ================================================ # Routing [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/routing.svg)](https://github.com/athena-framework/routing/releases) HTTP based routing library/framework. ## Getting Started Checkout the [Documentation](https://athenaframework.org/Routing). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/routing/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.2.0 ### `params` parameter of `ART::Generator::Interface#generate` has a looser type restriction This parameter was previously typed as a `Hash(String, String?)`, but is now accepts any `Hash`. Custom URL generators will need their type restriction updated, and may need to normalize/validate the hash value itself. ### New route default/matched route parameter type Route defaults and matcher return values now use the new `ART::Parameters` type instead of `Hash(String, String?)`. The new type supports _mostly_ the same API as the old `Hash` type, but may need to update type restrictions if you were passing around the defaults/matched route parameters hash. Additionally, if implementing a custom URL matcher, update return types from `Hash(String, String?)` to `ART::Parameters`. ## Upgrade to 0.1.9 ### New `ART::Generator::Interface` method If implementing a custom URL Generator, you will now need to implement the following new method: - `abstract def generate(route : String, reference_type : ART::Generator::ReferenceType = :absolute_path, **params) : String` ================================================ FILE: src/components/routing/docs/README.md ================================================ The `Athena::Routing` component provides a performant and robust HTTP based routing library/framework. ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-routing: github: athena-framework/routing version: ~> 0.2.0 ``` ## Usage This component is primarily intended to be used as a basis for a routing implementation for a framework, handling the majority of the heavy lifting. The routing component supports various ways to control which routes are matched, including: * Regex patterns * [host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) header values * HTTP method/scheme * Request format/locale * Dynamic callbacks Using the routing component involves adding [ART::Route](/Routing/Route/) instances to an [ART::RouteCollection](/Routing/RouteCollection/). The collection is then compiled via [ART.compile](). From here, an [ART::Matcher::URLMatcherInterface](/Routing/Matcher/URLMatcherInterface/) or [ART::Matcher::RequestMatcherInterface](/Routing/Matcher/RequestMatcherInterface/) could then be used to determine which route matches a given path or [ART::Request](/Routing/Request/). ```crystal # Create a new route collection and add a route with a single parameter to it. routes = ART::RouteCollection.new routes.add "blog_show", ART::Route.new "/blog/{slug}" # Compile the routes. ART.compile routes # Represents the request in an agnostic data format. # In practice this would be created from the current `ART::Request`. context = ART::RequestContext.new # Match a request by path. matcher = ART::Matcher::URLMatcher.new context matcher.match "/blog/foo-bar" # => ART::Parameters{"_route" => "blog_show", "slug" => "foo-bar"} ``` It is also possible to go the other way, generate a URL based on its name and set of parameters: ```crystal # Generating routes based on route name and parameters is also possible. generator = ART::Generator::URLGenerator.new context generator.generate "blog_show", slug: "bar-baz", source: "Crystal" # => "/blog/bar-baz?source=Crystal" ``` ### Simple Webapp The Crystal stdlib provides [HTTP::Server](https://crystal-lang.org/api/HTTP/Server.html) as a very robust basis to a web application. However it lacks a fairly critical feature: routing. The Routing component provides [ART::RoutingHandler](/Routing/RoutingHandler/) which can be used to add basic routing functionality. This can be a good choice for super simple web applications that do not need any additional frameworky features. ```crystal handler = ART::RoutingHandler.new # The `methods` property can be used to limit the route to a particular HTTP method. handler.add "new_article", ART::Route.new("/article", methods: "post") do |ctx| pp ctx.request.body.try &.gets_to_end end # The match parameters from the route are passed to the callback as a `Hash(String, String?)`. handler.add "article", ART::Route.new("/article/{id<\\d+>}", methods: "get") do |ctx, params| pp params # => {"_route" => "article", "id" => "10"} end # Call the `#compile` method when providing the handler to the handler array. server = HTTP::Server.new([ handler.compile, ]) address = server.bind_tcp 8080 puts "Listening on http://#{address}" server.listen ``` ## Learn More * [Parameter Validation](/Routing/Route/#Athena::Routing::Route--parameter-validation) * Route [Requirement](/Routing/Requirement/) helpers * [Catch-all/Glob](/Routing/Route/#Athena::Routing::Route--slash-characters-in-route-parameters) routes ================================================ FILE: src/components/routing/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Routing site_url: https://athenaframework.org/Routing/ repo_url: https://github.com/athena-framework/routing nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-routing/src/athena-routing.cr source_locations: lib/athena-routing: https://github.com/athena-framework/routing/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/routing/shard.yml ================================================ name: athena-routing version: 0.2.0 crystal: ~> 1.8 license: MIT repository: https://github.com/athena-framework/routing documentation: https://athenaframework.org/Routing description: | HTTP based routing library/framework. authors: - George Dietrich ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection0.cr ================================================ false #### Hash(String, Array(ART::RouteProvider::StaticRouteData)).new #### Hash(Int32, Regex).new #### Hash(String, Array(ART::RouteProvider::DynamicRouteData)).new #### 0 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection1.cr ================================================ true #### { "/test/baz" => [{ART::Parameters.new({"_route" => "baz"}), nil, nil, nil, false, false, nil}], "/test/baz.html" => [{ART::Parameters.new({"_route" => "baz2"}), nil, nil, nil, false, false, nil}], "/test/baz3" => [{ART::Parameters.new({"_route" => "baz3"}), nil, nil, nil, true, false, nil}], "/foofoo" => [{ART::Parameters.new({"_route" => "foofoo", "def" => "test"}), nil, nil, nil, false, false, nil}], "/spa ce" => [{ART::Parameters.new({"_route" => "space"}), nil, nil, nil, false, false, nil}], "/multi/new" => [{ART::Parameters.new({"_route" => "overridden2"}), nil, nil, nil, false, false, nil}], "/multi/hey" => [{ART::Parameters.new({"_route" => "hey"}), nil, nil, nil, true, false, nil}], "/ababa" => [{ART::Parameters.new({"_route" => "ababa"}), nil, nil, nil, false, false, nil}], "/route1" => [{ART::Parameters.new({"_route" => "route1"}), "a.example.com", nil, nil, false, false, nil}], "/c2/route2" => [{ART::Parameters.new({"_route" => "route2"}), "a.example.com", nil, nil, false, false, nil}], "/route4" => [{ART::Parameters.new({"_route" => "route4"}), "a.example.com", nil, nil, false, false, nil}], "/c2/route3" => [{ART::Parameters.new({"_route" => "route3"}), "b.example.com", nil, nil, false, false, nil}], "/route5" => [{ART::Parameters.new({"_route" => "route5"}), "c.example.com", nil, nil, false, false, nil}], "/route6" => [{ART::Parameters.new({"_route" => "route6"}), nil, nil, nil, false, false, nil}], "/route11" => [{ART::Parameters.new({"_route" => "route11"}), /^(?P[^\.]++)\.example\.com$/i, nil, nil, false, false, nil}], "/route12" => [{ART::Parameters.new({"_route" => "route12", "var1" => "val"}), /^(?P[^\.]++)\.example\.com$/i, nil, nil, false, false, nil}], "/route17" => [{ART::Parameters.new({"_route" => "route17"}), nil, nil, nil, false, false, nil}], } #### { 0 => ART.create_regex "^(?|(?:(?:[^./]*+\\.)++)(?|/foo/(baz|athenaa)(*:47)|/bar(?|/([^/]++)(*:70)|head/([^/]++)(*:90))|/test/([^/]++)(?|(*:115))|/([']+)(*:131)|/a/(?|b\"b/([^/]++)(?|(*:160)|(*:168))|(.*)(*:181)|b\"b/([^/]++)(?|(*:204)|(*:212)))|/multi/hello(?:/([^/]++))?(*:248)|/([^/]++)/b/([^/]++)(?|(*:279)|(*:287))|/aba/([^/]++)(*:309))|(?i:([^\\.]++)\\.example\\.com)\\.(?|/route1(?|3/([^/]++)(*:371)|4/([^/]++)(*:389)))|(?i:c\\.example\\.com)\\.(?|/route15/([^/]++)(*:441))|(?:(?:[^./]*+\\.)++)(?|/route16/([^/]++)(*:489)|/a/(?|a\\.\\.\\.(*:510)|b/(?|([^/]++)(*:531)|c/([^/]++)(*:549)))))/?$", } #### { "47" => [{ART::Parameters.new({"_route" => "foo", "def" => "test"}), Set{"bar"}, nil, nil, false, true, nil}], "70" => [{ART::Parameters.new({"_route" => "bar"}), Set{"foo"}, Set{"GET", "HEAD"}, nil, false, true, nil}], "90" => [{ART::Parameters.new({"_route" => "barhead"}), Set{"foo"}, Set{"GET"}, nil, false, true, nil}], "115" => [ {ART::Parameters.new({"_route" => "baz4"}), Set{"foo"}, nil, nil, true, true, nil}, {ART::Parameters.new({"_route" => "baz5"}), Set{"foo"}, Set{"POST"}, nil, true, true, nil}, {ART::Parameters.new({"_route" => "baz.baz6"}), Set{"foo"}, Set{"PUT"}, nil, true, true, nil}, ], "131" => [{ART::Parameters.new({"_route" => "quoter"}), Set{"quoter"}, nil, nil, false, true, nil}], "160" => [{ART::Parameters.new({"_route" => "foo1"}), Set{"foo"}, Set{"PUT"}, nil, false, true, nil}], "168" => [{ART::Parameters.new({"_route" => "bar1"}), Set{"bar"}, nil, nil, false, true, nil}], "181" => [{ART::Parameters.new({"_route" => "overridden"}), Set{"var"}, nil, nil, false, true, nil}], "204" => [{ART::Parameters.new({"_route" => "foo2"}), Set{"foo1"}, nil, nil, false, true, nil}], "212" => [{ART::Parameters.new({"_route" => "bar2"}), Set{"bar1"}, nil, nil, false, true, nil}], "248" => [{ART::Parameters.new({"_route" => "helloWorld", "who" => "World!"}), Set{"who"}, nil, nil, false, true, nil}], "279" => [{ART::Parameters.new({"_route" => "foo3"}), Set{"_locale", "foo"}, nil, nil, false, true, nil}], "287" => [{ART::Parameters.new({"_route" => "bar3"}), Set{"_locale", "bar"}, nil, nil, false, true, nil}], "309" => [{ART::Parameters.new({"_route" => "foo4"}), Set{"foo"}, nil, nil, false, true, nil}], "371" => [{ART::Parameters.new({"_route" => "route13"}), Set{"name", "var1"}, nil, nil, false, true, nil}], "389" => [{ART::Parameters.new({"_route" => "route14", "var1" => "val"}), Set{"name", "var1"}, nil, nil, false, true, nil}], "441" => [{ART::Parameters.new({"_route" => "route15"}), Set{"name"}, nil, nil, false, true, nil}], "489" => [{ART::Parameters.new({"_route" => "route16", "var1" => "val"}), Set{"name"}, nil, nil, false, true, nil}], "510" => [{ART::Parameters.new({"_route" => "a"}), Set(String).new, nil, nil, false, false, nil}], "531" => [{ART::Parameters.new({"_route" => "b"}), Set{"var"}, nil, nil, false, true, nil}], "549" => [{ART::Parameters.new({"_route" => "c"}), Set{"var"}, nil, nil, false, true, nil}], } #### 0 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection10.cr ================================================ false #### Hash(String, Array(ART::RouteProvider::StaticRouteData)).new #### { 0 => ART.create_regex "^(?|/(en|fr)/(?|admin/post(?|(*:32)|/(?|new(*:46)|(\\d+)(*:58)|(\\d+)/edit(*:75)|(\\d+)/delete(*:94)))|blog(?|(*:110)|/(?|rss\\.xml(*:130)|p(?|age/([^/]++)(*:154)|osts/([^/]++)(*:175))|comments/(\\d+)/new(*:202)|search(*:216)))|log(?|in(*:234)|out(*:245)))|/(en|fr)?(*:264))/?$", } #### { "32" => [{ART::Parameters.new({"_route" => "a", "_locale" => "en"}), Set{"_locale"}, nil, nil, true, false, nil}], "46" => [{ART::Parameters.new({"_route" => "b", "_locale" => "en"}), Set{"_locale"}, nil, nil, false, false, nil}], "58" => [{ART::Parameters.new({"_route" => "c", "_locale" => "en"}), Set{"_locale", "id"}, nil, nil, false, true, nil}], "75" => [{ART::Parameters.new({"_route" => "d", "_locale" => "en"}), Set{"_locale", "id"}, nil, nil, false, false, nil}], "94" => [{ART::Parameters.new({"_route" => "e", "_locale" => "en"}), Set{"_locale", "id"}, nil, nil, false, false, nil}], "110" => [{ART::Parameters.new({"_route" => "f", "_locale" => "en"}), Set{"_locale"}, nil, nil, true, false, nil}], "130" => [{ART::Parameters.new({"_route" => "g", "_locale" => "en"}), Set{"_locale"}, nil, nil, false, false, nil}], "154" => [{ART::Parameters.new({"_route" => "h", "_locale" => "en"}), Set{"_locale", "page"}, nil, nil, false, true, nil}], "175" => [{ART::Parameters.new({"_route" => "i", "_locale" => "en"}), Set{"_locale", "page"}, nil, nil, false, true, nil}], "202" => [{ART::Parameters.new({"_route" => "j", "_locale" => "en"}), Set{"_locale", "id"}, nil, nil, false, false, nil}], "216" => [{ART::Parameters.new({"_route" => "k", "_locale" => "en"}), Set{"_locale"}, nil, nil, false, false, nil}], "234" => [{ART::Parameters.new({"_route" => "l", "_locale" => "en"}), Set{"_locale"}, nil, nil, false, false, nil}], "245" => [{ART::Parameters.new({"_route" => "m", "_locale" => "en"}), Set{"_locale"}, nil, nil, false, false, nil}], "264" => [{ART::Parameters.new({"_route" => "n", "_locale" => "en"}), Set{"_locale"}, nil, nil, false, true, nil}], } #### 0 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection11.cr ================================================ false #### Hash(String, Array(ART::RouteProvider::StaticRouteData)).new #### { 0 => ART.create_regex "^(?|/abc([^/]++)/(?|1(?|(*:27)|0(?|(*:38)|0(*:46)))|2(?|(*:59)|0(?|(*:70)|0(*:78)))))/?$", } #### { "27" => [{ART::Parameters.new({"_route" => "r1"}), Set{"foo"}, nil, nil, false, false, nil}], "38" => [{ART::Parameters.new({"_route" => "r10"}), Set{"foo"}, nil, nil, false, false, nil}], "46" => [{ART::Parameters.new({"_route" => "r100"}), Set{"foo"}, nil, nil, false, false, nil}], "59" => [{ART::Parameters.new({"_route" => "r2"}), Set{"foo"}, nil, nil, false, false, nil}], "70" => [{ART::Parameters.new({"_route" => "r20"}), Set{"foo"}, nil, nil, false, false, nil}], "78" => [{ART::Parameters.new({"_route" => "r200"}), Set{"foo"}, nil, nil, false, false, nil}], } #### 0 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection12.cr ================================================ true #### Hash(String, Array(ART::RouteProvider::StaticRouteData)).new #### { 0 => ART.create_regex "^(?|(?i:([^\\.]++)\\.example\\.com)\\.(?|/abc([^/]++)(?|(*:55))))/?$", } #### { "55" => [ {ART::Parameters.new({"_route" => "r1"}), Set{"foo", "foo"}, nil, nil, false, true, nil}, {ART::Parameters.new({"_route" => "r2"}), Set{"foo", "foo"}, nil, nil, false, true, nil}, ], } #### 0 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection2.cr ================================================ true #### { "/test/baz" => [{ART::Parameters.new({"_route" => "baz"}), nil, nil, nil, false, false, nil}], "/test/baz.html" => [{ART::Parameters.new({"_route" => "baz2"}), nil, nil, nil, false, false, nil}], "/test/baz3" => [{ART::Parameters.new({"_route" => "baz3"}), nil, nil, nil, true, false, nil}], "/foofoo" => [{ART::Parameters.new({"_route" => "foofoo", "def" => "test"}), nil, nil, nil, false, false, nil}], "/spa ce" => [{ART::Parameters.new({"_route" => "space"}), nil, nil, nil, false, false, nil}], "/multi/new" => [{ART::Parameters.new({"_route" => "overridden2"}), nil, nil, nil, false, false, nil}], "/multi/hey" => [{ART::Parameters.new({"_route" => "hey"}), nil, nil, nil, true, false, nil}], "/ababa" => [{ART::Parameters.new({"_route" => "ababa"}), nil, nil, nil, false, false, nil}], "/route1" => [{ART::Parameters.new({"_route" => "route1"}), "a.example.com", nil, nil, false, false, nil}], "/c2/route2" => [{ART::Parameters.new({"_route" => "route2"}), "a.example.com", nil, nil, false, false, nil}], "/route4" => [{ART::Parameters.new({"_route" => "route4"}), "a.example.com", nil, nil, false, false, nil}], "/c2/route3" => [{ART::Parameters.new({"_route" => "route3"}), "b.example.com", nil, nil, false, false, nil}], "/route5" => [{ART::Parameters.new({"_route" => "route5"}), "c.example.com", nil, nil, false, false, nil}], "/route6" => [{ART::Parameters.new({"_route" => "route6"}), nil, nil, nil, false, false, nil}], "/route11" => [{ART::Parameters.new({"_route" => "route11"}), /^(?P[^\.]++)\.example\.com$/i, nil, nil, false, false, nil}], "/route12" => [{ART::Parameters.new({"_route" => "route12", "var1" => "val"}), /^(?P[^\.]++)\.example\.com$/i, nil, nil, false, false, nil}], "/route17" => [{ART::Parameters.new({"_route" => "route17"}), nil, nil, nil, false, false, nil}], "/secure" => [{ART::Parameters.new({"_route" => "secure"}), nil, nil, Set{"https"}, false, false, nil}], "/nonsecure" => [{ART::Parameters.new({"_route" => "nonsecure"}), nil, nil, Set{"http"}, false, false, nil}], } #### { 0 => ART.create_regex "^(?|(?:(?:[^./]*+\\.)++)(?|/foo/(baz|athenaa)(*:47)|/bar(?|/([^/]++)(*:70)|head/([^/]++)(*:90))|/test/([^/]++)(?|(*:115))|/([']+)(*:131)|/a/(?|b\"b/([^/]++)(?|(*:160)|(*:168))|(.*)(*:181)|b\"b/([^/]++)(?|(*:204)|(*:212)))|/multi/hello(?:/([^/]++))?(*:248)|/([^/]++)/b/([^/]++)(?|(*:279)|(*:287))|/aba/([^/]++)(*:309))|(?i:([^\\.]++)\\.example\\.com)\\.(?|/route1(?|3/([^/]++)(*:371)|4/([^/]++)(*:389)))|(?i:c\\.example\\.com)\\.(?|/route15/([^/]++)(*:441))|(?:(?:[^./]*+\\.)++)(?|/route16/([^/]++)(*:489)|/a/(?|a\\.\\.\\.(*:510)|b/(?|([^/]++)(*:531)|c/([^/]++)(*:549)))))/?$", } #### { "47" => [{ART::Parameters.new({"_route" => "foo", "def" => "test"}), Set{"bar"}, nil, nil, false, true, nil}], "70" => [{ART::Parameters.new({"_route" => "bar"}), Set{"foo"}, Set{"GET", "HEAD"}, nil, false, true, nil}], "90" => [{ART::Parameters.new({"_route" => "barhead"}), Set{"foo"}, Set{"GET"}, nil, false, true, nil}], "115" => [ {ART::Parameters.new({"_route" => "baz4"}), Set{"foo"}, nil, nil, true, true, nil}, {ART::Parameters.new({"_route" => "baz5"}), Set{"foo"}, Set{"POST"}, nil, true, true, nil}, {ART::Parameters.new({"_route" => "baz.baz6"}), Set{"foo"}, Set{"PUT"}, nil, true, true, nil}, ], "131" => [{ART::Parameters.new({"_route" => "quoter"}), Set{"quoter"}, nil, nil, false, true, nil}], "160" => [{ART::Parameters.new({"_route" => "foo1"}), Set{"foo"}, Set{"PUT"}, nil, false, true, nil}], "168" => [{ART::Parameters.new({"_route" => "bar1"}), Set{"bar"}, nil, nil, false, true, nil}], "181" => [{ART::Parameters.new({"_route" => "overridden"}), Set{"var"}, nil, nil, false, true, nil}], "204" => [{ART::Parameters.new({"_route" => "foo2"}), Set{"foo1"}, nil, nil, false, true, nil}], "212" => [{ART::Parameters.new({"_route" => "bar2"}), Set{"bar1"}, nil, nil, false, true, nil}], "248" => [{ART::Parameters.new({"_route" => "helloWorld", "who" => "World!"}), Set{"who"}, nil, nil, false, true, nil}], "279" => [{ART::Parameters.new({"_route" => "foo3"}), Set{"_locale", "foo"}, nil, nil, false, true, nil}], "287" => [{ART::Parameters.new({"_route" => "bar3"}), Set{"_locale", "bar"}, nil, nil, false, true, nil}], "309" => [{ART::Parameters.new({"_route" => "foo4"}), Set{"foo"}, nil, nil, false, true, nil}], "371" => [{ART::Parameters.new({"_route" => "route13"}), Set{"name", "var1"}, nil, nil, false, true, nil}], "389" => [{ART::Parameters.new({"_route" => "route14", "var1" => "val"}), Set{"name", "var1"}, nil, nil, false, true, nil}], "441" => [{ART::Parameters.new({"_route" => "route15"}), Set{"name"}, nil, nil, false, true, nil}], "489" => [{ART::Parameters.new({"_route" => "route16", "var1" => "val"}), Set{"name"}, nil, nil, false, true, nil}], "510" => [{ART::Parameters.new({"_route" => "a"}), Set(String).new, nil, nil, false, false, nil}], "531" => [{ART::Parameters.new({"_route" => "b"}), Set{"var"}, nil, nil, false, true, nil}], "549" => [{ART::Parameters.new({"_route" => "c"}), Set{"var"}, nil, nil, false, true, nil}], } #### 0 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection3.cr ================================================ false #### { "/rootprefix/test" => [{ART::Parameters.new({"_route" => "static"}), nil, nil, nil, false, false, nil}], "/with-condition" => [{ART::Parameters.new({"_route" => "with-condition"}), nil, nil, nil, false, false, 0}], } #### { 0 => ART.create_regex "^(?|/rootprefix/([^/]++)(*:27))/?$", } #### { "27" => [{ART::Parameters.new({"_route" => "dynamic"}), Set{"var"}, nil, nil, false, true, nil}], } #### 1 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection4.cr ================================================ false #### { "/just_head" => [{ART::Parameters.new({"_route" => "just_head"}), nil, Set{"HEAD"}, nil, false, false, nil}], "/head_and_get" => [{ART::Parameters.new({"_route" => "head_and_get"}), nil, Set{"HEAD", "GET"}, nil, false, false, nil}], "/get_and_head" => [{ART::Parameters.new({"_route" => "get_and_head"}), nil, Set{"GET", "HEAD"}, nil, false, false, nil}], "/post_and_head" => [{ART::Parameters.new({"_route" => "post_and_head"}), nil, Set{"POST", "HEAD"}, nil, false, false, nil}], "/put_and_post" => [ {ART::Parameters.new({"_route" => "put_and_post"}), nil, Set{"PUT", "POST"}, nil, false, false, nil}, {ART::Parameters.new({"_route" => "put_and_get_and_head"}), nil, Set{"PUT", "GET", "HEAD"}, nil, false, false, nil}, ], } #### Hash(Int32, Regex).new #### Hash(String, Array(ART::RouteProvider::DynamicRouteData)).new #### 0 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection5.cr ================================================ false #### { "/a/11" => [{ART::Parameters.new({"_route" => "a_first"}), nil, nil, nil, false, false, nil}], "/a/22" => [{ART::Parameters.new({"_route" => "a_second"}), nil, nil, nil, false, false, nil}], "/a/33" => [{ART::Parameters.new({"_route" => "a_third"}), nil, nil, nil, false, false, nil}], "/a/44" => [{ART::Parameters.new({"_route" => "a_fourth"}), nil, nil, nil, true, false, nil}], "/a/55" => [{ART::Parameters.new({"_route" => "a_fifth"}), nil, nil, nil, true, false, nil}], "/a/66" => [{ART::Parameters.new({"_route" => "a_sixth"}), nil, nil, nil, true, false, nil}], "/nested/group/a" => [{ART::Parameters.new({"_route" => "nested_a"}), nil, nil, nil, true, false, nil}], "/nested/group/b" => [{ART::Parameters.new({"_route" => "nested_b"}), nil, nil, nil, true, false, nil}], "/nested/group/c" => [{ART::Parameters.new({"_route" => "nested_c"}), nil, nil, nil, true, false, nil}], "/slashed/group" => [{ART::Parameters.new({"_route" => "slashed_a"}), nil, nil, nil, true, false, nil}], "/slashed/group/b" => [{ART::Parameters.new({"_route" => "slashed_b"}), nil, nil, nil, true, false, nil}], "/slashed/group/c" => [{ART::Parameters.new({"_route" => "slashed_c"}), nil, nil, nil, true, false, nil}], } #### { 0 => ART.create_regex("^(?|/([^/]++)(*:16)|/nested/([^/]++)(*:39))/?$"), } #### { "16" => [{ART::Parameters.new({"_route" => "a_wildcard"}), Set{"param"}, nil, nil, false, true, nil}], "39" => [{ART::Parameters.new({"_route" => "nested_wildcard"}), Set{"param"}, nil, nil, false, true, nil}], } #### 0 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection6.cr ================================================ false #### { "/trailing/simple/no-methods" => [{ART::Parameters.new({"_route" => "simple_trailing_slash_no_methods"}), nil, nil, nil, true, false, nil}], "/trailing/simple/get-method" => [{ART::Parameters.new({"_route" => "simple_trailing_slash_GET_method"}), nil, Set{"GET"}, nil, true, false, nil}], "/trailing/simple/head-method" => [{ART::Parameters.new({"_route" => "simple_trailing_slash_HEAD_method"}), nil, Set{"HEAD"}, nil, true, false, nil}], "/trailing/simple/post-method" => [{ART::Parameters.new({"_route" => "simple_trailing_slash_POST_method"}), nil, Set{"POST"}, nil, true, false, nil}], "/not-trailing/simple/no-methods" => [{ART::Parameters.new({"_route" => "simple_not_trailing_slash_no_methods"}), nil, nil, nil, false, false, nil}], "/not-trailing/simple/get-method" => [{ART::Parameters.new({"_route" => "simple_not_trailing_slash_GET_method"}), nil, Set{"GET"}, nil, false, false, nil}], "/not-trailing/simple/head-method" => [{ART::Parameters.new({"_route" => "simple_not_trailing_slash_HEAD_method"}), nil, Set{"HEAD"}, nil, false, false, nil}], "/not-trailing/simple/post-method" => [{ART::Parameters.new({"_route" => "simple_not_trailing_slash_POST_method"}), nil, Set{"POST"}, nil, false, false, nil}], } #### { 0 => ART.create_regex "^(?|/trailing/regex/(?|no\\-methods/([^/]++)(*:46)|get\\-method/([^/]++)(*:73)|head\\-method/([^/]++)(*:101)|post\\-method/([^/]++)(*:130))|/not\\-trailing/regex/(?|no\\-methods/([^/]++)(*:183)|get\\-method/([^/]++)(*:211)|head\\-method/([^/]++)(*:240)|post\\-method/([^/]++)(*:269)))/?$", } #### { "46" => [{ART::Parameters.new({"_route" => "regex_trailing_slash_no_methods"}), Set{"param"}, nil, nil, true, true, nil}], "73" => [{ART::Parameters.new({"_route" => "regex_trailing_slash_GET_method"}), Set{"param"}, Set{"GET"}, nil, true, true, nil}], "101" => [{ART::Parameters.new({"_route" => "regex_trailing_slash_HEAD_method"}), Set{"param"}, Set{"HEAD"}, nil, true, true, nil}], "130" => [{ART::Parameters.new({"_route" => "regex_trailing_slash_POST_method"}), Set{"param"}, Set{"POST"}, nil, true, true, nil}], "183" => [{ART::Parameters.new({"_route" => "regex_not_trailing_slash_no_methods"}), Set{"param"}, nil, nil, false, true, nil}], "211" => [{ART::Parameters.new({"_route" => "regex_not_trailing_slash_GET_method"}), Set{"param"}, Set{"GET"}, nil, false, true, nil}], "240" => [{ART::Parameters.new({"_route" => "regex_not_trailing_slash_HEAD_method"}), Set{"param"}, Set{"HEAD"}, nil, false, true, nil}], "269" => [{ART::Parameters.new({"_route" => "regex_not_trailing_slash_POST_method"}), Set{"param"}, Set{"POST"}, nil, false, true, nil}], } #### 0 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection7.cr ================================================ false #### { "/trailing/simple/no-methods" => [{ART::Parameters.new({"_route" => "simple_trailing_slash_no_methods"}), nil, nil, nil, true, false, nil}], "/trailing/simple/get-method" => [{ART::Parameters.new({"_route" => "simple_trailing_slash_GET_method"}), nil, Set{"GET"}, nil, true, false, nil}], "/trailing/simple/head-method" => [{ART::Parameters.new({"_route" => "simple_trailing_slash_HEAD_method"}), nil, Set{"HEAD"}, nil, true, false, nil}], "/trailing/simple/post-method" => [{ART::Parameters.new({"_route" => "simple_trailing_slash_POST_method"}), nil, Set{"POST"}, nil, true, false, nil}], "/not-trailing/simple/no-methods" => [{ART::Parameters.new({"_route" => "simple_not_trailing_slash_no_methods"}), nil, nil, nil, false, false, nil}], "/not-trailing/simple/get-method" => [{ART::Parameters.new({"_route" => "simple_not_trailing_slash_GET_method"}), nil, Set{"GET"}, nil, false, false, nil}], "/not-trailing/simple/head-method" => [{ART::Parameters.new({"_route" => "simple_not_trailing_slash_HEAD_method"}), nil, Set{"HEAD"}, nil, false, false, nil}], "/not-trailing/simple/post-method" => [{ART::Parameters.new({"_route" => "simple_not_trailing_slash_POST_method"}), nil, Set{"POST"}, nil, false, false, nil}], } #### { 0 => ART.create_regex "^(?|/trailing/regex/(?|no\\-methods/([^/]++)(*:46)|get\\-method/([^/]++)(*:73)|head\\-method/([^/]++)(*:101)|post\\-method/([^/]++)(*:130))|/not\\-trailing/regex/(?|no\\-methods/([^/]++)(*:183)|get\\-method/([^/]++)(*:211)|head\\-method/([^/]++)(*:240)|post\\-method/([^/]++)(*:269)))/?$", } #### { "46" => [{ART::Parameters.new({"_route" => "regex_trailing_slash_no_methods"}), Set{"param"}, nil, nil, true, true, nil}], "73" => [{ART::Parameters.new({"_route" => "regex_trailing_slash_GET_method"}), Set{"param"}, Set{"GET"}, nil, true, true, nil}], "101" => [{ART::Parameters.new({"_route" => "regex_trailing_slash_HEAD_method"}), Set{"param"}, Set{"HEAD"}, nil, true, true, nil}], "130" => [{ART::Parameters.new({"_route" => "regex_trailing_slash_POST_method"}), Set{"param"}, Set{"POST"}, nil, true, true, nil}], "183" => [{ART::Parameters.new({"_route" => "regex_not_trailing_slash_no_methods"}), Set{"param"}, nil, nil, false, true, nil}], "211" => [{ART::Parameters.new({"_route" => "regex_not_trailing_slash_GET_method"}), Set{"param"}, Set{"GET"}, nil, false, true, nil}], "240" => [{ART::Parameters.new({"_route" => "regex_not_trailing_slash_HEAD_method"}), Set{"param"}, Set{"HEAD"}, nil, false, true, nil}], "269" => [{ART::Parameters.new({"_route" => "regex_not_trailing_slash_POST_method"}), Set{"param"}, Set{"POST"}, nil, false, true, nil}], } #### 0 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection8.cr ================================================ true #### { "/" => [ {ART::Parameters.new({"_route" => "a"}), /^(?P[^\.]++)\.e\.c\.b\.a$/i, nil, nil, false, false, nil}, {ART::Parameters.new({"_route" => "c"}), /^(?P[^\.]++)\.e\.c\.b\.a$/i, nil, nil, false, false, nil}, {ART::Parameters.new({"_route" => "b"}), "d.c.b.a", nil, nil, false, false, nil}, ], } #### Hash(Int32, Regex).new #### Hash(String, Array(ART::RouteProvider::DynamicRouteData)).new #### 0 ================================================ FILE: src/components/routing/spec/fixtures/route_provider/route_collection9.cr ================================================ false #### Hash(String, Array(ART::RouteProvider::StaticRouteData)).new #### { 0 => ART.create_regex("^(?|/c(?|f(?|cd20/([^/]++)/([^/]++)/([^/]++)/cfcd20(*:54)|e(?|cdb/([^/]++)/([^/]++)/([^/]++)/cfecdb(*:102)|e39/([^/]++)/([^/]++)/([^/]++)/cfee39(*:147))|a086/([^/]++)/([^/]++)/([^/]++)/cfa086(*:194)|004f/([^/]++)/([^/]++)/([^/]++)/cf004f(*:240))|4(?|ca42/([^/]++)/([^/]++)/([^/]++)/c4ca42(*:291)|5147/([^/]++)/([^/]++)/([^/]++)/c45147(*:337)|1000/([^/]++)/([^/]++)/([^/]++)/c41000(*:383))|8(?|1e72/([^/]++)/([^/]++)/([^/]++)/c81e72(*:434)|ffe9/([^/]++)/([^/]++)/([^/]++)/c8ffe9(*:480)|6a7e/([^/]++)/([^/]++)/([^/]++)/c86a7e(*:526))|9(?|f0f8/([^/]++)/([^/]++)/([^/]++)/c9f0f8(*:577)|e107/([^/]++)/([^/]++)/([^/]++)/c9e107(*:623))|2(?|0(?|ad4/([^/]++)/([^/]++)/([^/]++)/c20ad4(*:677)|3d8/([^/]++)/([^/]++)/([^/]++)/c203d8(*:722))|4cd7/([^/]++)/([^/]++)/([^/]++)/c24cd7(*:769))|5(?|1ce4/([^/]++)/([^/]++)/([^/]++)/c51ce4(*:820)|2f1b/([^/]++)/([^/]++)/([^/]++)/c52f1b(*:866)|ff25/([^/]++)/([^/]++)/([^/]++)/c5ff25(*:912))|7(?|4d97/([^/]++)/([^/]++)/([^/]++)/c74d97(*:963)|e124/([^/]++)/([^/]++)/([^/]++)/c7e124(*:1009))|16a53/([^/]++)/([^/]++)/([^/]++)/c16a53(*:1058)|0(?|c7c7/([^/]++)/([^/]++)/([^/]++)/c0c7c7(*:1109)|e190/([^/]++)/([^/]++)/([^/]++)/c0e190(*:1156)|42f4/([^/]++)/([^/]++)/([^/]++)/c042f4(*:1203)|58f5/([^/]++)/([^/]++)/([^/]++)/c058f5(*:1250))|e(?|debb/([^/]++)/([^/]++)/([^/]++)/cedebb(*:1302)|e631/([^/]++)/([^/]++)/([^/]++)/cee631(*:1349))|a(?|46c1/([^/]++)/([^/]++)/([^/]++)/ca46c1(*:1401)|f1a3/([^/]++)/([^/]++)/([^/]++)/caf1a3(*:1448))|b70ab/([^/]++)/([^/]++)/([^/]++)/cb70ab(*:1497)|d0069/([^/]++)/([^/]++)/([^/]++)/cd0069(*:1545)|3(?|e878/([^/]++)/([^/]++)/([^/]++)/c3e878(*:1596)|c59e/([^/]++)/([^/]++)/([^/]++)/c3c59e(*:1643)))|/e(?|c(?|cbc8/([^/]++)/([^/]++)/([^/]++)/eccbc8(*:1701)|8(?|956/([^/]++)/([^/]++)/([^/]++)/ec8956(*:1751)|ce6/([^/]++)/([^/]++)/([^/]++)/ec8ce6(*:1797))|5dec/([^/]++)/([^/]++)/([^/]++)/ec5dec(*:1845))|4(?|da3b/([^/]++)/([^/]++)/([^/]++)/e4da3b(*:1897)|a622/([^/]++)/([^/]++)/([^/]++)/e4a622(*:1944)|6de7/([^/]++)/([^/]++)/([^/]++)/e46de7(*:1991)|4fea/([^/]++)/([^/]++)/([^/]++)/e44fea(*:2038))|3(?|6985/([^/]++)/([^/]++)/([^/]++)/e36985(*:2090)|796a/([^/]++)/([^/]++)/([^/]++)/e3796a(*:2137))|a(?|5d2f/([^/]++)/([^/]++)/([^/]++)/ea5d2f(*:2189)|e27d/([^/]++)/([^/]++)/([^/]++)/eae27d(*:2236))|2(?|c(?|420/([^/]++)/([^/]++)/([^/]++)/e2c420(*:2291)|0be/([^/]++)/([^/]++)/([^/]++)/e2c0be(*:2337))|ef52/([^/]++)/([^/]++)/([^/]++)/e2ef52(*:2385))|d(?|3d2c/([^/]++)/([^/]++)/([^/]++)/ed3d2c(*:2437)|a80a/([^/]++)/([^/]++)/([^/]++)/eda80a(*:2484)|dea8/([^/]++)/([^/]++)/([^/]++)/eddea8(*:2531))|b(?|16(?|0d/([^/]++)/([^/]++)/([^/]++)/eb160d(*:2586)|37/([^/]++)/([^/]++)/([^/]++)/eb1637(*:2631))|a0dc/([^/]++)/([^/]++)/([^/]++)/eba0dc(*:2679))|0(?|0da0/([^/]++)/([^/]++)/([^/]++)/e00da0(*:2731)|c641/([^/]++)/([^/]++)/([^/]++)/e0c641(*:2778))|e(?|cca5/([^/]++)/([^/]++)/([^/]++)/eecca5(*:2830)|d5af/([^/]++)/([^/]++)/([^/]++)/eed5af(*:2877))|96ed4/([^/]++)/([^/]++)/([^/]++)/e96ed4(*:2926)|1(?|6542/([^/]++)/([^/]++)/([^/]++)/e16542(*:2977)|e32e/([^/]++)/([^/]++)/([^/]++)/e1e32e(*:3024))|56954/([^/]++)/([^/]++)/([^/]++)/e56954(*:3073)|f(?|0d39/([^/]++)/([^/]++)/([^/]++)/ef0d39(*:3124)|e937/([^/]++)/([^/]++)/([^/]++)/efe937(*:3171)|575e/([^/]++)/([^/]++)/([^/]++)/ef575e(*:3218))|7b24b/([^/]++)/([^/]++)/([^/]++)/e7b24b(*:3267)|836d8/([^/]++)/([^/]++)/([^/]++)/e836d8(*:3315))|/a(?|8(?|7ff6/([^/]++)/([^/]++)/([^/]++)/a87ff6(*:3372)|baa5/([^/]++)/([^/]++)/([^/]++)/a8baa5(*:3419)|f15e/([^/]++)/([^/]++)/([^/]++)/a8f15e(*:3466)|c88a/([^/]++)/([^/]++)/([^/]++)/a8c88a(*:3513)|abb4/([^/]++)/([^/]++)/([^/]++)/a8abb4(*:3560))|a(?|b323/([^/]++)/([^/]++)/([^/]++)/aab323(*:3612)|942a/([^/]++)/([^/]++)/([^/]++)/aa942a(*:3659))|5(?|bfc9/([^/]++)/([^/]++)/([^/]++)/a5bfc9(*:3711)|771b/([^/]++)/([^/]++)/([^/]++)/a5771b(*:3758)|e001/([^/]++)/([^/]++)/([^/]++)/a5e001(*:3805)|97e5/([^/]++)/([^/]++)/([^/]++)/a597e5(*:3852)|16a8/([^/]++)/([^/]++)/([^/]++)/a516a8(*:3899))|1d0c6/([^/]++)/([^/]++)/([^/]++)/a1d0c6(*:3948)|6(?|84ec/([^/]++)/([^/]++)/([^/]++)/a684ec(*:3999)|6658/([^/]++)/([^/]++)/([^/]++)/a66658(*:4046))|3(?|f390/([^/]++)/([^/]++)/([^/]++)/a3f390(*:4098)|c65c/([^/]++)/([^/]++)/([^/]++)/a3c65c(*:4145))|d(?|61ab/([^/]++)/([^/]++)/([^/]++)/ad61ab(*:4197)|13a2/([^/]++)/([^/]++)/([^/]++)/ad13a2(*:4244)|972f/([^/]++)/([^/]++)/([^/]++)/ad972f(*:4291))|c(?|627a/([^/]++)/([^/]++)/([^/]++)/ac627a(*:4343)|1dd2/([^/]++)/([^/]++)/([^/]++)/ac1dd2(*:4390))|9(?|7da6/([^/]++)/([^/]++)/([^/]++)/a97da6(*:4442)|6b65/([^/]++)/([^/]++)/([^/]++)/a96b65(*:4489))|0(?|a080/([^/]++)/([^/]++)/([^/]++)/a0a080(*:4541)|2ffd/([^/]++)/([^/]++)/([^/]++)/a02ffd(*:4588)|1a03/([^/]++)/([^/]++)/([^/]++)/a01a03(*:4635))|4(?|a042/([^/]++)/([^/]++)/([^/]++)/a4a042(*:4687)|f236/([^/]++)/([^/]++)/([^/]++)/a4f236(*:4734)|9e94/([^/]++)/([^/]++)/([^/]++)/a49e94(*:4781))|2557a/([^/]++)/([^/]++)/([^/]++)/a2557a(*:4830)|b817c/([^/]++)/([^/]++)/([^/]++)/ab817c(*:4878))|/1(?|6(?|7909/([^/]++)/([^/]++)/([^/]++)/167909(*:4935)|a5cd/([^/]++)/([^/]++)/([^/]++)/16a5cd(*:4982)|51cf/([^/]++)/([^/]++)/([^/]++)/1651cf(*:5029))|f(?|0e3d/([^/]++)/([^/]++)/([^/]++)/1f0e3d(*:5081)|f(?|1de/([^/]++)/([^/]++)/([^/]++)/1ff1de(*:5131)|8a7/([^/]++)/([^/]++)/([^/]++)/1ff8a7(*:5177)))|8(?|2be0/([^/]++)/([^/]++)/([^/]++)/182be0(*:5230)|d804/([^/]++)/([^/]++)/([^/]++)/18d804(*:5277)|9977/([^/]++)/([^/]++)/([^/]++)/189977(*:5324))|c(?|383c/([^/]++)/([^/]++)/([^/]++)/1c383c(*:5376)|9ac0/([^/]++)/([^/]++)/([^/]++)/1c9ac0(*:5423))|9(?|ca14/([^/]++)/([^/]++)/([^/]++)/19ca14(*:5475)|f3cd/([^/]++)/([^/]++)/([^/]++)/19f3cd(*:5522))|7(?|e621/([^/]++)/([^/]++)/([^/]++)/17e621(*:5574)|0000/([^/]++)/([^/]++)/([^/]++)/170000(*:5621)|d63b/([^/]++)/([^/]++)/([^/]++)/17d63b(*:5668))|4(?|bfa6/([^/]++)/([^/]++)/([^/]++)/14bfa6(*:5720)|0f69/([^/]++)/([^/]++)/([^/]++)/140f69(*:5767)|9e96/([^/]++)/([^/]++)/([^/]++)/149e96(*:5814)|2949/([^/]++)/([^/]++)/([^/]++)/142949(*:5861))|a(?|fa34/([^/]++)/([^/]++)/([^/]++)/1afa34(*:5913)|5b1e/([^/]++)/([^/]++)/([^/]++)/1a5b1e(*:5960))|3(?|8(?|597/([^/]++)/([^/]++)/([^/]++)/138597(*:6015)|bb0/([^/]++)/([^/]++)/([^/]++)/138bb0(*:6061))|f(?|e9d/([^/]++)/([^/]++)/([^/]++)/13fe9d(*:6112)|989/([^/]++)/([^/]++)/([^/]++)/13f989(*:6158)|3cf/([^/]++)/([^/]++)/([^/]++)/13f3cf(*:6204)))|d7f7a/([^/]++)/([^/]++)/([^/]++)/1d7f7a(*:6254)|5(?|34b7/([^/]++)/([^/]++)/([^/]++)/1534b7(*:6305)|8f30/([^/]++)/([^/]++)/([^/]++)/158f30(*:6352)|4384/([^/]++)/([^/]++)/([^/]++)/154384(*:6399)|d4e8/([^/]++)/([^/]++)/([^/]++)/15d4e8(*:6446))|1(?|5f89/([^/]++)/([^/]++)/([^/]++)/115f89(*:6498)|b984/([^/]++)/([^/]++)/([^/]++)/11b984(*:6545))|068c6/([^/]++)/([^/]++)/([^/]++)/1068c6(*:6594)|be3bc/([^/]++)/([^/]++)/([^/]++)/1be3bc(*:6642))|/8(?|f(?|1(?|4e4/([^/]++)/([^/]++)/([^/]++)/8f14e4(*:6702)|21c/([^/]++)/([^/]++)/([^/]++)/8f121c(*:6748))|8551/([^/]++)/([^/]++)/([^/]++)/8f8551(*:6796)|5329/([^/]++)/([^/]++)/([^/]++)/8f5329(*:6843)|e009/([^/]++)/([^/]++)/([^/]++)/8fe009(*:6890))|e(?|296a/([^/]++)/([^/]++)/([^/]++)/8e296a(*:6942)|98d8/([^/]++)/([^/]++)/([^/]++)/8e98d8(*:6989)|fb10/([^/]++)/([^/]++)/([^/]++)/8efb10(*:7036)|6b42/([^/]++)/([^/]++)/([^/]++)/8e6b42(*:7083))|61398/([^/]++)/([^/]++)/([^/]++)/861398(*:7132)|1(?|2b4b/([^/]++)/([^/]++)/([^/]++)/812b4b(*:7183)|9f46/([^/]++)/([^/]++)/([^/]++)/819f46(*:7230)|6b11/([^/]++)/([^/]++)/([^/]++)/816b11(*:7277))|d(?|5e95/([^/]++)/([^/]++)/([^/]++)/8d5e95(*:7329)|3bba/([^/]++)/([^/]++)/([^/]++)/8d3bba(*:7376)|d48d/([^/]++)/([^/]++)/([^/]++)/8dd48d(*:7423)|7d8e/([^/]++)/([^/]++)/([^/]++)/8d7d8e(*:7470))|2(?|aa4b/([^/]++)/([^/]++)/([^/]++)/82aa4b(*:7522)|1(?|612/([^/]++)/([^/]++)/([^/]++)/821612(*:7572)|fa7/([^/]++)/([^/]++)/([^/]++)/821fa7(*:7618))|cec9/([^/]++)/([^/]++)/([^/]++)/82cec9(*:7666))|5(?|d8ce/([^/]++)/([^/]++)/([^/]++)/85d8ce(*:7718)|4d(?|6f/([^/]++)/([^/]++)/([^/]++)/854d6f(*:7768)|9f/([^/]++)/([^/]++)/([^/]++)/854d9f(*:7813)))|4d9ee/([^/]++)/([^/]++)/([^/]++)/84d9ee(*:7863)|c(?|19f5/([^/]++)/([^/]++)/([^/]++)/8c19f5(*:7914)|b22b/([^/]++)/([^/]++)/([^/]++)/8cb22b(*:7961))|39ab4/([^/]++)/([^/]++)/([^/]++)/839ab4(*:8010)|9f0fd/([^/]++)/([^/]++)/([^/]++)/89f0fd(*:8058)|bf121/([^/]++)/([^/]++)/([^/]++)/8bf121(*:8106)|77a9b/([^/]++)/([^/]++)/([^/]++)/877a9b(*:8154))|/4(?|5(?|c48c/([^/]++)/([^/]++)/([^/]++)/45c48c(*:8211)|fbc6/([^/]++)/([^/]++)/([^/]++)/45fbc6(*:8258))|e732c/([^/]++)/([^/]++)/([^/]++)/4e732c(*:8307)|4f683/([^/]++)/([^/]++)/([^/]++)/44f683(*:8355)|3(?|ec51/([^/]++)/([^/]++)/([^/]++)/43ec51(*:8406)|2aca/([^/]++)/([^/]++)/([^/]++)/432aca(*:8453))|c5(?|6ff/([^/]++)/([^/]++)/([^/]++)/4c56ff(*:8505)|bde/([^/]++)/([^/]++)/([^/]++)/4c5bde(*:8551))|2(?|a0e1/([^/]++)/([^/]++)/([^/]++)/42a0e1(*:8603)|e7aa/([^/]++)/([^/]++)/([^/]++)/42e7aa(*:8650)|998c/([^/]++)/([^/]++)/([^/]++)/42998c(*:8697)|8fca/([^/]++)/([^/]++)/([^/]++)/428fca(*:8744))|7(?|d1e9/([^/]++)/([^/]++)/([^/]++)/47d1e9(*:8796)|34ba/([^/]++)/([^/]++)/([^/]++)/4734ba(*:8843))|6ba9f/([^/]++)/([^/]++)/([^/]++)/46ba9f(*:8892)|8aedb/([^/]++)/([^/]++)/([^/]++)/48aedb(*:8940)|9(?|182f/([^/]++)/([^/]++)/([^/]++)/49182f(*:8991)|6e05/([^/]++)/([^/]++)/([^/]++)/496e05(*:9038)|ae49/([^/]++)/([^/]++)/([^/]++)/49ae49(*:9085))|0008b/([^/]++)/([^/]++)/([^/]++)/40008b(*:9134)|1(?|f1f1/([^/]++)/([^/]++)/([^/]++)/41f1f1(*:9185)|ae36/([^/]++)/([^/]++)/([^/]++)/41ae36(*:9232))|f(?|6ffe/([^/]++)/([^/]++)/([^/]++)/4f6ffe(*:9284)|4adc/([^/]++)/([^/]++)/([^/]++)/4f4adc(*:9331)))|/d(?|3(?|d944/([^/]++)/([^/]++)/([^/]++)/d3d944(*:9389)|9577/([^/]++)/([^/]++)/([^/]++)/d39577(*:9436)|4ab1/([^/]++)/([^/]++)/([^/]++)/d34ab1(*:9483))|6(?|7d8a/([^/]++)/([^/]++)/([^/]++)/d67d8a(*:9535)|4592/([^/]++)/([^/]++)/([^/]++)/d64592(*:9582)|baf6/([^/]++)/([^/]++)/([^/]++)/d6baf6(*:9629)|1e4b/([^/]++)/([^/]++)/([^/]++)/d61e4b(*:9676))|9(?|d4f4/([^/]++)/([^/]++)/([^/]++)/d9d4f4(*:9728)|6409/([^/]++)/([^/]++)/([^/]++)/d96409(*:9775)|47bf/([^/]++)/([^/]++)/([^/]++)/d947bf(*:9822)|fc5b/([^/]++)/([^/]++)/([^/]++)/d9fc5b(*:9869))|8(?|2c8d/([^/]++)/([^/]++)/([^/]++)/d82c8d(*:9921)|1f9c/([^/]++)/([^/]++)/([^/]++)/d81f9c(*:9968))|2(?|ddea/([^/]++)/([^/]++)/([^/]++)/d2ddea(*:10020)|96c1/([^/]++)/([^/]++)/([^/]++)/d296c1(*:10068))|0(?|9bf4/([^/]++)/([^/]++)/([^/]++)/d09bf4(*:10121)|7e70/([^/]++)/([^/]++)/([^/]++)/d07e70(*:10169))|1(?|f(?|e17/([^/]++)/([^/]++)/([^/]++)/d1fe17(*:10225)|491/([^/]++)/([^/]++)/([^/]++)/d1f491(*:10272)|255/([^/]++)/([^/]++)/([^/]++)/d1f255(*:10319))|c38a/([^/]++)/([^/]++)/([^/]++)/d1c38a(*:10368)|8f65/([^/]++)/([^/]++)/([^/]++)/d18f65(*:10416))|a4fb5/([^/]++)/([^/]++)/([^/]++)/da4fb5(*:10466)|b8e1a/([^/]++)/([^/]++)/([^/]++)/db8e1a(*:10515)|709f3/([^/]++)/([^/]++)/([^/]++)/d709f3(*:10564)|c(?|912a/([^/]++)/([^/]++)/([^/]++)/dc912a(*:10616)|6a64/([^/]++)/([^/]++)/([^/]++)/dc6a64(*:10664))|db306/([^/]++)/([^/]++)/([^/]++)/ddb306(*:10714))|/6(?|5(?|12bd/([^/]++)/([^/]++)/([^/]++)/6512bd(*:10772)|b9ee/([^/]++)/([^/]++)/([^/]++)/65b9ee(*:10820)|ded5/([^/]++)/([^/]++)/([^/]++)/65ded5(*:10868))|f(?|4922/([^/]++)/([^/]++)/([^/]++)/6f4922(*:10921)|3ef7/([^/]++)/([^/]++)/([^/]++)/6f3ef7(*:10969)|aa80/([^/]++)/([^/]++)/([^/]++)/6faa80(*:11017))|e(?|a(?|9ab/([^/]++)/([^/]++)/([^/]++)/6ea9ab(*:11073)|2ef/([^/]++)/([^/]++)/([^/]++)/6ea2ef(*:11120))|cbdd/([^/]++)/([^/]++)/([^/]++)/6ecbdd(*:11169))|3(?|64d3/([^/]++)/([^/]++)/([^/]++)/6364d3(*:11222)|dc7e/([^/]++)/([^/]++)/([^/]++)/63dc7e(*:11270)|923f/([^/]++)/([^/]++)/([^/]++)/63923f(*:11318))|c(?|8349/([^/]++)/([^/]++)/([^/]++)/6c8349(*:11371)|4b76/([^/]++)/([^/]++)/([^/]++)/6c4b76(*:11419)|dd60/([^/]++)/([^/]++)/([^/]++)/6cdd60(*:11467)|9882/([^/]++)/([^/]++)/([^/]++)/6c9882(*:11515)|524f/([^/]++)/([^/]++)/([^/]++)/6c524f(*:11563))|7(?|c6a1/([^/]++)/([^/]++)/([^/]++)/67c6a1(*:11616)|f7fb/([^/]++)/([^/]++)/([^/]++)/67f7fb(*:11664))|42e92/([^/]++)/([^/]++)/([^/]++)/642e92(*:11714)|6(?|f041/([^/]++)/([^/]++)/([^/]++)/66f041(*:11766)|808e/([^/]++)/([^/]++)/([^/]++)/66808e(*:11814)|3682/([^/]++)/([^/]++)/([^/]++)/663682(*:11862))|8(?|d30a/([^/]++)/([^/]++)/([^/]++)/68d30a(*:11915)|8396/([^/]++)/([^/]++)/([^/]++)/688396(*:11963)|5545/([^/]++)/([^/]++)/([^/]++)/685545(*:12011)|ce19/([^/]++)/([^/]++)/([^/]++)/68ce19(*:12059))|9(?|74ce/([^/]++)/([^/]++)/([^/]++)/6974ce(*:12112)|8d51/([^/]++)/([^/]++)/([^/]++)/698d51(*:12160)|adc1/([^/]++)/([^/]++)/([^/]++)/69adc1(*:12208)|cb3e/([^/]++)/([^/]++)/([^/]++)/69cb3e(*:12256))|da(?|900/([^/]++)/([^/]++)/([^/]++)/6da900(*:12309)|37d/([^/]++)/([^/]++)/([^/]++)/6da37d(*:12356))|21bf6/([^/]++)/([^/]++)/([^/]++)/621bf6(*:12406)|a9aed/([^/]++)/([^/]++)/([^/]++)/6a9aed(*:12455))|/9(?|b(?|f31c/([^/]++)/([^/]++)/([^/]++)/9bf31c(*:12513)|8619/([^/]++)/([^/]++)/([^/]++)/9b8619(*:12561)|04d1/([^/]++)/([^/]++)/([^/]++)/9b04d1(*:12609)|e40c/([^/]++)/([^/]++)/([^/]++)/9be40c(*:12657)|70e8/([^/]++)/([^/]++)/([^/]++)/9b70e8(*:12705))|8(?|f137/([^/]++)/([^/]++)/([^/]++)/98f137(*:12758)|dce8/([^/]++)/([^/]++)/([^/]++)/98dce8(*:12806)|72ed/([^/]++)/([^/]++)/([^/]++)/9872ed(*:12854)|b297/([^/]++)/([^/]++)/([^/]++)/98b297(*:12902))|a(?|1158/([^/]++)/([^/]++)/([^/]++)/9a1158(*:12955)|9687/([^/]++)/([^/]++)/([^/]++)/9a9687(*:13003))|f(?|6140/([^/]++)/([^/]++)/([^/]++)/9f6140(*:13056)|c3d7/([^/]++)/([^/]++)/([^/]++)/9fc3d7(*:13104)|d818/([^/]++)/([^/]++)/([^/]++)/9fd818(*:13152))|7(?|78d5/([^/]++)/([^/]++)/([^/]++)/9778d5(*:13205)|6652/([^/]++)/([^/]++)/([^/]++)/976652(*:13253)|9d47/([^/]++)/([^/]++)/([^/]++)/979d47(*:13301))|3db85/([^/]++)/([^/]++)/([^/]++)/93db85(*:13351)|2c(?|c22/([^/]++)/([^/]++)/([^/]++)/92cc22(*:13403)|8c9/([^/]++)/([^/]++)/([^/]++)/92c8c9(*:13450))|03ce9/([^/]++)/([^/]++)/([^/]++)/903ce9(*:13500)|6da2f/([^/]++)/([^/]++)/([^/]++)/96da2f(*:13549)|d(?|cb88/([^/]++)/([^/]++)/([^/]++)/9dcb88(*:13601)|fcd5/([^/]++)/([^/]++)/([^/]++)/9dfcd5(*:13649)|e6d1/([^/]++)/([^/]++)/([^/]++)/9de6d1(*:13697))|c(?|fdf1/([^/]++)/([^/]++)/([^/]++)/9cfdf1(*:13750)|838d/([^/]++)/([^/]++)/([^/]++)/9c838d(*:13798))|18(?|890/([^/]++)/([^/]++)/([^/]++)/918890(*:13851)|317/([^/]++)/([^/]++)/([^/]++)/918317(*:13898))|4(?|f6d7/([^/]++)/([^/]++)/([^/]++)/94f6d7(*:13951)|1e1a/([^/]++)/([^/]++)/([^/]++)/941e1a(*:13999)|31c8/([^/]++)/([^/]++)/([^/]++)/9431c8(*:14047)|61cc/([^/]++)/([^/]++)/([^/]++)/9461cc(*:14095))|50a41/([^/]++)/([^/]++)/([^/]++)/950a41(*:14145))|/7(?|0(?|efdf/([^/]++)/([^/]++)/([^/]++)/70efdf(*:14203)|5f21/([^/]++)/([^/]++)/([^/]++)/705f21(*:14251)|c639/([^/]++)/([^/]++)/([^/]++)/70c639(*:14299))|2b32a/([^/]++)/([^/]++)/([^/]++)/72b32a(*:14349)|f(?|39f8/([^/]++)/([^/]++)/([^/]++)/7f39f8(*:14401)|6ffa/([^/]++)/([^/]++)/([^/]++)/7f6ffa(*:14449)|1(?|de2/([^/]++)/([^/]++)/([^/]++)/7f1de2(*:14500)|00b/([^/]++)/([^/]++)/([^/]++)/7f100b(*:14547))|e1f8/([^/]++)/([^/]++)/([^/]++)/7fe1f8(*:14596))|3(?|5b90/([^/]++)/([^/]++)/([^/]++)/735b90(*:14649)|278a/([^/]++)/([^/]++)/([^/]++)/73278a(*:14697)|80ad/([^/]++)/([^/]++)/([^/]++)/7380ad(*:14745))|cbbc4/([^/]++)/([^/]++)/([^/]++)/7cbbc4(*:14795)|6(?|4796/([^/]++)/([^/]++)/([^/]++)/764796(*:14847)|dc61/([^/]++)/([^/]++)/([^/]++)/76dc61(*:14895))|e(?|f605/([^/]++)/([^/]++)/([^/]++)/7ef605(*:14948)|7757/([^/]++)/([^/]++)/([^/]++)/7e7757(*:14996)|a(?|be3/([^/]++)/([^/]++)/([^/]++)/7eabe3(*:15047)|cb5/([^/]++)/([^/]++)/([^/]++)/7eacb5(*:15094)))|5(?|7b50/([^/]++)/([^/]++)/([^/]++)/757b50(*:15148)|8874/([^/]++)/([^/]++)/([^/]++)/758874(*:15196)|fc09/([^/]++)/([^/]++)/([^/]++)/75fc09(*:15244))|4(?|db12/([^/]++)/([^/]++)/([^/]++)/74db12(*:15297)|071a/([^/]++)/([^/]++)/([^/]++)/74071a(*:15345))|a614f/([^/]++)/([^/]++)/([^/]++)/7a614f(*:15395)|d04bb/([^/]++)/([^/]++)/([^/]++)/7d04bb(*:15444))|/3(?|c(?|59dc/([^/]++)/([^/]++)/([^/]++)/3c59dc(*:15502)|ec07/([^/]++)/([^/]++)/([^/]++)/3cec07(*:15550)|7781/([^/]++)/([^/]++)/([^/]++)/3c7781(*:15598)|f166/([^/]++)/([^/]++)/([^/]++)/3cf166(*:15646))|7(?|693c/([^/]++)/([^/]++)/([^/]++)/37693c(*:15699)|a749/([^/]++)/([^/]++)/([^/]++)/37a749(*:15747)|bc2f/([^/]++)/([^/]++)/([^/]++)/37bc2f(*:15795)|1bce/([^/]++)/([^/]++)/([^/]++)/371bce(*:15843))|3(?|e75f/([^/]++)/([^/]++)/([^/]++)/33e75f(*:15896)|5f53/([^/]++)/([^/]++)/([^/]++)/335f53(*:15944))|4(?|1(?|73c/([^/]++)/([^/]++)/([^/]++)/34173c(*:16000)|6a7/([^/]++)/([^/]++)/([^/]++)/3416a7(*:16047))|ed06/([^/]++)/([^/]++)/([^/]++)/34ed06(*:16096))|2(?|95c7/([^/]++)/([^/]++)/([^/]++)/3295c7(*:16149)|bb90/([^/]++)/([^/]++)/([^/]++)/32bb90(*:16197)|0722/([^/]++)/([^/]++)/([^/]++)/320722(*:16245))|5(?|f4a8/([^/]++)/([^/]++)/([^/]++)/35f4a8(*:16298)|7a6f/([^/]++)/([^/]++)/([^/]++)/357a6f(*:16346)|2fe2/([^/]++)/([^/]++)/([^/]++)/352fe2(*:16394)|0510/([^/]++)/([^/]++)/([^/]++)/350510(*:16442))|ef815/([^/]++)/([^/]++)/([^/]++)/3ef815(*:16492)|8(?|b3ef/([^/]++)/([^/]++)/([^/]++)/38b3ef(*:16544)|af86/([^/]++)/([^/]++)/([^/]++)/38af86(*:16592)|db3a/([^/]++)/([^/]++)/([^/]++)/38db3a(*:16640))|d(?|ef18/([^/]++)/([^/]++)/([^/]++)/3def18(*:16693)|d48a/([^/]++)/([^/]++)/([^/]++)/3dd48a(*:16741))|9(?|88c7/([^/]++)/([^/]++)/([^/]++)/3988c7(*:16794)|0597/([^/]++)/([^/]++)/([^/]++)/390597(*:16842)|461a/([^/]++)/([^/]++)/([^/]++)/39461a(*:16890))|6(?|3663/([^/]++)/([^/]++)/([^/]++)/363663(*:16943)|44a6/([^/]++)/([^/]++)/([^/]++)/3644a6(*:16991)|660e/([^/]++)/([^/]++)/([^/]++)/36660e(*:17039))|1(?|fefc/([^/]++)/([^/]++)/([^/]++)/31fefc(*:17092)|0dcb/([^/]++)/([^/]++)/([^/]++)/310dcb(*:17140))|b8a61/([^/]++)/([^/]++)/([^/]++)/3b8a61(*:17190)|fe94a/([^/]++)/([^/]++)/([^/]++)/3fe94a(*:17239)|ad7c2/([^/]++)/([^/]++)/([^/]++)/3ad7c2(*:17288))|/b(?|6(?|d767/([^/]++)/([^/]++)/([^/]++)/b6d767(*:17346)|f047/([^/]++)/([^/]++)/([^/]++)/b6f047(*:17394))|53(?|b3a/([^/]++)/([^/]++)/([^/]++)/b53b3a(*:17447)|4ba/([^/]++)/([^/]++)/([^/]++)/b534ba(*:17494))|3(?|e3e3/([^/]++)/([^/]++)/([^/]++)/b3e3e3(*:17547)|967a/([^/]++)/([^/]++)/([^/]++)/b3967a(*:17595))|7(?|3ce3/([^/]++)/([^/]++)/([^/]++)/b73ce3(*:17648)|b16e/([^/]++)/([^/]++)/([^/]++)/b7b16e(*:17696))|d(?|4c9a/([^/]++)/([^/]++)/([^/]++)/bd4c9a(*:17749)|686f/([^/]++)/([^/]++)/([^/]++)/bd686f(*:17797))|f8229/([^/]++)/([^/]++)/([^/]++)/bf8229(*:17847)|1(?|d10e/([^/]++)/([^/]++)/([^/]++)/b1d10e(*:17899)|a59b/([^/]++)/([^/]++)/([^/]++)/b1a59b(*:17947))|c(?|be33/([^/]++)/([^/]++)/([^/]++)/bcbe33(*:18000)|6dc4/([^/]++)/([^/]++)/([^/]++)/bc6dc4(*:18048)|a82e/([^/]++)/([^/]++)/([^/]++)/bca82e(*:18096))|e(?|83ab/([^/]++)/([^/]++)/([^/]++)/be83ab(*:18149)|ed13/([^/]++)/([^/]++)/([^/]++)/beed13(*:18197))|2eb73/([^/]++)/([^/]++)/([^/]++)/b2eb73(*:18247)|83aac/([^/]++)/([^/]++)/([^/]++)/b83aac(*:18296)|ac916/([^/]++)/([^/]++)/([^/]++)/bac916(*:18345)|b(?|f94b/([^/]++)/([^/]++)/([^/]++)/bbf94b(*:18397)|cbff/([^/]++)/([^/]++)/([^/]++)/bbcbff(*:18445))|9228e/([^/]++)/([^/]++)/([^/]++)/b9228e(*:18495))|/0(?|2(?|e74f/([^/]++)/([^/]++)/([^/]++)/02e74f(*:18553)|522a/([^/]++)/([^/]++)/([^/]++)/02522a(*:18601)|66e3/([^/]++)/([^/]++)/([^/]++)/0266e3(*:18649))|9(?|3f65/([^/]++)/([^/]++)/([^/]++)/093f65(*:18702)|1d58/([^/]++)/([^/]++)/([^/]++)/091d58(*:18750))|7(?|2b03/([^/]++)/([^/]++)/([^/]++)/072b03(*:18803)|e1cd/([^/]++)/([^/]++)/([^/]++)/07e1cd(*:18851)|7(?|7d5/([^/]++)/([^/]++)/([^/]++)/0777d5(*:18902)|e29/([^/]++)/([^/]++)/([^/]++)/077e29(*:18949))|cdfd/([^/]++)/([^/]++)/([^/]++)/07cdfd(*:18998))|3(?|afdb/([^/]++)/([^/]++)/([^/]++)/03afdb(*:19051)|36dc/([^/]++)/([^/]++)/([^/]++)/0336dc(*:19099)|c6b0/([^/]++)/([^/]++)/([^/]++)/03c6b0(*:19147)|53ab/([^/]++)/([^/]++)/([^/]++)/0353ab(*:19195))|6(?|9059/([^/]++)/([^/]++)/([^/]++)/069059(*:19248)|4096/([^/]++)/([^/]++)/([^/]++)/064096(*:19296)|0ad9/([^/]++)/([^/]++)/([^/]++)/060ad9(*:19344)|138b/([^/]++)/([^/]++)/([^/]++)/06138b(*:19392)|eb61/([^/]++)/([^/]++)/([^/]++)/06eb61(*:19440))|1(?|3(?|d40/([^/]++)/([^/]++)/([^/]++)/013d40(*:19496)|86b/([^/]++)/([^/]++)/([^/]++)/01386b(*:19543))|161a/([^/]++)/([^/]++)/([^/]++)/01161a(*:19592)|9d38/([^/]++)/([^/]++)/([^/]++)/019d38(*:19640))|f(?|28b5/([^/]++)/([^/]++)/([^/]++)/0f28b5(*:19693)|49c8/([^/]++)/([^/]++)/([^/]++)/0f49c8(*:19741))|a(?|09c8/([^/]++)/([^/]++)/([^/]++)/0a09c8(*:19794)|a188/([^/]++)/([^/]++)/([^/]++)/0aa188(*:19842))|0(?|6f52/([^/]++)/([^/]++)/([^/]++)/006f52(*:19895)|4114/([^/]++)/([^/]++)/([^/]++)/004114(*:19943)|ec53/([^/]++)/([^/]++)/([^/]++)/00ec53(*:19991))|4(?|5117/([^/]++)/([^/]++)/([^/]++)/045117(*:20044)|0259/([^/]++)/([^/]++)/([^/]++)/040259(*:20092))|84b6f/([^/]++)/([^/]++)/([^/]++)/084b6f(*:20142)|e(?|6597/([^/]++)/([^/]++)/([^/]++)/0e6597(*:20194)|0193/([^/]++)/([^/]++)/([^/]++)/0e0193(*:20242))|bb4ae/([^/]++)/([^/]++)/([^/]++)/0bb4ae(*:20292)|5(?|049e/([^/]++)/([^/]++)/([^/]++)/05049e(*:20344)|84ce/([^/]++)/([^/]++)/([^/]++)/0584ce(*:20392)|f971/([^/]++)/([^/]++)/([^/]++)/05f971(*:20440))|c74b7/([^/]++)/([^/]++)/([^/]++)/0c74b7(*:20490)|d(?|0fd7/([^/]++)/([^/]++)/([^/]++)/0d0fd7(*:20542)|eb1c/([^/]++)/([^/]++)/([^/]++)/0deb1c(*:20590)))|/f(?|7(?|1(?|771/([^/]++)/([^/]++)/([^/]++)/f71771(*:20652)|849/([^/]++)/([^/]++)/([^/]++)/f71849(*:20699))|e6c8/([^/]++)/([^/]++)/([^/]++)/f7e6c8(*:20748)|6640/([^/]++)/([^/]++)/([^/]++)/f76640(*:20796)|3b76/([^/]++)/([^/]++)/([^/]++)/f73b76(*:20844)|4909/([^/]++)/([^/]++)/([^/]++)/f74909(*:20892)|70b6/([^/]++)/([^/]++)/([^/]++)/f770b6(*:20940))|4(?|57c5/([^/]++)/([^/]++)/([^/]++)/f457c5(*:20993)|b9ec/([^/]++)/([^/]++)/([^/]++)/f4b9ec(*:21041)|f6dc/([^/]++)/([^/]++)/([^/]++)/f4f6dc(*:21089))|c(?|490c/([^/]++)/([^/]++)/([^/]++)/fc490c(*:21142)|2213/([^/]++)/([^/]++)/([^/]++)/fc2213(*:21190)|cb60/([^/]++)/([^/]++)/([^/]++)/fccb60(*:21238))|b(?|d793/([^/]++)/([^/]++)/([^/]++)/fbd793(*:21291)|7b9f/([^/]++)/([^/]++)/([^/]++)/fb7b9f(*:21339))|0(?|33ab/([^/]++)/([^/]++)/([^/]++)/f033ab(*:21392)|935e/([^/]++)/([^/]++)/([^/]++)/f0935e(*:21440))|e(?|9fc2/([^/]++)/([^/]++)/([^/]++)/fe9fc2(*:21493)|131d/([^/]++)/([^/]++)/([^/]++)/fe131d(*:21541)|73f6/([^/]++)/([^/]++)/([^/]++)/fe73f6(*:21589))|8(?|9913/([^/]++)/([^/]++)/([^/]++)/f89913(*:21642)|c1f2/([^/]++)/([^/]++)/([^/]++)/f8c1f2(*:21690)|5454/([^/]++)/([^/]++)/([^/]++)/f85454(*:21738))|2(?|2170/([^/]++)/([^/]++)/([^/]++)/f22170(*:21791)|fc99/([^/]++)/([^/]++)/([^/]++)/f2fc99(*:21839))|a(?|7cdf/([^/]++)/([^/]++)/([^/]++)/fa7cdf(*:21892)|a9af/([^/]++)/([^/]++)/([^/]++)/faa9af(*:21940))|340f1/([^/]++)/([^/]++)/([^/]++)/f340f1(*:21990)|9(?|0f2a/([^/]++)/([^/]++)/([^/]++)/f90f2a(*:22042)|b902/([^/]++)/([^/]++)/([^/]++)/f9b902(*:22090))|fd52f/([^/]++)/([^/]++)/([^/]++)/ffd52f(*:22140)|61d69/([^/]++)/([^/]++)/([^/]++)/f61d69(*:22189)|5f859/([^/]++)/([^/]++)/([^/]++)/f5f859(*:22238)|1b6f2/([^/]++)/([^/]++)/([^/]++)/f1b6f2(*:22287))|/2(?|8(?|3802/([^/]++)/([^/]++)/([^/]++)/283802(*:22345)|dd2c/([^/]++)/([^/]++)/([^/]++)/28dd2c(*:22393)|9dff/([^/]++)/([^/]++)/([^/]++)/289dff(*:22441)|f0b8/([^/]++)/([^/]++)/([^/]++)/28f0b8(*:22489))|a(?|38a4/([^/]++)/([^/]++)/([^/]++)/2a38a4(*:22542)|79ea/([^/]++)/([^/]++)/([^/]++)/2a79ea(*:22590))|6(?|657d/([^/]++)/([^/]++)/([^/]++)/26657d(*:22643)|e359/([^/]++)/([^/]++)/([^/]++)/26e359(*:22691)|3373/([^/]++)/([^/]++)/([^/]++)/263373(*:22739))|7(?|23d0/([^/]++)/([^/]++)/([^/]++)/2723d0(*:22792)|4ad4/([^/]++)/([^/]++)/([^/]++)/274ad4(*:22840))|b(?|4492/([^/]++)/([^/]++)/([^/]++)/2b4492(*:22893)|24d4/([^/]++)/([^/]++)/([^/]++)/2b24d4(*:22941))|0(?|2cb9/([^/]++)/([^/]++)/([^/]++)/202cb9(*:22994)|f075/([^/]++)/([^/]++)/([^/]++)/20f075(*:23042)|50e0/([^/]++)/([^/]++)/([^/]++)/2050e0(*:23090))|f(?|2b26/([^/]++)/([^/]++)/([^/]++)/2f2b26(*:23143)|5570/([^/]++)/([^/]++)/([^/]++)/2f5570(*:23191))|4(?|b16f/([^/]++)/([^/]++)/([^/]++)/24b16f(*:23244)|8e84/([^/]++)/([^/]++)/([^/]++)/248e84(*:23292)|21fc/([^/]++)/([^/]++)/([^/]++)/2421fc(*:23340))|5(?|b282/([^/]++)/([^/]++)/([^/]++)/25b282(*:23393)|0cf8/([^/]++)/([^/]++)/([^/]++)/250cf8(*:23441)|ddc0/([^/]++)/([^/]++)/([^/]++)/25ddc0(*:23489))|18a0a/([^/]++)/([^/]++)/([^/]++)/218a0a(*:23539))|/5(?|4229a/([^/]++)/([^/]++)/([^/]++)/54229a(*:23594)|f(?|93f9/([^/]++)/([^/]++)/([^/]++)/5f93f9(*:23646)|d0b3/([^/]++)/([^/]++)/([^/]++)/5fd0b3(*:23694))|ef(?|0(?|59/([^/]++)/([^/]++)/([^/]++)/5ef059(*:23750)|b4/([^/]++)/([^/]++)/([^/]++)/5ef0b4(*:23796))|698/([^/]++)/([^/]++)/([^/]++)/5ef698(*:23844))|8(?|78a7/([^/]++)/([^/]++)/([^/]++)/5878a7(*:23897)|a2fc/([^/]++)/([^/]++)/([^/]++)/58a2fc(*:23945)|238e/([^/]++)/([^/]++)/([^/]++)/58238e(*:23993))|7(?|aeee/([^/]++)/([^/]++)/([^/]++)/57aeee(*:24046)|7(?|ef1/([^/]++)/([^/]++)/([^/]++)/577ef1(*:24097)|bcc/([^/]++)/([^/]++)/([^/]++)/577bcc(*:24144))|37c6/([^/]++)/([^/]++)/([^/]++)/5737c6(*:24193))|3(?|9fd5/([^/]++)/([^/]++)/([^/]++)/539fd5(*:24246)|c3bc/([^/]++)/([^/]++)/([^/]++)/53c3bc(*:24294))|5(?|5d67/([^/]++)/([^/]++)/([^/]++)/555d67(*:24347)|0a14/([^/]++)/([^/]++)/([^/]++)/550a14(*:24395)|9cb9/([^/]++)/([^/]++)/([^/]++)/559cb9(*:24443)|a7cf/([^/]++)/([^/]++)/([^/]++)/55a7cf(*:24491))|02e4a/([^/]++)/([^/]++)/([^/]++)/502e4a(*:24541)|b8add/([^/]++)/([^/]++)/([^/]++)/5b8add(*:24590)|2720e/([^/]++)/([^/]++)/([^/]++)/52720e(*:24639)|a4b25/([^/]++)/([^/]++)/([^/]++)/5a4b25(*:24688)|1d92b/([^/]++)/([^/]++)/([^/]++)/51d92b(*:24737)|98b3e/([^/]++)/([^/]++)/([^/]++)/598b3e(*:24786)))/?$"), 24786 => ART.create_regex("^(?|/5(?|b69b9/([^/]++)/([^/]++)/([^/]++)/5b69b9(*:24837)|9(?|b90e/([^/]++)/([^/]++)/([^/]++)/59b90e(*:24889)|c330/([^/]++)/([^/]++)/([^/]++)/59c330(*:24937))|3(?|fde9/([^/]++)/([^/]++)/([^/]++)/53fde9(*:24990)|e3a7/([^/]++)/([^/]++)/([^/]++)/53e3a7(*:25038))|e(?|a164/([^/]++)/([^/]++)/([^/]++)/5ea164(*:25091)|3881/([^/]++)/([^/]++)/([^/]++)/5e3881(*:25139)|9f92/([^/]++)/([^/]++)/([^/]++)/5e9f92(*:25187)|c91a/([^/]++)/([^/]++)/([^/]++)/5ec91a(*:25235))|7(?|3703/([^/]++)/([^/]++)/([^/]++)/573703(*:25288)|51ec/([^/]++)/([^/]++)/([^/]++)/5751ec(*:25336)|05e1/([^/]++)/([^/]++)/([^/]++)/5705e1(*:25384))|8(?|ae74/([^/]++)/([^/]++)/([^/]++)/58ae74(*:25437)|d4d1/([^/]++)/([^/]++)/([^/]++)/58d4d1(*:25485)|07a6/([^/]++)/([^/]++)/([^/]++)/5807a6(*:25533)|e4d4/([^/]++)/([^/]++)/([^/]++)/58e4d4(*:25581))|d(?|44ee/([^/]++)/([^/]++)/([^/]++)/5d44ee(*:25634)|d9db/([^/]++)/([^/]++)/([^/]++)/5dd9db(*:25682))|5(?|b37c/([^/]++)/([^/]++)/([^/]++)/55b37c(*:25735)|743c/([^/]++)/([^/]++)/([^/]++)/55743c(*:25783)|6f39/([^/]++)/([^/]++)/([^/]++)/556f39(*:25831))|c(?|0492/([^/]++)/([^/]++)/([^/]++)/5c0492(*:25884)|572e/([^/]++)/([^/]++)/([^/]++)/5c572e(*:25932)|9362/([^/]++)/([^/]++)/([^/]++)/5c9362(*:25980))|4(?|8731/([^/]++)/([^/]++)/([^/]++)/548731(*:26033)|a367/([^/]++)/([^/]++)/([^/]++)/54a367(*:26081))|0(?|0e75/([^/]++)/([^/]++)/([^/]++)/500e75(*:26134)|c3d7/([^/]++)/([^/]++)/([^/]++)/50c3d7(*:26182))|f(?|2c22/([^/]++)/([^/]++)/([^/]++)/5f2c22(*:26235)|0f5e/([^/]++)/([^/]++)/([^/]++)/5f0f5e(*:26283))|1ef18/([^/]++)/([^/]++)/([^/]++)/51ef18(*:26333))|/b(?|5(?|b41f/([^/]++)/([^/]++)/([^/]++)/b5b41f(*:26391)|dc4e/([^/]++)/([^/]++)/([^/]++)/b5dc4e(*:26439)|6a18/([^/]++)/([^/]++)/([^/]++)/b56a18(*:26487)|5ec2/([^/]++)/([^/]++)/([^/]++)/b55ec2(*:26535))|337e8/([^/]++)/([^/]++)/([^/]++)/b337e8(*:26585)|a(?|2fd3/([^/]++)/([^/]++)/([^/]++)/ba2fd3(*:26637)|3866/([^/]++)/([^/]++)/([^/]++)/ba3866(*:26685))|2(?|eeb7/([^/]++)/([^/]++)/([^/]++)/b2eeb7(*:26738)|f627/([^/]++)/([^/]++)/([^/]++)/b2f627(*:26786))|7(?|3dfe/([^/]++)/([^/]++)/([^/]++)/b73dfe(*:26839)|bb35/([^/]++)/([^/]++)/([^/]++)/b7bb35(*:26887)|ee6f/([^/]++)/([^/]++)/([^/]++)/b7ee6f(*:26935)|892f/([^/]++)/([^/]++)/([^/]++)/b7892f(*:26983)|0683/([^/]++)/([^/]++)/([^/]++)/b70683(*:27031))|4(?|288d/([^/]++)/([^/]++)/([^/]++)/b4288d(*:27084)|a528/([^/]++)/([^/]++)/([^/]++)/b4a528(*:27132))|e(?|3159/([^/]++)/([^/]++)/([^/]++)/be3159(*:27185)|b22f/([^/]++)/([^/]++)/([^/]++)/beb22f(*:27233)|a595/([^/]++)/([^/]++)/([^/]++)/bea595(*:27281))|1(?|eec3/([^/]++)/([^/]++)/([^/]++)/b1eec3(*:27334)|37fd/([^/]++)/([^/]++)/([^/]++)/b137fd(*:27382))|0(?|56eb/([^/]++)/([^/]++)/([^/]++)/b056eb(*:27435)|b183/([^/]++)/([^/]++)/([^/]++)/b0b183(*:27483))|f6276/([^/]++)/([^/]++)/([^/]++)/bf6276(*:27533)|6(?|edc1/([^/]++)/([^/]++)/([^/]++)/b6edc1(*:27585)|a108/([^/]++)/([^/]++)/([^/]++)/b6a108(*:27633))|86e8d/([^/]++)/([^/]++)/([^/]++)/b86e8d(*:27683))|/2(?|8(?|5e19/([^/]++)/([^/]++)/([^/]++)/285e19(*:27741)|2(?|3f4/([^/]++)/([^/]++)/([^/]++)/2823f4(*:27792)|67a/([^/]++)/([^/]++)/([^/]++)/28267a(*:27839))|8cc0/([^/]++)/([^/]++)/([^/]++)/288cc0(*:27888)|7e03/([^/]++)/([^/]++)/([^/]++)/287e03(*:27936))|d(?|6cc4/([^/]++)/([^/]++)/([^/]++)/2d6cc4(*:27989)|ea61/([^/]++)/([^/]++)/([^/]++)/2dea61(*:28037)|ace7/([^/]++)/([^/]++)/([^/]++)/2dace7(*:28085))|b(?|8a61/([^/]++)/([^/]++)/([^/]++)/2b8a61(*:28138)|b232/([^/]++)/([^/]++)/([^/]++)/2bb232(*:28186)|a596/([^/]++)/([^/]++)/([^/]++)/2ba596(*:28234)|cab9/([^/]++)/([^/]++)/([^/]++)/2bcab9(*:28282))|9(?|8f95/([^/]++)/([^/]++)/([^/]++)/298f95(*:28335)|1597/([^/]++)/([^/]++)/([^/]++)/291597(*:28383))|58be1/([^/]++)/([^/]++)/([^/]++)/258be1(*:28433)|3(?|3509/([^/]++)/([^/]++)/([^/]++)/233509(*:28485)|ce18/([^/]++)/([^/]++)/([^/]++)/23ce18(*:28533))|6(?|dd0d/([^/]++)/([^/]++)/([^/]++)/26dd0d(*:28586)|408f/([^/]++)/([^/]++)/([^/]++)/26408f(*:28634))|f(?|37d1/([^/]++)/([^/]++)/([^/]++)/2f37d1(*:28687)|885d/([^/]++)/([^/]++)/([^/]++)/2f885d(*:28735))|2(?|91d2/([^/]++)/([^/]++)/([^/]++)/2291d2(*:28788)|ac3c/([^/]++)/([^/]++)/([^/]++)/22ac3c(*:28836)|fb0c/([^/]++)/([^/]++)/([^/]++)/22fb0c(*:28884))|4(?|6819/([^/]++)/([^/]++)/([^/]++)/246819(*:28937)|896e/([^/]++)/([^/]++)/([^/]++)/24896e(*:28985))|a(?|fe45/([^/]++)/([^/]++)/([^/]++)/2afe45(*:29038)|084e/([^/]++)/([^/]++)/([^/]++)/2a084e(*:29086)|9d12/([^/]++)/([^/]++)/([^/]++)/2a9d12(*:29134)|b564/([^/]++)/([^/]++)/([^/]++)/2ab564(*:29182))|1(?|7eed/([^/]++)/([^/]++)/([^/]++)/217eed(*:29235)|0f76/([^/]++)/([^/]++)/([^/]++)/210f76(*:29283))|e65f2/([^/]++)/([^/]++)/([^/]++)/2e65f2(*:29333)|ca65f/([^/]++)/([^/]++)/([^/]++)/2ca65f(*:29382)|0aee3/([^/]++)/([^/]++)/([^/]++)/20aee3(*:29431))|/e(?|8(?|c065/([^/]++)/([^/]++)/([^/]++)/e8c065(*:29489)|20a4/([^/]++)/([^/]++)/([^/]++)/e820a4(*:29537))|2(?|230b/([^/]++)/([^/]++)/([^/]++)/e2230b(*:29590)|a2dc/([^/]++)/([^/]++)/([^/]++)/e2a2dc(*:29638)|05ee/([^/]++)/([^/]++)/([^/]++)/e205ee(*:29686))|b(?|d962/([^/]++)/([^/]++)/([^/]++)/ebd962(*:29739)|6fdc/([^/]++)/([^/]++)/([^/]++)/eb6fdc(*:29787))|d(?|265b/([^/]++)/([^/]++)/([^/]++)/ed265b(*:29840)|fbe1/([^/]++)/([^/]++)/([^/]++)/edfbe1(*:29888)|e7e2/([^/]++)/([^/]++)/([^/]++)/ede7e2(*:29936))|6(?|b4b2/([^/]++)/([^/]++)/([^/]++)/e6b4b2(*:29989)|cb2a/([^/]++)/([^/]++)/([^/]++)/e6cb2a(*:30037))|5(?|f6ad/([^/]++)/([^/]++)/([^/]++)/e5f6ad(*:30090)|55eb/([^/]++)/([^/]++)/([^/]++)/e555eb(*:30138)|841d/([^/]++)/([^/]++)/([^/]++)/e5841d(*:30186)|7c6b/([^/]++)/([^/]++)/([^/]++)/e57c6b(*:30234))|aae33/([^/]++)/([^/]++)/([^/]++)/eaae33(*:30284)|4(?|bb4c/([^/]++)/([^/]++)/([^/]++)/e4bb4c(*:30336)|9b8b/([^/]++)/([^/]++)/([^/]++)/e49b8b(*:30384))|7(?|0611/([^/]++)/([^/]++)/([^/]++)/e70611(*:30437)|f8a7/([^/]++)/([^/]++)/([^/]++)/e7f8a7(*:30485)|44f9/([^/]++)/([^/]++)/([^/]++)/e744f9(*:30533))|9(?|95f9/([^/]++)/([^/]++)/([^/]++)/e995f9(*:30586)|4550/([^/]++)/([^/]++)/([^/]++)/e94550(*:30634)|7ee2/([^/]++)/([^/]++)/([^/]++)/e97ee2(*:30682))|e(?|fc9e/([^/]++)/([^/]++)/([^/]++)/eefc9e(*:30735)|b69a/([^/]++)/([^/]++)/([^/]++)/eeb69a(*:30783))|0(?|7413/([^/]++)/([^/]++)/([^/]++)/e07413(*:30836)|cf1f/([^/]++)/([^/]++)/([^/]++)/e0cf1f(*:30884)|ec45/([^/]++)/([^/]++)/([^/]++)/e0ec45(*:30932))|f4e3b/([^/]++)/([^/]++)/([^/]++)/ef4e3b(*:30982)|c5aa0/([^/]++)/([^/]++)/([^/]++)/ec5aa0(*:31031))|/f(?|f(?|4d5f/([^/]++)/([^/]++)/([^/]++)/ff4d5f(*:31089)|eabd/([^/]++)/([^/]++)/([^/]++)/ffeabd(*:31137))|3(?|f27a/([^/]++)/([^/]++)/([^/]++)/f3f27a(*:31190)|8762/([^/]++)/([^/]++)/([^/]++)/f38762(*:31238))|4(?|be00/([^/]++)/([^/]++)/([^/]++)/f4be00(*:31291)|5526/([^/]++)/([^/]++)/([^/]++)/f45526(*:31339)|7d0a/([^/]++)/([^/]++)/([^/]++)/f47d0a(*:31387))|0(?|e52b/([^/]++)/([^/]++)/([^/]++)/f0e52b(*:31440)|adc8/([^/]++)/([^/]++)/([^/]++)/f0adc8(*:31488))|de926/([^/]++)/([^/]++)/([^/]++)/fde926(*:31538)|5(?|deae/([^/]++)/([^/]++)/([^/]++)/f5deae(*:31590)|7a2f/([^/]++)/([^/]++)/([^/]++)/f57a2f(*:31638))|7(?|6a89/([^/]++)/([^/]++)/([^/]++)/f76a89(*:31691)|9921/([^/]++)/([^/]++)/([^/]++)/f79921(*:31739)|e905/([^/]++)/([^/]++)/([^/]++)/f7e905(*:31787))|2(?|9c21/([^/]++)/([^/]++)/([^/]++)/f29c21(*:31840)|201f/([^/]++)/([^/]++)/([^/]++)/f2201f(*:31888))|a(?|e0b2/([^/]++)/([^/]++)/([^/]++)/fae0b2(*:31941)|14d4/([^/]++)/([^/]++)/([^/]++)/fa14d4(*:31989)|3a3c/([^/]++)/([^/]++)/([^/]++)/fa3a3c(*:32037)|83a1/([^/]++)/([^/]++)/([^/]++)/fa83a1(*:32085))|c(?|cb3c/([^/]++)/([^/]++)/([^/]++)/fccb3c(*:32138)|8001/([^/]++)/([^/]++)/([^/]++)/fc8001(*:32186)|3cf4/([^/]++)/([^/]++)/([^/]++)/fc3cf4(*:32234)|4930/([^/]++)/([^/]++)/([^/]++)/fc4930(*:32282))|64eac/([^/]++)/([^/]++)/([^/]++)/f64eac(*:32332)|b8970/([^/]++)/([^/]++)/([^/]++)/fb8970(*:32381)|1c159/([^/]++)/([^/]++)/([^/]++)/f1c159(*:32430)|9(?|028f/([^/]++)/([^/]++)/([^/]++)/f9028f(*:32482)|a40a/([^/]++)/([^/]++)/([^/]++)/f9a40a(*:32530))|e(?|8c15/([^/]++)/([^/]++)/([^/]++)/fe8c15(*:32583)|c8d4/([^/]++)/([^/]++)/([^/]++)/fec8d4(*:32631)|7ee8/([^/]++)/([^/]++)/([^/]++)/fe7ee8(*:32679)))|/3(?|8(?|9(?|bc7/([^/]++)/([^/]++)/([^/]++)/389bc7(*:32741)|13e/([^/]++)/([^/]++)/([^/]++)/38913e(*:32788))|71bd/([^/]++)/([^/]++)/([^/]++)/3871bd(*:32837))|d(?|c487/([^/]++)/([^/]++)/([^/]++)/3dc487(*:32890)|2d8c/([^/]++)/([^/]++)/([^/]++)/3d2d8c(*:32938)|8e28/([^/]++)/([^/]++)/([^/]++)/3d8e28(*:32986)|f1d4/([^/]++)/([^/]++)/([^/]++)/3df1d4(*:33034))|7f0e8/([^/]++)/([^/]++)/([^/]++)/37f0e8(*:33084)|3(?|e807/([^/]++)/([^/]++)/([^/]++)/33e807(*:33136)|28bd/([^/]++)/([^/]++)/([^/]++)/3328bd(*:33184))|a(?|0(?|772/([^/]++)/([^/]++)/([^/]++)/3a0772(*:33240)|66b/([^/]++)/([^/]++)/([^/]++)/3a066b(*:33287))|835d/([^/]++)/([^/]++)/([^/]++)/3a835d(*:33336))|0(?|bb38/([^/]++)/([^/]++)/([^/]++)/30bb38(*:33389)|3ed4/([^/]++)/([^/]++)/([^/]++)/303ed4(*:33437)|ef30/([^/]++)/([^/]++)/([^/]++)/30ef30(*:33485)|1ad0/([^/]++)/([^/]++)/([^/]++)/301ad0(*:33533))|4(?|9389/([^/]++)/([^/]++)/([^/]++)/349389(*:33586)|35c3/([^/]++)/([^/]++)/([^/]++)/3435c3(*:33634))|62(?|1f1/([^/]++)/([^/]++)/([^/]++)/3621f1(*:33687)|e80/([^/]++)/([^/]++)/([^/]++)/362e80(*:33734))|5(?|cf86/([^/]++)/([^/]++)/([^/]++)/35cf86(*:33787)|2407/([^/]++)/([^/]++)/([^/]++)/352407(*:33835))|2b30a/([^/]++)/([^/]++)/([^/]++)/32b30a(*:33885)|1839b/([^/]++)/([^/]++)/([^/]++)/31839b(*:33934)|b(?|5dca/([^/]++)/([^/]++)/([^/]++)/3b5dca(*:33986)|3dba/([^/]++)/([^/]++)/([^/]++)/3b3dba(*:34034))|e89eb/([^/]++)/([^/]++)/([^/]++)/3e89eb(*:34084)|cef96/([^/]++)/([^/]++)/([^/]++)/3cef96(*:34133))|/0(?|8(?|7408/([^/]++)/([^/]++)/([^/]++)/087408(*:34191)|b255/([^/]++)/([^/]++)/([^/]++)/08b255(*:34239)|c543/([^/]++)/([^/]++)/([^/]++)/08c543(*:34287)|d986/([^/]++)/([^/]++)/([^/]++)/08d986(*:34335)|419b/([^/]++)/([^/]++)/([^/]++)/08419b(*:34383))|7(?|563a/([^/]++)/([^/]++)/([^/]++)/07563a(*:34436)|6a0c/([^/]++)/([^/]++)/([^/]++)/076a0c(*:34484)|a96b/([^/]++)/([^/]++)/([^/]++)/07a96b(*:34532)|c580/([^/]++)/([^/]++)/([^/]++)/07c580(*:34580)|8719/([^/]++)/([^/]++)/([^/]++)/078719(*:34628))|f(?|cbc6/([^/]++)/([^/]++)/([^/]++)/0fcbc6(*:34681)|9661/([^/]++)/([^/]++)/([^/]++)/0f9661(*:34729)|f(?|39b/([^/]++)/([^/]++)/([^/]++)/0ff39b(*:34780)|803/([^/]++)/([^/]++)/([^/]++)/0ff803(*:34827))|840b/([^/]++)/([^/]++)/([^/]++)/0f840b(*:34876))|1(?|f78b/([^/]++)/([^/]++)/([^/]++)/01f78b(*:34929)|3a00/([^/]++)/([^/]++)/([^/]++)/013a00(*:34977)|8825/([^/]++)/([^/]++)/([^/]++)/018825(*:35025))|6(?|9(?|d3b/([^/]++)/([^/]++)/([^/]++)/069d3b(*:35081)|97f/([^/]++)/([^/]++)/([^/]++)/06997f(*:35128))|1412/([^/]++)/([^/]++)/([^/]++)/061412(*:35177))|4(?|ecb1/([^/]++)/([^/]++)/([^/]++)/04ecb1(*:35230)|3c3d/([^/]++)/([^/]++)/([^/]++)/043c3d(*:35278))|0ac8e/([^/]++)/([^/]++)/([^/]++)/00ac8e(*:35328)|5(?|1e4e/([^/]++)/([^/]++)/([^/]++)/051e4e(*:35380)|37fb/([^/]++)/([^/]++)/([^/]++)/0537fb(*:35428))|d(?|7de1/([^/]++)/([^/]++)/([^/]++)/0d7de1(*:35481)|3180/([^/]++)/([^/]++)/([^/]++)/0d3180(*:35529)|0871/([^/]++)/([^/]++)/([^/]++)/0d0871(*:35577))|cb929/([^/]++)/([^/]++)/([^/]++)/0cb929(*:35627)|2(?|a32a/([^/]++)/([^/]++)/([^/]++)/02a32a(*:35679)|4d7f/([^/]++)/([^/]++)/([^/]++)/024d7f(*:35727))|efe32/([^/]++)/([^/]++)/([^/]++)/0efe32(*:35777)|a113e/([^/]++)/([^/]++)/([^/]++)/0a113e(*:35826)|b8aff/([^/]++)/([^/]++)/([^/]++)/0b8aff(*:35875))|/a(?|7(?|6088/([^/]++)/([^/]++)/([^/]++)/a76088(*:35933)|aeed/([^/]++)/([^/]++)/([^/]++)/a7aeed(*:35981)|33fa/([^/]++)/([^/]++)/([^/]++)/a733fa(*:36029))|9a(?|665/([^/]++)/([^/]++)/([^/]++)/a9a665(*:36082)|1d5/([^/]++)/([^/]++)/([^/]++)/a9a1d5(*:36129))|8(?|6c45/([^/]++)/([^/]++)/([^/]++)/a86c45(*:36182)|849b/([^/]++)/([^/]++)/([^/]++)/a8849b(*:36230)|e(?|864/([^/]++)/([^/]++)/([^/]++)/a8e864(*:36281)|cba/([^/]++)/([^/]++)/([^/]++)/a8ecba(*:36328)))|c(?|c3e0/([^/]++)/([^/]++)/([^/]++)/acc3e0(*:36382)|f4b8/([^/]++)/([^/]++)/([^/]++)/acf4b8(*:36430))|b(?|d815/([^/]++)/([^/]++)/([^/]++)/abd815(*:36483)|233b/([^/]++)/([^/]++)/([^/]++)/ab233b(*:36531)|a3b6/([^/]++)/([^/]++)/([^/]++)/aba3b6(*:36579)|88b1/([^/]++)/([^/]++)/([^/]++)/ab88b1(*:36627))|5(?|3240/([^/]++)/([^/]++)/([^/]++)/a53240(*:36680)|cdd4/([^/]++)/([^/]++)/([^/]++)/a5cdd4(*:36728))|f(?|d(?|483/([^/]++)/([^/]++)/([^/]++)/afd483(*:36784)|a33/([^/]++)/([^/]++)/([^/]++)/afda33(*:36831))|f162/([^/]++)/([^/]++)/([^/]++)/aff162(*:36880))|e(?|0eb3/([^/]++)/([^/]++)/([^/]++)/ae0eb3(*:36933)|b313/([^/]++)/([^/]++)/([^/]++)/aeb313(*:36981))|1(?|d33d/([^/]++)/([^/]++)/([^/]++)/a1d33d(*:37034)|140a/([^/]++)/([^/]++)/([^/]++)/a1140a(*:37082))|ddfa9/([^/]++)/([^/]++)/([^/]++)/addfa9(*:37132)|6(?|7f09/([^/]++)/([^/]++)/([^/]++)/a67f09(*:37184)|4c94/([^/]++)/([^/]++)/([^/]++)/a64c94(*:37232))|a169b/([^/]++)/([^/]++)/([^/]++)/aa169b(*:37282)|4300b/([^/]++)/([^/]++)/([^/]++)/a4300b(*:37331)|3d68b/([^/]++)/([^/]++)/([^/]++)/a3d68b(*:37380))|/1(?|0(?|a(?|7cd/([^/]++)/([^/]++)/([^/]++)/10a7cd(*:37441)|5ab/([^/]++)/([^/]++)/([^/]++)/10a5ab(*:37488))|9a0c/([^/]++)/([^/]++)/([^/]++)/109a0c(*:37537))|3f320/([^/]++)/([^/]++)/([^/]++)/13f320(*:37587)|6(?|c222/([^/]++)/([^/]++)/([^/]++)/16c222(*:37639)|8908/([^/]++)/([^/]++)/([^/]++)/168908(*:37687))|5(?|de21/([^/]++)/([^/]++)/([^/]++)/15de21(*:37740)|95af/([^/]++)/([^/]++)/([^/]++)/1595af(*:37788))|1(?|b921/([^/]++)/([^/]++)/([^/]++)/11b921(*:37841)|4193/([^/]++)/([^/]++)/([^/]++)/114193(*:37889))|bb91f/([^/]++)/([^/]++)/([^/]++)/1bb91f(*:37939)|7(?|28ef/([^/]++)/([^/]++)/([^/]++)/1728ef(*:37991)|c276/([^/]++)/([^/]++)/([^/]++)/17c276(*:38039)|0c94/([^/]++)/([^/]++)/([^/]++)/170c94(*:38087))|85(?|c29/([^/]++)/([^/]++)/([^/]++)/185c29(*:38140)|e65/([^/]++)/([^/]++)/([^/]++)/185e65(*:38187))|9(?|2fc0/([^/]++)/([^/]++)/([^/]++)/192fc0(*:38240)|b(?|c91/([^/]++)/([^/]++)/([^/]++)/19bc91(*:38291)|650/([^/]++)/([^/]++)/([^/]++)/19b650(*:38338))|05ae/([^/]++)/([^/]++)/([^/]++)/1905ae(*:38387))|e(?|cfb4/([^/]++)/([^/]++)/([^/]++)/1ecfb4(*:38440)|fa39/([^/]++)/([^/]++)/([^/]++)/1efa39(*:38488)|056d/([^/]++)/([^/]++)/([^/]++)/1e056d(*:38536))|aa48f/([^/]++)/([^/]++)/([^/]++)/1aa48f(*:38586)|f(?|c214/([^/]++)/([^/]++)/([^/]++)/1fc214(*:38638)|5089/([^/]++)/([^/]++)/([^/]++)/1f5089(*:38686)|4477/([^/]++)/([^/]++)/([^/]++)/1f4477(*:38734))|c(?|c363/([^/]++)/([^/]++)/([^/]++)/1cc363(*:38787)|1d4d/([^/]++)/([^/]++)/([^/]++)/1c1d4d(*:38835)|e927/([^/]++)/([^/]++)/([^/]++)/1ce927(*:38883)))|/6(?|3(?|538f/([^/]++)/([^/]++)/([^/]++)/63538f(*:38942)|2cee/([^/]++)/([^/]++)/([^/]++)/632cee(*:38990)|95eb/([^/]++)/([^/]++)/([^/]++)/6395eb(*:39038))|9(?|421f/([^/]++)/([^/]++)/([^/]++)/69421f(*:39091)|2f93/([^/]++)/([^/]++)/([^/]++)/692f93(*:39139))|5658f/([^/]++)/([^/]++)/([^/]++)/65658f(*:39189)|4(?|7bba/([^/]++)/([^/]++)/([^/]++)/647bba(*:39241)|223c/([^/]++)/([^/]++)/([^/]++)/64223c(*:39289))|e(?|2713/([^/]++)/([^/]++)/([^/]++)/6e2713(*:39342)|0721/([^/]++)/([^/]++)/([^/]++)/6e0721(*:39390)|7b33/([^/]++)/([^/]++)/([^/]++)/6e7b33(*:39438))|0(?|5ff7/([^/]++)/([^/]++)/([^/]++)/605ff7(*:39491)|8159/([^/]++)/([^/]++)/([^/]++)/608159(*:39539))|a(?|ca97/([^/]++)/([^/]++)/([^/]++)/6aca97(*:39592)|10bb/([^/]++)/([^/]++)/([^/]++)/6a10bb(*:39640)|ab12/([^/]++)/([^/]++)/([^/]++)/6aab12(*:39688))|7(?|66aa/([^/]++)/([^/]++)/([^/]++)/6766aa(*:39741)|e103/([^/]++)/([^/]++)/([^/]++)/67e103(*:39789)|d(?|96d/([^/]++)/([^/]++)/([^/]++)/67d96d(*:39840)|16d/([^/]++)/([^/]++)/([^/]++)/67d16d(*:39887))|0e8a/([^/]++)/([^/]++)/([^/]++)/670e8a(*:39936)|7e09/([^/]++)/([^/]++)/([^/]++)/677e09(*:39984))|8(?|264b/([^/]++)/([^/]++)/([^/]++)/68264b(*:40037)|053a/([^/]++)/([^/]++)/([^/]++)/68053a(*:40085))|c(?|2979/([^/]++)/([^/]++)/([^/]++)/6c2979(*:40138)|d67d/([^/]++)/([^/]++)/([^/]++)/6cd67d(*:40186)|3cf7/([^/]++)/([^/]++)/([^/]++)/6c3cf7(*:40234)|fe0e/([^/]++)/([^/]++)/([^/]++)/6cfe0e(*:40282))|bc24f/([^/]++)/([^/]++)/([^/]++)/6bc24f(*:40332)|f2268/([^/]++)/([^/]++)/([^/]++)/6f2268(*:40381)|1b4a6/([^/]++)/([^/]++)/([^/]++)/61b4a6(*:40430)|21461/([^/]++)/([^/]++)/([^/]++)/621461(*:40479)|d0f84/([^/]++)/([^/]++)/([^/]++)/6d0f84(*:40528)|60229/([^/]++)/([^/]++)/([^/]++)/660229(*:40577))|/c(?|f(?|6735/([^/]++)/([^/]++)/([^/]++)/cf6735(*:40635)|bce4/([^/]++)/([^/]++)/([^/]++)/cfbce4(*:40683))|3(?|99(?|86/([^/]++)/([^/]++)/([^/]++)/c39986(*:40739)|2e/([^/]++)/([^/]++)/([^/]++)/c3992e(*:40785))|61bc/([^/]++)/([^/]++)/([^/]++)/c361bc(*:40834)|2d9b/([^/]++)/([^/]++)/([^/]++)/c32d9b(*:40882))|75b6f/([^/]++)/([^/]++)/([^/]++)/c75b6f(*:40932)|c(?|b(?|1d4/([^/]++)/([^/]++)/([^/]++)/ccb1d4(*:40987)|098/([^/]++)/([^/]++)/([^/]++)/ccb098(*:41034))|c0aa/([^/]++)/([^/]++)/([^/]++)/ccc0aa(*:41083)|1aa4/([^/]++)/([^/]++)/([^/]++)/cc1aa4(*:41131))|b(?|cb58/([^/]++)/([^/]++)/([^/]++)/cbcb58(*:41184)|b6a3/([^/]++)/([^/]++)/([^/]++)/cbb6a3(*:41232))|9892a/([^/]++)/([^/]++)/([^/]++)/c9892a(*:41282)|6e19e/([^/]++)/([^/]++)/([^/]++)/c6e19e(*:41331)|dc0d6/([^/]++)/([^/]++)/([^/]++)/cdc0d6(*:41380)|5ab0b/([^/]++)/([^/]++)/([^/]++)/c5ab0b(*:41429)|a(?|9c26/([^/]++)/([^/]++)/([^/]++)/ca9c26(*:41481)|8155/([^/]++)/([^/]++)/([^/]++)/ca8155(*:41529)|7591/([^/]++)/([^/]++)/([^/]++)/ca7591(*:41577))|0(?|6d06/([^/]++)/([^/]++)/([^/]++)/c06d06(*:41630)|f168/([^/]++)/([^/]++)/([^/]++)/c0f168(*:41678))|8(?|ed21/([^/]++)/([^/]++)/([^/]++)/c8ed21(*:41731)|fbbc/([^/]++)/([^/]++)/([^/]++)/c8fbbc(*:41779)|c41c/([^/]++)/([^/]++)/([^/]++)/c8c41c(*:41827))|15da1/([^/]++)/([^/]++)/([^/]++)/c15da1(*:41877)|2(?|626d/([^/]++)/([^/]++)/([^/]++)/c2626d(*:41929)|aee8/([^/]++)/([^/]++)/([^/]++)/c2aee8(*:41977)|2abf/([^/]++)/([^/]++)/([^/]++)/c22abf(*:42025))|e78d1/([^/]++)/([^/]++)/([^/]++)/ce78d1(*:42075)|4(?|015b/([^/]++)/([^/]++)/([^/]++)/c4015b(*:42127)|b31c/([^/]++)/([^/]++)/([^/]++)/c4b31c(*:42175)))|/8(?|5(?|422a/([^/]++)/([^/]++)/([^/]++)/85422a(*:42234)|1ddf/([^/]++)/([^/]++)/([^/]++)/851ddf(*:42282)|fc37/([^/]++)/([^/]++)/([^/]++)/85fc37(*:42330))|1(?|4481/([^/]++)/([^/]++)/([^/]++)/814481(*:42383)|e74d/([^/]++)/([^/]++)/([^/]++)/81e74d(*:42431))|d(?|3(?|420/([^/]++)/([^/]++)/([^/]++)/8d3420(*:42487)|17b/([^/]++)/([^/]++)/([^/]++)/8d317b(*:42534))|f707/([^/]++)/([^/]++)/([^/]++)/8df707(*:42583)|6dc3/([^/]++)/([^/]++)/([^/]++)/8d6dc3(*:42631))|e(?|efcf/([^/]++)/([^/]++)/([^/]++)/8eefcf(*:42684)|bda5/([^/]++)/([^/]++)/([^/]++)/8ebda5(*:42732)|82ab/([^/]++)/([^/]++)/([^/]++)/8e82ab(*:42780))|b(?|16eb/([^/]++)/([^/]++)/([^/]++)/8b16eb(*:42833)|6dd7/([^/]++)/([^/]++)/([^/]++)/8b6dd7(*:42881)|5040/([^/]++)/([^/]++)/([^/]++)/8b5040(*:42929))|c(?|7bbb/([^/]++)/([^/]++)/([^/]++)/8c7bbb(*:42982)|6744/([^/]++)/([^/]++)/([^/]++)/8c6744(*:43030)|235f/([^/]++)/([^/]++)/([^/]++)/8c235f(*:43078))|8(?|4d24/([^/]++)/([^/]++)/([^/]++)/884d24(*:43131)|ae63/([^/]++)/([^/]++)/([^/]++)/88ae63(*:43179))|7(?|5715/([^/]++)/([^/]++)/([^/]++)/875715(*:43232)|2488/([^/]++)/([^/]++)/([^/]++)/872488(*:43280))|4(?|1172/([^/]++)/([^/]++)/([^/]++)/841172(*:43333)|6c26/([^/]++)/([^/]++)/([^/]++)/846c26(*:43381)|f7e6/([^/]++)/([^/]++)/([^/]++)/84f7e6(*:43429)|7cc5/([^/]++)/([^/]++)/([^/]++)/847cc5(*:43477))|f(?|ecb2/([^/]++)/([^/]++)/([^/]++)/8fecb2(*:43530)|7d80/([^/]++)/([^/]++)/([^/]++)/8f7d80(*:43578)|468c/([^/]++)/([^/]++)/([^/]++)/8f468c(*:43626))|a0e11/([^/]++)/([^/]++)/([^/]++)/8a0e11(*:43676)|2(?|f2b3/([^/]++)/([^/]++)/([^/]++)/82f2b3(*:43728)|489c/([^/]++)/([^/]++)/([^/]++)/82489c(*:43776))|6(?|b122/([^/]++)/([^/]++)/([^/]++)/86b122(*:43829)|0320/([^/]++)/([^/]++)/([^/]++)/860320(*:43877))|9(?|2c91/([^/]++)/([^/]++)/([^/]++)/892c91(*:43930)|fcd0/([^/]++)/([^/]++)/([^/]++)/89fcd0(*:43978))|065d0/([^/]++)/([^/]++)/([^/]++)/8065d0(*:44028))|/d(?|6(?|4a34/([^/]++)/([^/]++)/([^/]++)/d64a34(*:44086)|c651/([^/]++)/([^/]++)/([^/]++)/d6c651(*:44134))|f(?|877f/([^/]++)/([^/]++)/([^/]++)/df877f(*:44187)|263d/([^/]++)/([^/]++)/([^/]++)/df263d(*:44235)|7f28/([^/]++)/([^/]++)/([^/]++)/df7f28(*:44283)|6d23/([^/]++)/([^/]++)/([^/]++)/df6d23(*:44331))|b(?|85e2/([^/]++)/([^/]++)/([^/]++)/db85e2(*:44384)|e272/([^/]++)/([^/]++)/([^/]++)/dbe272(*:44432))|d(?|45(?|85/([^/]++)/([^/]++)/([^/]++)/dd4585(*:44488)|04/([^/]++)/([^/]++)/([^/]++)/dd4504(*:44534))|8eb9/([^/]++)/([^/]++)/([^/]++)/dd8eb9(*:44583))|a(?|ca41/([^/]++)/([^/]++)/([^/]++)/daca41(*:44636)|8ce5/([^/]++)/([^/]++)/([^/]++)/da8ce5(*:44684)|0d11/([^/]++)/([^/]++)/([^/]++)/da0d11(*:44732))|4(?|90d7/([^/]++)/([^/]++)/([^/]++)/d490d7(*:44785)|c2e4/([^/]++)/([^/]++)/([^/]++)/d4c2e4(*:44833))|8(?|6ea6/([^/]++)/([^/]++)/([^/]++)/d86ea6(*:44886)|40cc/([^/]++)/([^/]++)/([^/]++)/d840cc(*:44934))|c(?|82d6/([^/]++)/([^/]++)/([^/]++)/dc82d6(*:44987)|6a70/([^/]++)/([^/]++)/([^/]++)/dc6a70(*:45035)|5689/([^/]++)/([^/]++)/([^/]++)/dc5689(*:45083))|7(?|a728/([^/]++)/([^/]++)/([^/]++)/d7a728(*:45136)|0732/([^/]++)/([^/]++)/([^/]++)/d70732(*:45184)|9aac/([^/]++)/([^/]++)/([^/]++)/d79aac(*:45232))|14220/([^/]++)/([^/]++)/([^/]++)/d14220(*:45282)|5(?|cfea/([^/]++)/([^/]++)/([^/]++)/d5cfea(*:45334)|8072/([^/]++)/([^/]++)/([^/]++)/d58072(*:45382)|54f7/([^/]++)/([^/]++)/([^/]++)/d554f7(*:45430)|16b1/([^/]++)/([^/]++)/([^/]++)/d516b1(*:45478)|6b9f/([^/]++)/([^/]++)/([^/]++)/d56b9f(*:45526))|045c5/([^/]++)/([^/]++)/([^/]++)/d045c5(*:45576)|2(?|ed45/([^/]++)/([^/]++)/([^/]++)/d2ed45(*:45628)|40e3/([^/]++)/([^/]++)/([^/]++)/d240e3(*:45676))|93ed5/([^/]++)/([^/]++)/([^/]++)/d93ed5(*:45726))|/7(?|b(?|cdf7/([^/]++)/([^/]++)/([^/]++)/7bcdf7(*:45784)|13b2/([^/]++)/([^/]++)/([^/]++)/7b13b2(*:45832))|dcd34/([^/]++)/([^/]++)/([^/]++)/7dcd34(*:45882)|f(?|24d2/([^/]++)/([^/]++)/([^/]++)/7f24d2(*:45934)|5d04/([^/]++)/([^/]++)/([^/]++)/7f5d04(*:45982)|1171/([^/]++)/([^/]++)/([^/]++)/7f1171(*:46030)|a732/([^/]++)/([^/]++)/([^/]++)/7fa732(*:46078))|6(?|6ebc/([^/]++)/([^/]++)/([^/]++)/766ebc(*:46131)|34ea/([^/]++)/([^/]++)/([^/]++)/7634ea(*:46179))|750ca/([^/]++)/([^/]++)/([^/]++)/7750ca(*:46229)|1(?|a(?|3cb/([^/]++)/([^/]++)/([^/]++)/71a3cb(*:46284)|d16/([^/]++)/([^/]++)/([^/]++)/71ad16(*:46331))|43d7/([^/]++)/([^/]++)/([^/]++)/7143d7(*:46380))|88d98/([^/]++)/([^/]++)/([^/]++)/788d98(*:46430)|2(?|da7f/([^/]++)/([^/]++)/([^/]++)/72da7f(*:46482)|50eb/([^/]++)/([^/]++)/([^/]++)/7250eb(*:46530))|c(?|590f/([^/]++)/([^/]++)/([^/]++)/7c590f(*:46583)|e328/([^/]++)/([^/]++)/([^/]++)/7ce328(*:46631))|a5392/([^/]++)/([^/]++)/([^/]++)/7a5392(*:46681)|95c7a/([^/]++)/([^/]++)/([^/]++)/795c7a(*:46730)|504ad/([^/]++)/([^/]++)/([^/]++)/7504ad(*:46779)|04afe/([^/]++)/([^/]++)/([^/]++)/704afe(*:46828)|4bba2/([^/]++)/([^/]++)/([^/]++)/74bba2(*:46877))|/9(?|b(?|72e3/([^/]++)/([^/]++)/([^/]++)/9b72e3(*:46935)|698e/([^/]++)/([^/]++)/([^/]++)/9b698e(*:46983))|7e852/([^/]++)/([^/]++)/([^/]++)/97e852(*:47033)|4c7bb/([^/]++)/([^/]++)/([^/]++)/94c7bb(*:47082)|9(?|c5e0/([^/]++)/([^/]++)/([^/]++)/99c5e0(*:47134)|6a7f/([^/]++)/([^/]++)/([^/]++)/996a7f(*:47182)|bcfc/([^/]++)/([^/]++)/([^/]++)/99bcfc(*:47230)|0827/([^/]++)/([^/]++)/([^/]++)/990827(*:47278))|a(?|d6aa/([^/]++)/([^/]++)/([^/]++)/9ad6aa(*:47331)|b0d8/([^/]++)/([^/]++)/([^/]++)/9ab0d8(*:47379))|c(?|f81d/([^/]++)/([^/]++)/([^/]++)/9cf81d(*:47432)|c138/([^/]++)/([^/]++)/([^/]++)/9cc138(*:47480)|82c7/([^/]++)/([^/]++)/([^/]++)/9c82c7(*:47528)|0180/([^/]++)/([^/]++)/([^/]++)/9c0180(*:47576))|f(?|396f/([^/]++)/([^/]++)/([^/]++)/9f396f(*:47629)|e859/([^/]++)/([^/]++)/([^/]++)/9fe859(*:47677)|53d8/([^/]++)/([^/]++)/([^/]++)/9f53d8(*:47725))|12d2b/([^/]++)/([^/]++)/([^/]++)/912d2b(*:47775)|59a55/([^/]++)/([^/]++)/([^/]++)/959a55(*:47824)|6(?|ea64/([^/]++)/([^/]++)/([^/]++)/96ea64(*:47876)|b9bf/([^/]++)/([^/]++)/([^/]++)/96b9bf(*:47924))|e3cfc/([^/]++)/([^/]++)/([^/]++)/9e3cfc(*:47974)|2(?|fb0c/([^/]++)/([^/]++)/([^/]++)/92fb0c(*:48026)|262b/([^/]++)/([^/]++)/([^/]++)/92262b(*:48074)|32fe/([^/]++)/([^/]++)/([^/]++)/9232fe(*:48122)|977a/([^/]++)/([^/]++)/([^/]++)/92977a(*:48170))|8d6f5/([^/]++)/([^/]++)/([^/]++)/98d6f5(*:48220)|0794e/([^/]++)/([^/]++)/([^/]++)/90794e(*:48269)|34815/([^/]++)/([^/]++)/([^/]++)/934815(*:48318))|/4(?|e(?|4b5f/([^/]++)/([^/]++)/([^/]++)/4e4b5f(*:48376)|a06f/([^/]++)/([^/]++)/([^/]++)/4ea06f(*:48424)|0(?|928/([^/]++)/([^/]++)/([^/]++)/4e0928(*:48475)|cb6/([^/]++)/([^/]++)/([^/]++)/4e0cb6(*:48522)))|6922a/([^/]++)/([^/]++)/([^/]++)/46922a(*:48573)|4(?|c4c1/([^/]++)/([^/]++)/([^/]++)/44c4c1(*:48625)|3cb0/([^/]++)/([^/]++)/([^/]++)/443cb0(*:48673))|8ab2f/([^/]++)/([^/]++)/([^/]++)/48ab2f(*:48723)|5(?|645a/([^/]++)/([^/]++)/([^/]++)/45645a(*:48775)|58db/([^/]++)/([^/]++)/([^/]++)/4558db(*:48823))|2e77b/([^/]++)/([^/]++)/([^/]++)/42e77b(*:48873)|c27ce/([^/]++)/([^/]++)/([^/]++)/4c27ce(*:48922)|f(?|fce0/([^/]++)/([^/]++)/([^/]++)/4ffce0(*:48974)|ac9b/([^/]++)/([^/]++)/([^/]++)/4fac9b(*:49022))|a47d2/([^/]++)/([^/]++)/([^/]++)/4a47d2(*:49072)|70e7a/([^/]++)/([^/]++)/([^/]++)/470e7a(*:49121)|b(?|0(?|4a6/([^/]++)/([^/]++)/([^/]++)/4b04a6(*:49176)|a59/([^/]++)/([^/]++)/([^/]++)/4b0a59(*:49223)|250/([^/]++)/([^/]++)/([^/]++)/4b0250(*:49270))|6538/([^/]++)/([^/]++)/([^/]++)/4b6538(*:49319))|3(?|f(?|a7f/([^/]++)/([^/]++)/([^/]++)/43fa7f(*:49375)|eae/([^/]++)/([^/]++)/([^/]++)/43feae(*:49422))|0c36/([^/]++)/([^/]++)/([^/]++)/430c36(*:49471)|7d7d/([^/]++)/([^/]++)/([^/]++)/437d7d(*:49519)|1135/([^/]++)/([^/]++)/([^/]++)/431135(*:49567))|d(?|5b99/([^/]++)/([^/]++)/([^/]++)/4d5b99(*:49620)|aa3d/([^/]++)/([^/]++)/([^/]++)/4daa3d(*:49668))|9c9ad/([^/]++)/([^/]++)/([^/]++)/49c9ad(*:49718)))/?$"), } #### { "54" => [{ART::Parameters.new({"_route" => "_0"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "102" => [{ART::Parameters.new({"_route" => "_190"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "147" => [{ART::Parameters.new({"_route" => "_478"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "194" => [{ART::Parameters.new({"_route" => "_259"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "240" => [{ART::Parameters.new({"_route" => "_368"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "291" => [{ART::Parameters.new({"_route" => "_1"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "337" => [{ART::Parameters.new({"_route" => "_116"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "383" => [{ART::Parameters.new({"_route" => "_490"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "434" => [{ART::Parameters.new({"_route" => "_2"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "480" => [{ART::Parameters.new({"_route" => "_124"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "526" => [{ART::Parameters.new({"_route" => "_389"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "577" => [{ART::Parameters.new({"_route" => "_8"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "623" => [{ART::Parameters.new({"_route" => "_104"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "677" => [{ART::Parameters.new({"_route" => "_12"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "722" => [{ART::Parameters.new({"_route" => "_442"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "769" => [{ART::Parameters.new({"_route" => "_253"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "820" => [{ART::Parameters.new({"_route" => "_13"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "866" => [{ART::Parameters.new({"_route" => "_254"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "912" => [{ART::Parameters.new({"_route" => "_347"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "963" => [{ART::Parameters.new({"_route" => "_16"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1009" => [{ART::Parameters.new({"_route" => "_87"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1058" => [{ART::Parameters.new({"_route" => "_31"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1109" => [{ART::Parameters.new({"_route" => "_50"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1156" => [{ART::Parameters.new({"_route" => "_219"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1203" => [{ART::Parameters.new({"_route" => "_332"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1250" => [{ART::Parameters.new({"_route" => "_359"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1302" => [{ART::Parameters.new({"_route" => "_183"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1349" => [{ART::Parameters.new({"_route" => "_500"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1401" => [{ART::Parameters.new({"_route" => "_214"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1448" => [{ART::Parameters.new({"_route" => "_321"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1497" => [{ART::Parameters.new({"_route" => "_243"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1545" => [{ART::Parameters.new({"_route" => "_328"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1596" => [{ART::Parameters.new({"_route" => "_362"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1643" => [{ART::Parameters.new({"_route" => "_488"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1701" => [{ART::Parameters.new({"_route" => "_3"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1751" => [{ART::Parameters.new({"_route" => "_102"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1797" => [{ART::Parameters.new({"_route" => "_220"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1845" => [{ART::Parameters.new({"_route" => "_127"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1897" => [{ART::Parameters.new({"_route" => "_5"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1944" => [{ART::Parameters.new({"_route" => "_242"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "1991" => [{ART::Parameters.new({"_route" => "_397"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2038" => [{ART::Parameters.new({"_route" => "_454"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2090" => [{ART::Parameters.new({"_route" => "_34"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2137" => [{ART::Parameters.new({"_route" => "_281"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2189" => [{ART::Parameters.new({"_route" => "_64"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2236" => [{ART::Parameters.new({"_route" => "_205"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2291" => [{ART::Parameters.new({"_route" => "_71"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2337" => [{ART::Parameters.new({"_route" => "_203"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2385" => [{ART::Parameters.new({"_route" => "_97"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2437" => [{ART::Parameters.new({"_route" => "_98"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2484" => [{ART::Parameters.new({"_route" => "_267"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2531" => [{ART::Parameters.new({"_route" => "_309"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2586" => [{ART::Parameters.new({"_route" => "_117"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2631" => [{ART::Parameters.new({"_route" => "_211"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2679" => [{ART::Parameters.new({"_route" => "_484"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2731" => [{ART::Parameters.new({"_route" => "_139"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2778" => [{ART::Parameters.new({"_route" => "_421"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2830" => [{ART::Parameters.new({"_route" => "_185"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2877" => [{ART::Parameters.new({"_route" => "_439"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2926" => [{ART::Parameters.new({"_route" => "_218"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "2977" => [{ART::Parameters.new({"_route" => "_233"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3024" => [{ART::Parameters.new({"_route" => "_483"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3073" => [{ART::Parameters.new({"_route" => "_265"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3124" => [{ART::Parameters.new({"_route" => "_299"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3171" => [{ART::Parameters.new({"_route" => "_351"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3218" => [{ART::Parameters.new({"_route" => "_472"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3267" => [{ART::Parameters.new({"_route" => "_360"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3315" => [{ART::Parameters.new({"_route" => "_466"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3372" => [{ART::Parameters.new({"_route" => "_4"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3419" => [{ART::Parameters.new({"_route" => "_142"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3466" => [{ART::Parameters.new({"_route" => "_151"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3513" => [{ART::Parameters.new({"_route" => "_308"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3560" => [{ART::Parameters.new({"_route" => "_440"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3612" => [{ART::Parameters.new({"_route" => "_14"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3659" => [{ART::Parameters.new({"_route" => "_358"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3711" => [{ART::Parameters.new({"_route" => "_37"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3758" => [{ART::Parameters.new({"_route" => "_38"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3805" => [{ART::Parameters.new({"_route" => "_146"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3852" => [{ART::Parameters.new({"_route" => "_194"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3899" => [{ART::Parameters.new({"_route" => "_487"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3948" => [{ART::Parameters.new({"_route" => "_42"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "3999" => [{ART::Parameters.new({"_route" => "_54"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4046" => [{ART::Parameters.new({"_route" => "_326"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4098" => [{ART::Parameters.new({"_route" => "_68"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4145" => [{ART::Parameters.new({"_route" => "_108"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4197" => [{ART::Parameters.new({"_route" => "_74"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4244" => [{ART::Parameters.new({"_route" => "_315"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4291" => [{ART::Parameters.new({"_route" => "_374"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4343" => [{ART::Parameters.new({"_route" => "_99"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4390" => [{ART::Parameters.new({"_route" => "_238"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4442" => [{ART::Parameters.new({"_route" => "_107"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4489" => [{ART::Parameters.new({"_route" => "_409"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4541" => [{ART::Parameters.new({"_route" => "_122"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4588" => [{ART::Parameters.new({"_route" => "_379"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4635" => [{ART::Parameters.new({"_route" => "_390"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4687" => [{ART::Parameters.new({"_route" => "_171"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4734" => [{ART::Parameters.new({"_route" => "_260"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4781" => [{ART::Parameters.new({"_route" => "_434"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4830" => [{ART::Parameters.new({"_route" => "_189"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4878" => [{ART::Parameters.new({"_route" => "_467"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4935" => [{ART::Parameters.new({"_route" => "_6"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "4982" => [{ART::Parameters.new({"_route" => "_286"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5029" => [{ART::Parameters.new({"_route" => "_438"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5081" => [{ART::Parameters.new({"_route" => "_19"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5131" => [{ART::Parameters.new({"_route" => "_24"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5177" => [{ART::Parameters.new({"_route" => "_172"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5230" => [{ART::Parameters.new({"_route" => "_33"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5277" => [{ART::Parameters.new({"_route" => "_400"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5324" => [{ART::Parameters.new({"_route" => "_427"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5376" => [{ART::Parameters.new({"_route" => "_35"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5423" => [{ART::Parameters.new({"_route" => "_156"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5475" => [{ART::Parameters.new({"_route" => "_36"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5522" => [{ART::Parameters.new({"_route" => "_251"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5574" => [{ART::Parameters.new({"_route" => "_43"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5621" => [{ART::Parameters.new({"_route" => "_292"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5668" => [{ART::Parameters.new({"_route" => "_411"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5720" => [{ART::Parameters.new({"_route" => "_69"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5767" => [{ART::Parameters.new({"_route" => "_159"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5814" => [{ART::Parameters.new({"_route" => "_170"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5861" => [{ART::Parameters.new({"_route" => "_376"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5913" => [{ART::Parameters.new({"_route" => "_131"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "5960" => [{ART::Parameters.new({"_route" => "_446"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6015" => [{ART::Parameters.new({"_route" => "_140"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6061" => [{ART::Parameters.new({"_route" => "_353"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6112" => [{ART::Parameters.new({"_route" => "_224"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6158" => [{ART::Parameters.new({"_route" => "_346"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6204" => [{ART::Parameters.new({"_route" => "_443"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6254" => [{ART::Parameters.new({"_route" => "_154"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6305" => [{ART::Parameters.new({"_route" => "_212"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6352" => [{ART::Parameters.new({"_route" => "_313"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6399" => [{ART::Parameters.new({"_route" => "_395"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6446" => [{ART::Parameters.new({"_route" => "_441"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6498" => [{ART::Parameters.new({"_route" => "_223"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6545" => [{ART::Parameters.new({"_route" => "_303"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6594" => [{ART::Parameters.new({"_route" => "_410"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6642" => [{ART::Parameters.new({"_route" => "_494"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6702" => [{ART::Parameters.new({"_route" => "_7"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6748" => [{ART::Parameters.new({"_route" => "_268"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6796" => [{ART::Parameters.new({"_route" => "_178"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6843" => [{ART::Parameters.new({"_route" => "_179"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6890" => [{ART::Parameters.new({"_route" => "_416"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6942" => [{ART::Parameters.new({"_route" => "_25"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "6989" => [{ART::Parameters.new({"_route" => "_307"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7036" => [{ART::Parameters.new({"_route" => "_387"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7083" => [{ART::Parameters.new({"_route" => "_471"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7132" => [{ART::Parameters.new({"_route" => "_90"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7183" => [{ART::Parameters.new({"_route" => "_95"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7230" => [{ART::Parameters.new({"_route" => "_338"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7277" => [{ART::Parameters.new({"_route" => "_401"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7329" => [{ART::Parameters.new({"_route" => "_147"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7376" => [{ART::Parameters.new({"_route" => "_319"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7423" => [{ART::Parameters.new({"_route" => "_354"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7470" => [{ART::Parameters.new({"_route" => "_428"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7522" => [{ART::Parameters.new({"_route" => "_162"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7572" => [{ART::Parameters.new({"_route" => "_175"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7618" => [{ART::Parameters.new({"_route" => "_455"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7666" => [{ART::Parameters.new({"_route" => "_355"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7718" => [{ART::Parameters.new({"_route" => "_197"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7768" => [{ART::Parameters.new({"_route" => "_202"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7813" => [{ART::Parameters.new({"_route" => "_489"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7863" => [{ART::Parameters.new({"_route" => "_199"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7914" => [{ART::Parameters.new({"_route" => "_263"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "7961" => [{ART::Parameters.new({"_route" => "_406"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8010" => [{ART::Parameters.new({"_route" => "_289"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8058" => [{ART::Parameters.new({"_route" => "_325"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8106" => [{ART::Parameters.new({"_route" => "_378"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8154" => [{ART::Parameters.new({"_route" => "_468"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8211" => [{ART::Parameters.new({"_route" => "_9"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8258" => [{ART::Parameters.new({"_route" => "_216"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8307" => [{ART::Parameters.new({"_route" => "_26"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8355" => [{ART::Parameters.new({"_route" => "_62"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8406" => [{ART::Parameters.new({"_route" => "_81"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8453" => [{ART::Parameters.new({"_route" => "_318"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8505" => [{ART::Parameters.new({"_route" => "_121"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8551" => [{ART::Parameters.new({"_route" => "_182"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8603" => [{ART::Parameters.new({"_route" => "_136"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8650" => [{ART::Parameters.new({"_route" => "_415"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8697" => [{ART::Parameters.new({"_route" => "_457"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8744" => [{ART::Parameters.new({"_route" => "_463"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8796" => [{ART::Parameters.new({"_route" => "_148"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8843" => [{ART::Parameters.new({"_route" => "_273"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8892" => [{ART::Parameters.new({"_route" => "_284"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8940" => [{ART::Parameters.new({"_route" => "_288"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "8991" => [{ART::Parameters.new({"_route" => "_295"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9038" => [{ART::Parameters.new({"_route" => "_305"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9085" => [{ART::Parameters.new({"_route" => "_453"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9134" => [{ART::Parameters.new({"_route" => "_340"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9185" => [{ART::Parameters.new({"_route" => "_371"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9232" => [{ART::Parameters.new({"_route" => "_417"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9284" => [{ART::Parameters.new({"_route" => "_382"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9331" => [{ART::Parameters.new({"_route" => "_404"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9389" => [{ART::Parameters.new({"_route" => "_10"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9436" => [{ART::Parameters.new({"_route" => "_279"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9483" => [{ART::Parameters.new({"_route" => "_377"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9535" => [{ART::Parameters.new({"_route" => "_39"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9582" => [{ART::Parameters.new({"_route" => "_40"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9629" => [{ART::Parameters.new({"_route" => "_264"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9676" => [{ART::Parameters.new({"_route" => "_449"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9728" => [{ART::Parameters.new({"_route" => "_46"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9775" => [{ART::Parameters.new({"_route" => "_257"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9822" => [{ART::Parameters.new({"_route" => "_274"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9869" => [{ART::Parameters.new({"_route" => "_388"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9921" => [{ART::Parameters.new({"_route" => "_53"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "9968" => [{ART::Parameters.new({"_route" => "_345"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10020" => [{ART::Parameters.new({"_route" => "_73"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10068" => [{ART::Parameters.new({"_route" => "_296"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10121" => [{ART::Parameters.new({"_route" => "_75"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10169" => [{ART::Parameters.new({"_route" => "_458"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10225" => [{ART::Parameters.new({"_route" => "_79"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10272" => [{ART::Parameters.new({"_route" => "_129"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10319" => [{ART::Parameters.new({"_route" => "_418"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10368" => [{ART::Parameters.new({"_route" => "_225"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10416" => [{ART::Parameters.new({"_route" => "_479"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10466" => [{ART::Parameters.new({"_route" => "_120"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10515" => [{ART::Parameters.new({"_route" => "_276"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10564" => [{ART::Parameters.new({"_route" => "_370"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10616" => [{ART::Parameters.new({"_route" => "_385"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10664" => [{ART::Parameters.new({"_route" => "_469"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10714" => [{ART::Parameters.new({"_route" => "_435"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10772" => [{ART::Parameters.new({"_route" => "_11"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10820" => [{ART::Parameters.new({"_route" => "_105"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10868" => [{ART::Parameters.new({"_route" => "_132"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10921" => [{ART::Parameters.new({"_route" => "_18"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "10969" => [{ART::Parameters.new({"_route" => "_210"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11017" => [{ART::Parameters.new({"_route" => "_329"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11073" => [{ART::Parameters.new({"_route" => "_29"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11120" => [{ART::Parameters.new({"_route" => "_480"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11169" => [{ART::Parameters.new({"_route" => "_426"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11222" => [{ART::Parameters.new({"_route" => "_32"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11270" => [{ART::Parameters.new({"_route" => "_217"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11318" => [{ART::Parameters.new({"_route" => "_275"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11371" => [{ART::Parameters.new({"_route" => "_45"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11419" => [{ART::Parameters.new({"_route" => "_157"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11467" => [{ART::Parameters.new({"_route" => "_184"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11515" => [{ART::Parameters.new({"_route" => "_250"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11563" => [{ART::Parameters.new({"_route" => "_356"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11616" => [{ART::Parameters.new({"_route" => "_47"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11664" => [{ART::Parameters.new({"_route" => "_445"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11714" => [{ART::Parameters.new({"_route" => "_48"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11766" => [{ART::Parameters.new({"_route" => "_58"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11814" => [{ART::Parameters.new({"_route" => "_414"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11862" => [{ART::Parameters.new({"_route" => "_431"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11915" => [{ART::Parameters.new({"_route" => "_84"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "11963" => [{ART::Parameters.new({"_route" => "_294"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12011" => [{ART::Parameters.new({"_route" => "_336"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12059" => [{ART::Parameters.new({"_route" => "_465"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12112" => [{ART::Parameters.new({"_route" => "_103"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12160" => [{ART::Parameters.new({"_route" => "_111"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12208" => [{ART::Parameters.new({"_route" => "_207"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12256" => [{ART::Parameters.new({"_route" => "_402"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12309" => [{ART::Parameters.new({"_route" => "_230"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12356" => [{ART::Parameters.new({"_route" => "_331"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12406" => [{ART::Parameters.new({"_route" => "_248"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12455" => [{ART::Parameters.new({"_route" => "_282"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12513" => [{ART::Parameters.new({"_route" => "_15"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12561" => [{ART::Parameters.new({"_route" => "_130"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12609" => [{ART::Parameters.new({"_route" => "_231"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12657" => [{ART::Parameters.new({"_route" => "_365"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12705" => [{ART::Parameters.new({"_route" => "_448"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12758" => [{ART::Parameters.new({"_route" => "_20"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12806" => [{ART::Parameters.new({"_route" => "_93"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12854" => [{ART::Parameters.new({"_route" => "_186"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12902" => [{ART::Parameters.new({"_route" => "_460"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "12955" => [{ART::Parameters.new({"_route" => "_52"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13003" => [{ART::Parameters.new({"_route" => "_447"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13056" => [{ART::Parameters.new({"_route" => "_56"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13104" => [{ART::Parameters.new({"_route" => "_133"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13152" => [{ART::Parameters.new({"_route" => "_297"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13205" => [{ART::Parameters.new({"_route" => "_82"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13253" => [{ART::Parameters.new({"_route" => "_165"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13301" => [{ART::Parameters.new({"_route" => "_213"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13351" => [{ART::Parameters.new({"_route" => "_86"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13403" => [{ART::Parameters.new({"_route" => "_92"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13450" => [{ART::Parameters.new({"_route" => "_280"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13500" => [{ART::Parameters.new({"_route" => "_143"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13549" => [{ART::Parameters.new({"_route" => "_177"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13601" => [{ART::Parameters.new({"_route" => "_188"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13649" => [{ART::Parameters.new({"_route" => "_311"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13697" => [{ART::Parameters.new({"_route" => "_350"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13750" => [{ART::Parameters.new({"_route" => "_226"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13798" => [{ART::Parameters.new({"_route" => "_291"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13851" => [{ART::Parameters.new({"_route" => "_244"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13898" => [{ART::Parameters.new({"_route" => "_287"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13951" => [{ART::Parameters.new({"_route" => "_300"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "13999" => [{ART::Parameters.new({"_route" => "_451"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14047" => [{ART::Parameters.new({"_route" => "_452"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14095" => [{ART::Parameters.new({"_route" => "_481"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14145" => [{ART::Parameters.new({"_route" => "_312"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14203" => [{ART::Parameters.new({"_route" => "_17"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14251" => [{ART::Parameters.new({"_route" => "_227"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14299" => [{ART::Parameters.new({"_route" => "_393"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14349" => [{ART::Parameters.new({"_route" => "_57"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14401" => [{ART::Parameters.new({"_route" => "_61"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14449" => [{ART::Parameters.new({"_route" => "_112"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14500" => [{ART::Parameters.new({"_route" => "_135"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14547" => [{ART::Parameters.new({"_route" => "_271"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14596" => [{ART::Parameters.new({"_route" => "_459"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14649" => [{ART::Parameters.new({"_route" => "_67"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14697" => [{ART::Parameters.new({"_route" => "_113"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14745" => [{ART::Parameters.new({"_route" => "_497"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14795" => [{ART::Parameters.new({"_route" => "_70"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14847" => [{ART::Parameters.new({"_route" => "_89"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14895" => [{ART::Parameters.new({"_route" => "_128"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14948" => [{ART::Parameters.new({"_route" => "_150"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "14996" => [{ART::Parameters.new({"_route" => "_166"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15047" => [{ART::Parameters.new({"_route" => "_206"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15094" => [{ART::Parameters.new({"_route" => "_419"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15148" => [{ART::Parameters.new({"_route" => "_201"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15196" => [{ART::Parameters.new({"_route" => "_314"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15244" => [{ART::Parameters.new({"_route" => "_429"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15297" => [{ART::Parameters.new({"_route" => "_228"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15345" => [{ART::Parameters.new({"_route" => "_477"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15395" => [{ART::Parameters.new({"_route" => "_272"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15444" => [{ART::Parameters.new({"_route" => "_486"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15502" => [{ART::Parameters.new({"_route" => "_21"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15550" => [{ART::Parameters.new({"_route" => "_247"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15598" => [{ART::Parameters.new({"_route" => "_424"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15646" => [{ART::Parameters.new({"_route" => "_499"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15699" => [{ART::Parameters.new({"_route" => "_23"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15747" => [{ART::Parameters.new({"_route" => "_152"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15795" => [{ART::Parameters.new({"_route" => "_304"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15843" => [{ART::Parameters.new({"_route" => "_352"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15896" => [{ART::Parameters.new({"_route" => "_28"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "15944" => [{ART::Parameters.new({"_route" => "_240"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16000" => [{ART::Parameters.new({"_route" => "_30"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16047" => [{ART::Parameters.new({"_route" => "_41"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16096" => [{ART::Parameters.new({"_route" => "_301"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16149" => [{ART::Parameters.new({"_route" => "_66"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16197" => [{ART::Parameters.new({"_route" => "_72"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16245" => [{ART::Parameters.new({"_route" => "_320"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16298" => [{ART::Parameters.new({"_route" => "_78"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16346" => [{ART::Parameters.new({"_route" => "_337"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16394" => [{ART::Parameters.new({"_route" => "_399"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16442" => [{ART::Parameters.new({"_route" => "_495"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16492" => [{ART::Parameters.new({"_route" => "_85"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16544" => [{ART::Parameters.new({"_route" => "_101"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16592" => [{ART::Parameters.new({"_route" => "_176"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16640" => [{ART::Parameters.new({"_route" => "_246"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16693" => [{ART::Parameters.new({"_route" => "_125"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16741" => [{ART::Parameters.new({"_route" => "_341"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16794" => [{ART::Parameters.new({"_route" => "_137"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16842" => [{ART::Parameters.new({"_route" => "_270"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16890" => [{ART::Parameters.new({"_route" => "_386"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16943" => [{ART::Parameters.new({"_route" => "_169"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "16991" => [{ART::Parameters.new({"_route" => "_200"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17039" => [{ART::Parameters.new({"_route" => "_262"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17092" => [{ART::Parameters.new({"_route" => "_187"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17140" => [{ART::Parameters.new({"_route" => "_333"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17190" => [{ART::Parameters.new({"_route" => "_215"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17239" => [{ART::Parameters.new({"_route" => "_316"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17288" => [{ART::Parameters.new({"_route" => "_343"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17346" => [{ART::Parameters.new({"_route" => "_22"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17394" => [{ART::Parameters.new({"_route" => "_420"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17447" => [{ART::Parameters.new({"_route" => "_55"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17494" => [{ART::Parameters.new({"_route" => "_496"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17547" => [{ART::Parameters.new({"_route" => "_153"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17595" => [{ART::Parameters.new({"_route" => "_344"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17648" => [{ART::Parameters.new({"_route" => "_160"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17696" => [{ART::Parameters.new({"_route" => "_398"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17749" => [{ART::Parameters.new({"_route" => "_161"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17797" => [{ART::Parameters.new({"_route" => "_193"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17847" => [{ART::Parameters.new({"_route" => "_174"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17899" => [{ART::Parameters.new({"_route" => "_209"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "17947" => [{ART::Parameters.new({"_route" => "_261"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18000" => [{ART::Parameters.new({"_route" => "_222"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18048" => [{ART::Parameters.new({"_route" => "_323"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18096" => [{ART::Parameters.new({"_route" => "_380"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18149" => [{ART::Parameters.new({"_route" => "_232"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18197" => [{ART::Parameters.new({"_route" => "_383"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18247" => [{ART::Parameters.new({"_route" => "_306"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18296" => [{ART::Parameters.new({"_route" => "_327"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18345" => [{ART::Parameters.new({"_route" => "_364"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18397" => [{ART::Parameters.new({"_route" => "_403"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18445" => [{ART::Parameters.new({"_route" => "_405"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18495" => [{ART::Parameters.new({"_route" => "_412"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18553" => [{ART::Parameters.new({"_route" => "_27"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18601" => [{ART::Parameters.new({"_route" => "_134"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18649" => [{ART::Parameters.new({"_route" => "_245"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18702" => [{ART::Parameters.new({"_route" => "_59"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18750" => [{ART::Parameters.new({"_route" => "_208"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18803" => [{ART::Parameters.new({"_route" => "_60"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18851" => [{ART::Parameters.new({"_route" => "_119"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18902" => [{ART::Parameters.new({"_route" => "_163"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18949" => [{ART::Parameters.new({"_route" => "_249"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "18998" => [{ART::Parameters.new({"_route" => "_278"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19051" => [{ART::Parameters.new({"_route" => "_63"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19099" => [{ART::Parameters.new({"_route" => "_195"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19147" => [{ART::Parameters.new({"_route" => "_252"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19195" => [{ART::Parameters.new({"_route" => "_461"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19248" => [{ART::Parameters.new({"_route" => "_126"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19296" => [{ART::Parameters.new({"_route" => "_158"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19344" => [{ART::Parameters.new({"_route" => "_221"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19392" => [{ART::Parameters.new({"_route" => "_269"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19440" => [{ART::Parameters.new({"_route" => "_310"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19496" => [{ART::Parameters.new({"_route" => "_138"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19543" => [{ART::Parameters.new({"_route" => "_348"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19592" => [{ART::Parameters.new({"_route" => "_236"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19640" => [{ART::Parameters.new({"_route" => "_433"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19693" => [{ART::Parameters.new({"_route" => "_141"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19741" => [{ART::Parameters.new({"_route" => "_283"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19794" => [{ART::Parameters.new({"_route" => "_144"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19842" => [{ART::Parameters.new({"_route" => "_191"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19895" => [{ART::Parameters.new({"_route" => "_168"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19943" => [{ART::Parameters.new({"_route" => "_363"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "19991" => [{ART::Parameters.new({"_route" => "_381"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20044" => [{ART::Parameters.new({"_route" => "_180"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20092" => [{ART::Parameters.new({"_route" => "_339"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20142" => [{ART::Parameters.new({"_route" => "_196"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20194" => [{ART::Parameters.new({"_route" => "_198"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20242" => [{ART::Parameters.new({"_route" => "_285"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20292" => [{ART::Parameters.new({"_route" => "_349"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20344" => [{ART::Parameters.new({"_route" => "_367"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20392" => [{ART::Parameters.new({"_route" => "_384"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20440" => [{ART::Parameters.new({"_route" => "_498"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20490" => [{ART::Parameters.new({"_route" => "_369"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20542" => [{ART::Parameters.new({"_route" => "_408"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20590" => [{ART::Parameters.new({"_route" => "_413"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20652" => [{ART::Parameters.new({"_route" => "_44"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20699" => [{ART::Parameters.new({"_route" => "_256"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20748" => [{ART::Parameters.new({"_route" => "_173"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20796" => [{ART::Parameters.new({"_route" => "_266"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20844" => [{ART::Parameters.new({"_route" => "_392"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20892" => [{ART::Parameters.new({"_route" => "_430"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20940" => [{ART::Parameters.new({"_route" => "_482"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "20993" => [{ART::Parameters.new({"_route" => "_49"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21041" => [{ART::Parameters.new({"_route" => "_94"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21089" => [{ART::Parameters.new({"_route" => "_407"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21142" => [{ART::Parameters.new({"_route" => "_65"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21190" => [{ART::Parameters.new({"_route" => "_181"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21238" => [{ART::Parameters.new({"_route" => "_437"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21291" => [{ART::Parameters.new({"_route" => "_76"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21339" => [{ART::Parameters.new({"_route" => "_357"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21392" => [{ART::Parameters.new({"_route" => "_80"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21440" => [{ART::Parameters.new({"_route" => "_106"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21493" => [{ART::Parameters.new({"_route" => "_83"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21541" => [{ART::Parameters.new({"_route" => "_255"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21589" => [{ART::Parameters.new({"_route" => "_330"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21642" => [{ART::Parameters.new({"_route" => "_100"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21690" => [{ART::Parameters.new({"_route" => "_396"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21738" => [{ART::Parameters.new({"_route" => "_422"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21791" => [{ART::Parameters.new({"_route" => "_149"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21839" => [{ART::Parameters.new({"_route" => "_324"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21892" => [{ART::Parameters.new({"_route" => "_164"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21940" => [{ART::Parameters.new({"_route" => "_423"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "21990" => [{ART::Parameters.new({"_route" => "_241"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22042" => [{ART::Parameters.new({"_route" => "_290"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22090" => [{ART::Parameters.new({"_route" => "_335"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22140" => [{ART::Parameters.new({"_route" => "_373"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22189" => [{ART::Parameters.new({"_route" => "_375"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22238" => [{ART::Parameters.new({"_route" => "_450"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22287" => [{ART::Parameters.new({"_route" => "_464"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22345" => [{ART::Parameters.new({"_route" => "_51"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22393" => [{ART::Parameters.new({"_route" => "_77"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22441" => [{ART::Parameters.new({"_route" => "_234"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22489" => [{ART::Parameters.new({"_route" => "_394"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22542" => [{ART::Parameters.new({"_route" => "_88"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22590" => [{ART::Parameters.new({"_route" => "_155"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22643" => [{ART::Parameters.new({"_route" => "_96"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22691" => [{ART::Parameters.new({"_route" => "_298"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22739" => [{ART::Parameters.new({"_route" => "_470"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22792" => [{ART::Parameters.new({"_route" => "_109"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22840" => [{ART::Parameters.new({"_route" => "_204"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22893" => [{ART::Parameters.new({"_route" => "_115"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22941" => [{ART::Parameters.new({"_route" => "_145"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "22994" => [{ART::Parameters.new({"_route" => "_123"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23042" => [{ART::Parameters.new({"_route" => "_277"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23090" => [{ART::Parameters.new({"_route" => "_473"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23143" => [{ART::Parameters.new({"_route" => "_334"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23191" => [{ART::Parameters.new({"_route" => "_493"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23244" => [{ART::Parameters.new({"_route" => "_372"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23292" => [{ART::Parameters.new({"_route" => "_432"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23340" => [{ART::Parameters.new({"_route" => "_436"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23393" => [{ART::Parameters.new({"_route" => "_425"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23441" => [{ART::Parameters.new({"_route" => "_456"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23489" => [{ART::Parameters.new({"_route" => "_474"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23539" => [{ART::Parameters.new({"_route" => "_485"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23594" => [{ART::Parameters.new({"_route" => "_91"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23646" => [{ART::Parameters.new({"_route" => "_110"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23694" => [{ART::Parameters.new({"_route" => "_114"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23750" => [{ART::Parameters.new({"_route" => "_118"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23796" => [{ART::Parameters.new({"_route" => "_475"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23844" => [{ART::Parameters.new({"_route" => "_366"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23897" => [{ART::Parameters.new({"_route" => "_167"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23945" => [{ART::Parameters.new({"_route" => "_192"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "23993" => [{ART::Parameters.new({"_route" => "_342"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24046" => [{ART::Parameters.new({"_route" => "_229"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24097" => [{ART::Parameters.new({"_route" => "_235"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24144" => [{ART::Parameters.new({"_route" => "_302"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24193" => [{ART::Parameters.new({"_route" => "_322"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24246" => [{ART::Parameters.new({"_route" => "_237"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24294" => [{ART::Parameters.new({"_route" => "_293"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24347" => [{ART::Parameters.new({"_route" => "_239"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24395" => [{ART::Parameters.new({"_route" => "_444"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24443" => [{ART::Parameters.new({"_route" => "_491"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24491" => [{ART::Parameters.new({"_route" => "_492"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24541" => [{ART::Parameters.new({"_route" => "_258"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24590" => [{ART::Parameters.new({"_route" => "_317"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24639" => [{ART::Parameters.new({"_route" => "_361"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24688" => [{ART::Parameters.new({"_route" => "_391"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24737" => [{ART::Parameters.new({"_route" => "_462"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24786" => [{ART::Parameters.new({"_route" => "_476"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24837" => [{ART::Parameters.new({"_route" => "_501"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24889" => [{ART::Parameters.new({"_route" => "_514"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24937" => [{ART::Parameters.new({"_route" => "_731"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "24990" => [{ART::Parameters.new({"_route" => "_522"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25038" => [{ART::Parameters.new({"_route" => "_693"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25091" => [{ART::Parameters.new({"_route" => "_537"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25139" => [{ART::Parameters.new({"_route" => "_554"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25187" => [{ART::Parameters.new({"_route" => "_645"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25235" => [{ART::Parameters.new({"_route" => "_862"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25288" => [{ART::Parameters.new({"_route" => "_539"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25336" => [{ART::Parameters.new({"_route" => "_729"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25384" => [{ART::Parameters.new({"_route" => "_897"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25437" => [{ART::Parameters.new({"_route" => "_561"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25485" => [{ART::Parameters.new({"_route" => "_615"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25533" => [{ART::Parameters.new({"_route" => "_764"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25581" => [{ART::Parameters.new({"_route" => "_948"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25634" => [{ART::Parameters.new({"_route" => "_617"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25682" => [{ART::Parameters.new({"_route" => "_671"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25735" => [{ART::Parameters.new({"_route" => "_649"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25783" => [{ART::Parameters.new({"_route" => "_651"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25831" => [{ART::Parameters.new({"_route" => "_684"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25884" => [{ART::Parameters.new({"_route" => "_669"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25932" => [{ART::Parameters.new({"_route" => "_743"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "25980" => [{ART::Parameters.new({"_route" => "_962"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26033" => [{ART::Parameters.new({"_route" => "_694"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26081" => [{ART::Parameters.new({"_route" => "_985"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26134" => [{ART::Parameters.new({"_route" => "_707"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26182" => [{ART::Parameters.new({"_route" => "_718"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26235" => [{ART::Parameters.new({"_route" => "_720"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26283" => [{ART::Parameters.new({"_route" => "_745"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26333" => [{ART::Parameters.new({"_route" => "_874"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26391" => [{ART::Parameters.new({"_route" => "_502"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26439" => [{ART::Parameters.new({"_route" => "_667"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26487" => [{ART::Parameters.new({"_route" => "_911"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26535" => [{ART::Parameters.new({"_route" => "_942"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26585" => [{ART::Parameters.new({"_route" => "_504"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26637" => [{ART::Parameters.new({"_route" => "_524"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26685" => [{ART::Parameters.new({"_route" => "_732"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26738" => [{ART::Parameters.new({"_route" => "_596"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26786" => [{ART::Parameters.new({"_route" => "_601"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26839" => [{ART::Parameters.new({"_route" => "_620"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26887" => [{ART::Parameters.new({"_route" => "_631"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26935" => [{ART::Parameters.new({"_route" => "_771"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "26983" => [{ART::Parameters.new({"_route" => "_937"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27031" => [{ART::Parameters.new({"_route" => "_999"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27084" => [{ART::Parameters.new({"_route" => "_657"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27132" => [{ART::Parameters.new({"_route" => "_701"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27185" => [{ART::Parameters.new({"_route" => "_662"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27233" => [{ART::Parameters.new({"_route" => "_797"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27281" => [{ART::Parameters.new({"_route" => "_924"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27334" => [{ART::Parameters.new({"_route" => "_702"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27382" => [{ART::Parameters.new({"_route" => "_750"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27435" => [{ART::Parameters.new({"_route" => "_749"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27483" => [{ART::Parameters.new({"_route" => "_837"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27533" => [{ART::Parameters.new({"_route" => "_758"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27585" => [{ART::Parameters.new({"_route" => "_810"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27633" => [{ART::Parameters.new({"_route" => "_902"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27683" => [{ART::Parameters.new({"_route" => "_845"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27741" => [{ART::Parameters.new({"_route" => "_503"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27792" => [{ART::Parameters.new({"_route" => "_756"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27839" => [{ART::Parameters.new({"_route" => "_799"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27888" => [{ART::Parameters.new({"_route" => "_769"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27936" => [{ART::Parameters.new({"_route" => "_981"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "27989" => [{ART::Parameters.new({"_route" => "_507"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28037" => [{ART::Parameters.new({"_route" => "_672"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28085" => [{ART::Parameters.new({"_route" => "_790"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28138" => [{ART::Parameters.new({"_route" => "_515"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28186" => [{ART::Parameters.new({"_route" => "_523"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28234" => [{ART::Parameters.new({"_route" => "_957"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28282" => [{ART::Parameters.new({"_route" => "_995"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28335" => [{ART::Parameters.new({"_route" => "_532"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28383" => [{ART::Parameters.new({"_route" => "_642"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28433" => [{ART::Parameters.new({"_route" => "_579"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28485" => [{ART::Parameters.new({"_route" => "_625"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28533" => [{ART::Parameters.new({"_route" => "_916"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28586" => [{ART::Parameters.new({"_route" => "_633"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28634" => [{ART::Parameters.new({"_route" => "_656"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28687" => [{ART::Parameters.new({"_route" => "_658"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28735" => [{ART::Parameters.new({"_route" => "_943"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28788" => [{ART::Parameters.new({"_route" => "_664"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28836" => [{ART::Parameters.new({"_route" => "_852"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28884" => [{ART::Parameters.new({"_route" => "_870"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28937" => [{ART::Parameters.new({"_route" => "_683"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "28985" => [{ART::Parameters.new({"_route" => "_915"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29038" => [{ART::Parameters.new({"_route" => "_719"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29086" => [{ART::Parameters.new({"_route" => "_859"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29134" => [{ART::Parameters.new({"_route" => "_912"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29182" => [{ART::Parameters.new({"_route" => "_978"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29235" => [{ART::Parameters.new({"_route" => "_738"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29283" => [{ART::Parameters.new({"_route" => "_883"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29333" => [{ART::Parameters.new({"_route" => "_741"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29382" => [{ART::Parameters.new({"_route" => "_760"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29431" => [{ART::Parameters.new({"_route" => "_895"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29489" => [{ART::Parameters.new({"_route" => "_505"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29537" => [{ART::Parameters.new({"_route" => "_935"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29590" => [{ART::Parameters.new({"_route" => "_509"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29638" => [{ART::Parameters.new({"_route" => "_820"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29686" => [{ART::Parameters.new({"_route" => "_910"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29739" => [{ART::Parameters.new({"_route" => "_518"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29787" => [{ART::Parameters.new({"_route" => "_618"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29840" => [{ART::Parameters.new({"_route" => "_546"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29888" => [{ART::Parameters.new({"_route" => "_740"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29936" => [{ART::Parameters.new({"_route" => "_867"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "29989" => [{ART::Parameters.new({"_route" => "_572"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30037" => [{ART::Parameters.new({"_route" => "_952"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30090" => [{ART::Parameters.new({"_route" => "_573"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30138" => [{ART::Parameters.new({"_route" => "_692"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30186" => [{ART::Parameters.new({"_route" => "_700"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30234" => [{ART::Parameters.new({"_route" => "_772"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30284" => [{ART::Parameters.new({"_route" => "_653"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30336" => [{ART::Parameters.new({"_route" => "_695"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30384" => [{ART::Parameters.new({"_route" => "_748"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30437" => [{ART::Parameters.new({"_route" => "_710"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30485" => [{ART::Parameters.new({"_route" => "_716"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30533" => [{ART::Parameters.new({"_route" => "_969"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30586" => [{ART::Parameters.new({"_route" => "_734"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30634" => [{ART::Parameters.new({"_route" => "_742"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30682" => [{ART::Parameters.new({"_route" => "_844"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30735" => [{ART::Parameters.new({"_route" => "_763"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30783" => [{ART::Parameters.new({"_route" => "_965"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30836" => [{ART::Parameters.new({"_route" => "_778"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30884" => [{ART::Parameters.new({"_route" => "_813"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30932" => [{ART::Parameters.new({"_route" => "_831"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "30982" => [{ART::Parameters.new({"_route" => "_955"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31031" => [{ART::Parameters.new({"_route" => "_997"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31089" => [{ART::Parameters.new({"_route" => "_506"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31137" => [{ART::Parameters.new({"_route" => "_575"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31190" => [{ART::Parameters.new({"_route" => "_516"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31238" => [{ART::Parameters.new({"_route" => "_553"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31291" => [{ART::Parameters.new({"_route" => "_528"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31339" => [{ART::Parameters.new({"_route" => "_847"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31387" => [{ART::Parameters.new({"_route" => "_904"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31440" => [{ART::Parameters.new({"_route" => "_574"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31488" => [{ART::Parameters.new({"_route" => "_818"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31538" => [{ART::Parameters.new({"_route" => "_577"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31590" => [{ART::Parameters.new({"_route" => "_584"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31638" => [{ART::Parameters.new({"_route" => "_905"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31691" => [{ART::Parameters.new({"_route" => "_612"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31739" => [{ART::Parameters.new({"_route" => "_688"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31787" => [{ART::Parameters.new({"_route" => "_854"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31840" => [{ART::Parameters.new({"_route" => "_613"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31888" => [{ART::Parameters.new({"_route" => "_767"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31941" => [{ART::Parameters.new({"_route" => "_666"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "31989" => [{ART::Parameters.new({"_route" => "_759"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32037" => [{ART::Parameters.new({"_route" => "_827"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32085" => [{ART::Parameters.new({"_route" => "_840"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32138" => [{ART::Parameters.new({"_route" => "_680"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32186" => [{ART::Parameters.new({"_route" => "_784"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32234" => [{ART::Parameters.new({"_route" => "_842"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32282" => [{ART::Parameters.new({"_route" => "_860"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32332" => [{ART::Parameters.new({"_route" => "_704"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32381" => [{ART::Parameters.new({"_route" => "_727"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32430" => [{ART::Parameters.new({"_route" => "_777"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32482" => [{ART::Parameters.new({"_route" => "_838"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32530" => [{ART::Parameters.new({"_route" => "_861"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32583" => [{ART::Parameters.new({"_route" => "_849"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32631" => [{ART::Parameters.new({"_route" => "_982"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32679" => [{ART::Parameters.new({"_route" => "_986"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32741" => [{ART::Parameters.new({"_route" => "_508"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32788" => [{ART::Parameters.new({"_route" => "_517"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32837" => [{ART::Parameters.new({"_route" => "_622"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32890" => [{ART::Parameters.new({"_route" => "_513"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32938" => [{ART::Parameters.new({"_route" => "_655"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "32986" => [{ART::Parameters.new({"_route" => "_843"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33034" => [{ART::Parameters.new({"_route" => "_939"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33084" => [{ART::Parameters.new({"_route" => "_529"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33136" => [{ART::Parameters.new({"_route" => "_535"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33184" => [{ART::Parameters.new({"_route" => "_685"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33240" => [{ART::Parameters.new({"_route" => "_559"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33287" => [{ART::Parameters.new({"_route" => "_661"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33336" => [{ART::Parameters.new({"_route" => "_768"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33389" => [{ART::Parameters.new({"_route" => "_589"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33437" => [{ART::Parameters.new({"_route" => "_647"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33485" => [{ART::Parameters.new({"_route" => "_652"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33533" => [{ART::Parameters.new({"_route" => "_834"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33586" => [{ART::Parameters.new({"_route" => "_591"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33634" => [{ART::Parameters.new({"_route" => "_599"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33687" => [{ART::Parameters.new({"_route" => "_787"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33734" => [{ART::Parameters.new({"_route" => "_848"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33787" => [{ART::Parameters.new({"_route" => "_796"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33835" => [{ART::Parameters.new({"_route" => "_877"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33885" => [{ART::Parameters.new({"_route" => "_809"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33934" => [{ART::Parameters.new({"_route" => "_817"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "33986" => [{ART::Parameters.new({"_route" => "_819"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34034" => [{ART::Parameters.new({"_route" => "_865"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34084" => [{ART::Parameters.new({"_route" => "_919"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34133" => [{ART::Parameters.new({"_route" => "_949"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34191" => [{ART::Parameters.new({"_route" => "_510"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34239" => [{ART::Parameters.new({"_route" => "_590"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34287" => [{ART::Parameters.new({"_route" => "_597"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34335" => [{ART::Parameters.new({"_route" => "_682"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34383" => [{ART::Parameters.new({"_route" => "_723"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34436" => [{ART::Parameters.new({"_route" => "_521"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34484" => [{ART::Parameters.new({"_route" => "_594"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34532" => [{ART::Parameters.new({"_route" => "_689"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34580" => [{ART::Parameters.new({"_route" => "_713"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34628" => [{ART::Parameters.new({"_route" => "_889"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34681" => [{ART::Parameters.new({"_route" => "_531"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34729" => [{ART::Parameters.new({"_route" => "_639"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34780" => [{ART::Parameters.new({"_route" => "_646"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34827" => [{ART::Parameters.new({"_route" => "_659"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34876" => [{ART::Parameters.new({"_route" => "_959"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34929" => [{ART::Parameters.new({"_route" => "_550"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "34977" => [{ART::Parameters.new({"_route" => "_833"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35025" => [{ART::Parameters.new({"_route" => "_899"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35081" => [{ART::Parameters.new({"_route" => "_580"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35128" => [{ART::Parameters.new({"_route" => "_762"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35177" => [{ART::Parameters.new({"_route" => "_896"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35230" => [{ART::Parameters.new({"_route" => "_595"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35278" => [{ART::Parameters.new({"_route" => "_933"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35328" => [{ART::Parameters.new({"_route" => "_610"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35380" => [{ART::Parameters.new({"_route" => "_629"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35428" => [{ART::Parameters.new({"_route" => "_744"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35481" => [{ART::Parameters.new({"_route" => "_674"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35529" => [{ART::Parameters.new({"_route" => "_726"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35577" => [{ART::Parameters.new({"_route" => "_929"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35627" => [{ART::Parameters.new({"_route" => "_696"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35679" => [{ART::Parameters.new({"_route" => "_841"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35727" => [{ART::Parameters.new({"_route" => "_890"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35777" => [{ART::Parameters.new({"_route" => "_885"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35826" => [{ART::Parameters.new({"_route" => "_888"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35875" => [{ART::Parameters.new({"_route" => "_996"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35933" => [{ART::Parameters.new({"_route" => "_511"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "35981" => [{ART::Parameters.new({"_route" => "_576"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36029" => [{ART::Parameters.new({"_route" => "_623"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36082" => [{ART::Parameters.new({"_route" => "_560"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36129" => [{ART::Parameters.new({"_route" => "_585"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36182" => [{ART::Parameters.new({"_route" => "_570"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36230" => [{ART::Parameters.new({"_route" => "_578"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36281" => [{ART::Parameters.new({"_route" => "_780"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36328" => [{ART::Parameters.new({"_route" => "_808"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36382" => [{ART::Parameters.new({"_route" => "_593"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36430" => [{ART::Parameters.new({"_route" => "_900"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36483" => [{ART::Parameters.new({"_route" => "_632"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36531" => [{ART::Parameters.new({"_route" => "_654"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36579" => [{ART::Parameters.new({"_route" => "_721"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36627" => [{ART::Parameters.new({"_route" => "_836"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36680" => [{ART::Parameters.new({"_route" => "_637"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36728" => [{ART::Parameters.new({"_route" => "_737"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36784" => [{ART::Parameters.new({"_route" => "_699"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36831" => [{ART::Parameters.new({"_route" => "_822"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36880" => [{ART::Parameters.new({"_route" => "_853"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36933" => [{ART::Parameters.new({"_route" => "_708"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "36981" => [{ART::Parameters.new({"_route" => "_871"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37034" => [{ART::Parameters.new({"_route" => "_752"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37082" => [{ART::Parameters.new({"_route" => "_989"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37132" => [{ART::Parameters.new({"_route" => "_855"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37184" => [{ART::Parameters.new({"_route" => "_858"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37232" => [{ART::Parameters.new({"_route" => "_898"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37282" => [{ART::Parameters.new({"_route" => "_903"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37331" => [{ART::Parameters.new({"_route" => "_909"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37380" => [{ART::Parameters.new({"_route" => "_950"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37441" => [{ART::Parameters.new({"_route" => "_512"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37488" => [{ART::Parameters.new({"_route" => "_691"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37537" => [{ART::Parameters.new({"_route" => "_686"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37587" => [{ART::Parameters.new({"_route" => "_527"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37639" => [{ART::Parameters.new({"_route" => "_541"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37687" => [{ART::Parameters.new({"_route" => "_956"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37740" => [{ART::Parameters.new({"_route" => "_555"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37788" => [{ART::Parameters.new({"_route" => "_681"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37841" => [{ART::Parameters.new({"_route" => "_556"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37889" => [{ART::Parameters.new({"_route" => "_802"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37939" => [{ART::Parameters.new({"_route" => "_558"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "37991" => [{ART::Parameters.new({"_route" => "_564"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38039" => [{ART::Parameters.new({"_route" => "_670"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38087" => [{ART::Parameters.new({"_route" => "_884"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38140" => [{ART::Parameters.new({"_route" => "_627"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38187" => [{ART::Parameters.new({"_route" => "_746"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38240" => [{ART::Parameters.new({"_route" => "_668"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38291" => [{ART::Parameters.new({"_route" => "_712"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38338" => [{ART::Parameters.new({"_route" => "_863"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38387" => [{ART::Parameters.new({"_route" => "_801"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38440" => [{ART::Parameters.new({"_route" => "_709"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38488" => [{ART::Parameters.new({"_route" => "_850"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38536" => [{ART::Parameters.new({"_route" => "_918"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38586" => [{ART::Parameters.new({"_route" => "_803"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38638" => [{ART::Parameters.new({"_route" => "_864"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38686" => [{ART::Parameters.new({"_route" => "_880"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38734" => [{ART::Parameters.new({"_route" => "_927"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38787" => [{ART::Parameters.new({"_route" => "_930"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38835" => [{ART::Parameters.new({"_route" => "_951"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38883" => [{ART::Parameters.new({"_route" => "_963"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38942" => [{ART::Parameters.new({"_route" => "_519"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "38990" => [{ART::Parameters.new({"_route" => "_823"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39038" => [{ART::Parameters.new({"_route" => "_954"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39091" => [{ART::Parameters.new({"_route" => "_525"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39139" => [{ART::Parameters.new({"_route" => "_991"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39189" => [{ART::Parameters.new({"_route" => "_536"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39241" => [{ART::Parameters.new({"_route" => "_545"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39289" => [{ART::Parameters.new({"_route" => "_944"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39342" => [{ART::Parameters.new({"_route" => "_557"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39390" => [{ART::Parameters.new({"_route" => "_783"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39438" => [{ART::Parameters.new({"_route" => "_807"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39491" => [{ART::Parameters.new({"_route" => "_586"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39539" => [{ART::Parameters.new({"_route" => "_711"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39592" => [{ART::Parameters.new({"_route" => "_598"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39640" => [{ART::Parameters.new({"_route" => "_635"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39688" => [{ART::Parameters.new({"_route" => "_983"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39741" => [{ART::Parameters.new({"_route" => "_634"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39789" => [{ART::Parameters.new({"_route" => "_641"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39840" => [{ART::Parameters.new({"_route" => "_779"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39887" => [{ART::Parameters.new({"_route" => "_876"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39936" => [{ART::Parameters.new({"_route" => "_811"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "39984" => [{ART::Parameters.new({"_route" => "_824"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40037" => [{ART::Parameters.new({"_route" => "_660"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40085" => [{ART::Parameters.new({"_route" => "_789"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40138" => [{ART::Parameters.new({"_route" => "_733"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40186" => [{ART::Parameters.new({"_route" => "_735"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40234" => [{ART::Parameters.new({"_route" => "_882"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40282" => [{ART::Parameters.new({"_route" => "_967"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40332" => [{ART::Parameters.new({"_route" => "_736"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40381" => [{ART::Parameters.new({"_route" => "_753"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40430" => [{ART::Parameters.new({"_route" => "_786"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40479" => [{ART::Parameters.new({"_route" => "_907"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40528" => [{ART::Parameters.new({"_route" => "_920"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40577" => [{ART::Parameters.new({"_route" => "_971"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40635" => [{ART::Parameters.new({"_route" => "_520"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40683" => [{ART::Parameters.new({"_route" => "_891"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40739" => [{ART::Parameters.new({"_route" => "_534"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40785" => [{ART::Parameters.new({"_route" => "_602"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40834" => [{ART::Parameters.new({"_route" => "_605"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40882" => [{ART::Parameters.new({"_route" => "_979"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40932" => [{ART::Parameters.new({"_route" => "_547"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "40987" => [{ART::Parameters.new({"_route" => "_549"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41034" => [{ART::Parameters.new({"_route" => "_755"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41083" => [{ART::Parameters.new({"_route" => "_922"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41131" => [{ART::Parameters.new({"_route" => "_977"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41184" => [{ART::Parameters.new({"_route" => "_565"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41232" => [{ART::Parameters.new({"_route" => "_926"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41282" => [{ART::Parameters.new({"_route" => "_571"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41331" => [{ART::Parameters.new({"_route" => "_581"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41380" => [{ART::Parameters.new({"_route" => "_619"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41429" => [{ART::Parameters.new({"_route" => "_636"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41481" => [{ART::Parameters.new({"_route" => "_679"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41529" => [{ART::Parameters.new({"_route" => "_866"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41577" => [{ART::Parameters.new({"_route" => "_973"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41630" => [{ART::Parameters.new({"_route" => "_690"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41678" => [{ART::Parameters.new({"_route" => "_775"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41731" => [{ART::Parameters.new({"_route" => "_722"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41779" => [{ART::Parameters.new({"_route" => "_906"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41827" => [{ART::Parameters.new({"_route" => "_946"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41877" => [{ART::Parameters.new({"_route" => "_788"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41929" => [{ART::Parameters.new({"_route" => "_828"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "41977" => [{ART::Parameters.new({"_route" => "_892"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42025" => [{ART::Parameters.new({"_route" => "_972"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42075" => [{ART::Parameters.new({"_route" => "_829"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42127" => [{ART::Parameters.new({"_route" => "_923"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42175" => [{ART::Parameters.new({"_route" => "_947"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42234" => [{ART::Parameters.new({"_route" => "_526"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42282" => [{ART::Parameters.new({"_route" => "_614"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42330" => [{ART::Parameters.new({"_route" => "_621"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42383" => [{ART::Parameters.new({"_route" => "_543"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42431" => [{ART::Parameters.new({"_route" => "_812"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42487" => [{ART::Parameters.new({"_route" => "_548"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42534" => [{ART::Parameters.new({"_route" => "_747"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42583" => [{ART::Parameters.new({"_route" => "_715"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42631" => [{ART::Parameters.new({"_route" => "_940"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42684" => [{ART::Parameters.new({"_route" => "_563"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42732" => [{ART::Parameters.new({"_route" => "_611"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42780" => [{ART::Parameters.new({"_route" => "_830"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42833" => [{ART::Parameters.new({"_route" => "_569"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42881" => [{ART::Parameters.new({"_route" => "_908"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42929" => [{ART::Parameters.new({"_route" => "_913"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "42982" => [{ART::Parameters.new({"_route" => "_644"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43030" => [{ART::Parameters.new({"_route" => "_776"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43078" => [{ART::Parameters.new({"_route" => "_856"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43131" => [{ART::Parameters.new({"_route" => "_650"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43179" => [{ART::Parameters.new({"_route" => "_761"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43232" => [{ART::Parameters.new({"_route" => "_663"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43280" => [{ART::Parameters.new({"_route" => "_754"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43333" => [{ART::Parameters.new({"_route" => "_665"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43381" => [{ART::Parameters.new({"_route" => "_805"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43429" => [{ART::Parameters.new({"_route" => "_846"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43477" => [{ART::Parameters.new({"_route" => "_857"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43530" => [{ART::Parameters.new({"_route" => "_675"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43578" => [{ART::Parameters.new({"_route" => "_839"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43626" => [{ART::Parameters.new({"_route" => "_968"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43676" => [{ART::Parameters.new({"_route" => "_697"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43728" => [{ART::Parameters.new({"_route" => "_725"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43776" => [{ART::Parameters.new({"_route" => "_794"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43829" => [{ART::Parameters.new({"_route" => "_773"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43877" => [{ART::Parameters.new({"_route" => "_992"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43930" => [{ART::Parameters.new({"_route" => "_901"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "43978" => [{ART::Parameters.new({"_route" => "_970"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44028" => [{ART::Parameters.new({"_route" => "_964"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44086" => [{ART::Parameters.new({"_route" => "_530"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44134" => [{ART::Parameters.new({"_route" => "_703"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44187" => [{ART::Parameters.new({"_route" => "_533"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44235" => [{ART::Parameters.new({"_route" => "_739"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44283" => [{ART::Parameters.new({"_route" => "_791"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44331" => [{ART::Parameters.new({"_route" => "_987"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44384" => [{ART::Parameters.new({"_route" => "_566"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44432" => [{ART::Parameters.new({"_route" => "_592"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44488" => [{ART::Parameters.new({"_route" => "_568"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44534" => [{ART::Parameters.new({"_route" => "_868"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44583" => [{ART::Parameters.new({"_route" => "_878"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44636" => [{ART::Parameters.new({"_route" => "_588"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44684" => [{ART::Parameters.new({"_route" => "_793"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44732" => [{ART::Parameters.new({"_route" => "_917"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44785" => [{ART::Parameters.new({"_route" => "_600"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44833" => [{ART::Parameters.new({"_route" => "_728"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44886" => [{ART::Parameters.new({"_route" => "_603"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44934" => [{ART::Parameters.new({"_route" => "_765"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "44987" => [{ART::Parameters.new({"_route" => "_607"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45035" => [{ART::Parameters.new({"_route" => "_676"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45083" => [{ART::Parameters.new({"_route" => "_804"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45136" => [{ART::Parameters.new({"_route" => "_609"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45184" => [{ART::Parameters.new({"_route" => "_961"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45232" => [{ART::Parameters.new({"_route" => "_980"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45282" => [{ART::Parameters.new({"_route" => "_714"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45334" => [{ART::Parameters.new({"_route" => "_730"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45382" => [{ART::Parameters.new({"_route" => "_806"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45430" => [{ART::Parameters.new({"_route" => "_825"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45478" => [{ART::Parameters.new({"_route" => "_879"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45526" => [{ART::Parameters.new({"_route" => "_893"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45576" => [{ART::Parameters.new({"_route" => "_928"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45628" => [{ART::Parameters.new({"_route" => "_932"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45676" => [{ART::Parameters.new({"_route" => "_958"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45726" => [{ART::Parameters.new({"_route" => "_984"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45784" => [{ART::Parameters.new({"_route" => "_538"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45832" => [{ART::Parameters.new({"_route" => "_993"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45882" => [{ART::Parameters.new({"_route" => "_542"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45934" => [{ART::Parameters.new({"_route" => "_551"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "45982" => [{ART::Parameters.new({"_route" => "_687"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46030" => [{ART::Parameters.new({"_route" => "_724"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46078" => [{ART::Parameters.new({"_route" => "_925"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46131" => [{ART::Parameters.new({"_route" => "_587"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46179" => [{ART::Parameters.new({"_route" => "_914"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46229" => [{ART::Parameters.new({"_route" => "_616"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46284" => [{ART::Parameters.new({"_route" => "_677"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46331" => [{ART::Parameters.new({"_route" => "_815"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46380" => [{ART::Parameters.new({"_route" => "_781"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46430" => [{ART::Parameters.new({"_route" => "_717"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46482" => [{ART::Parameters.new({"_route" => "_782"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46530" => [{ART::Parameters.new({"_route" => "_832"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46583" => [{ART::Parameters.new({"_route" => "_795"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46631" => [{ART::Parameters.new({"_route" => "_887"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46681" => [{ART::Parameters.new({"_route" => "_800"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46730" => [{ART::Parameters.new({"_route" => "_826"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46779" => [{ART::Parameters.new({"_route" => "_881"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46828" => [{ART::Parameters.new({"_route" => "_886"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46877" => [{ART::Parameters.new({"_route" => "_938"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46935" => [{ART::Parameters.new({"_route" => "_540"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "46983" => [{ART::Parameters.new({"_route" => "_643"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47033" => [{ART::Parameters.new({"_route" => "_544"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47082" => [{ART::Parameters.new({"_route" => "_552"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47134" => [{ART::Parameters.new({"_route" => "_567"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47182" => [{ART::Parameters.new({"_route" => "_608"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47230" => [{ART::Parameters.new({"_route" => "_698"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47278" => [{ART::Parameters.new({"_route" => "_988"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47331" => [{ART::Parameters.new({"_route" => "_583"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47379" => [{ART::Parameters.new({"_route" => "_998"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47432" => [{ART::Parameters.new({"_route" => "_604"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47480" => [{ART::Parameters.new({"_route" => "_630"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47528" => [{ART::Parameters.new({"_route" => "_706"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47576" => [{ART::Parameters.new({"_route" => "_976"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47629" => [{ART::Parameters.new({"_route" => "_673"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47677" => [{ART::Parameters.new({"_route" => "_678"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47725" => [{ART::Parameters.new({"_route" => "_931"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47775" => [{ART::Parameters.new({"_route" => "_751"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47824" => [{ART::Parameters.new({"_route" => "_766"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47876" => [{ART::Parameters.new({"_route" => "_792"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47924" => [{ART::Parameters.new({"_route" => "_814"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "47974" => [{ART::Parameters.new({"_route" => "_798"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48026" => [{ART::Parameters.new({"_route" => "_851"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48074" => [{ART::Parameters.new({"_route" => "_941"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48122" => [{ART::Parameters.new({"_route" => "_953"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48170" => [{ART::Parameters.new({"_route" => "_975"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48220" => [{ART::Parameters.new({"_route" => "_873"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48269" => [{ART::Parameters.new({"_route" => "_936"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48318" => [{ART::Parameters.new({"_route" => "_994"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48376" => [{ART::Parameters.new({"_route" => "_562"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48424" => [{ART::Parameters.new({"_route" => "_770"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48475" => [{ART::Parameters.new({"_route" => "_774"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48522" => [{ART::Parameters.new({"_route" => "_966"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48573" => [{ART::Parameters.new({"_route" => "_582"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48625" => [{ART::Parameters.new({"_route" => "_606"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48673" => [{ART::Parameters.new({"_route" => "_648"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48723" => [{ART::Parameters.new({"_route" => "_624"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48775" => [{ART::Parameters.new({"_route" => "_626"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48823" => [{ART::Parameters.new({"_route" => "_821"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48873" => [{ART::Parameters.new({"_route" => "_628"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48922" => [{ART::Parameters.new({"_route" => "_638"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "48974" => [{ART::Parameters.new({"_route" => "_640"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49022" => [{ART::Parameters.new({"_route" => "_990"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49072" => [{ART::Parameters.new({"_route" => "_705"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49121" => [{ART::Parameters.new({"_route" => "_757"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49176" => [{ART::Parameters.new({"_route" => "_785"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49223" => [{ART::Parameters.new({"_route" => "_875"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49270" => [{ART::Parameters.new({"_route" => "_894"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49319" => [{ART::Parameters.new({"_route" => "_945"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49375" => [{ART::Parameters.new({"_route" => "_816"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49422" => [{ART::Parameters.new({"_route" => "_872"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49471" => [{ART::Parameters.new({"_route" => "_921"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49519" => [{ART::Parameters.new({"_route" => "_960"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49567" => [{ART::Parameters.new({"_route" => "_974"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49620" => [{ART::Parameters.new({"_route" => "_835"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49668" => [{ART::Parameters.new({"_route" => "_934"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], "49718" => [{ART::Parameters.new({"_route" => "_869"}), Set{"a", "b", "c"}, nil, nil, false, false, nil}], } #### 0 ================================================ FILE: src/components/routing/spec/generator/url_generator_spec.cr ================================================ require "../spec_helper" struct URLGeneratorTest < ASPEC::TestCase def test_generate_default_port : Nil self .generator(self.routes(ART::Route.new("/test"))) .generate("test", reference_type: :absolute_url).should eq "http://localhost/base/test" end def test_generate_secure_default_port : Nil self .generator(self.routes(ART::Route.new("/test")), context: ART::RequestContext.new(base_url: "/base", scheme: "https")) .generate("test", reference_type: :absolute_url).should eq "https://localhost/base/test" end def test_generate_non_standard_port : Nil self .generator(self.routes(ART::Route.new("/test")), context: ART::RequestContext.new(base_url: "/base", http_port: 8080)) .generate("test", reference_type: :absolute_url).should eq "http://localhost:8080/base/test" end def test_generate_secure_non_standard_port : Nil self .generator(self.routes(ART::Route.new("/test")), context: ART::RequestContext.new(base_url: "/base", scheme: "https", https_port: 8080)) .generate("test", reference_type: :absolute_url).should eq "https://localhost:8080/base/test" end def test_generate_no_parameters : Nil self .generator(self.routes(ART::Route.new("/test"))) .generate("test").should eq "/base/test" end def test_generate_with_parameters : Nil self .generator(self.routes(ART::Route.new("/test/{foo}"))) .generate("test", {"foo" => "bar"}).should eq "/base/test/bar" end def test_generate_nil_parameter : Nil self .generator(self.routes(ART::Route.new("/test.{format}", {"format" => nil}))) .generate("test").should eq "/base/test" end def test_generate_nil_parameter_required : Nil generator = self.generator self.routes ART::Route.new "/test/{foo}/bar", {"foo" => nil} expect_raises ART::Exception::InvalidParameter do generator.generate "test" end end def test_generate_not_passed_optional_parameter_in_between : Nil generator = self.generator self.routes ART::Route.new "/{slug}/{page}", {"slug" => "index", "page" => "0"} generator.generate("test", {"page" => 1}).should eq "/base/index/1" generator.generate("test").should eq "/base/" end @[DataProvider("query_param_provider")] def test_generate_extra_params(expected : String, key : String, value) : Nil self .generator(self.routes ART::Route.new "/test") .generate("test", {key => value}, reference_type: :absolute_url).should eq "http://localhost/base/test#{expected}" end def query_param_provider : Hash { "nil value" => {"", "foo", nil}, "string value" => {"?foo=bar", "foo", "bar"}, } end def test_generate_extra_param_from_globals : Nil self .generator(self.routes(ART::Route.new("/test")), context: ART::RequestContext.new(base_url: "/base").set_parameter("bar", "bar")) .generate("test", {"foo" => "bar"}).should eq "/base/test?foo=bar" end def test_generate_param_from_globals : Nil self .generator(self.routes(ART::Route.new("/test/{foo}")), context: ART::RequestContext.new(base_url: "/base").set_parameter("foo", "bar")) .generate("test").should eq "/base/test/bar" end def test_generate_param_from_globals_overrides_defaults : Nil self .generator(self.routes(ART::Route.new("/{_locale}", {"_locale" => "en"})), context: ART::RequestContext.new(base_url: "/base").set_parameter("_locale", "de")) .generate("test").should eq "/base/de" end def test_generate_localized_routes_preserve_the_good_locale_in_url : Nil routes = ART::RouteCollection.new routes.add "foo.en", ART::Route.new "/{_locale}/fork", {"_locale" => "en", "_canonical_route" => "foo"}, {"_locale" => /en/} routes.add "foo.fr", ART::Route.new "/{_locale}/fourchette", {"_locale" => "fr", "_canonical_route" => "foo"}, {"_locale" => /fr/} routes.add "fun.en", ART::Route.new "/fun", {"_locale" => "en", "_canonical_route" => "fun"}, {"_locale" => /en/} routes.add "fun.fr", ART::Route.new "/amusant", {"_locale" => "fr", "_canonical_route" => "fun"}, {"_locale" => /fr/} ART.compile routes generator = self.generator routes generator.context.set_parameter "_locale", "fr" generator.generate("foo").should eq "/base/fr/fourchette" generator.generate("foo.en").should eq "/base/en/fork" generator.generate("foo", {"_locale" => "en"}).should eq "/base/en/fork" generator.generate("foo.fr", {"_locale" => "en"}).should eq "/base/fr/fourchette" generator.generate("fun").should eq "/base/amusant" generator.generate("fun.en").should eq "/base/fun" generator.generate("fun", {"_locale" => "en"}).should eq "/base/fun" generator.generate("fun.fr", {"_locale" => "en"}).should eq "/base/amusant" end def test_generate_invalid_locale : Nil routes = ART::RouteCollection.new name = "test" {"hr" => "/foo", "en" => "/bar"}.each do |locale, path| routes.add "#{name}.#{locale}", ART::Route.new path, {"_locale" => locale, "_canonical_route" => name}, {"_locale" => locale} end ART.compile routes generator = self.generator routes, default_locale: "fr" expect_raises ART::Exception::RouteNotFound do generator.generate name end end def test_generate_default_locale : Nil routes = ART::RouteCollection.new name = "test" {"hr" => "/foo", "en" => "/bar"}.each do |locale, path| routes.add "#{name}.#{locale}", ART::Route.new path, {"_locale" => locale, "_canonical_route" => name}, {"_locale" => locale} end ART.compile routes self .generator(routes, default_locale: "hr") .generate(name, reference_type: :absolute_url).should eq "http://localhost/base/foo" end def test_generate_overridden_locale : Nil routes = ART::RouteCollection.new name = "test" {"hr" => "/foo", "en" => "/bar"}.each do |locale, path| routes.add "#{name}.#{locale}", ART::Route.new path, {"_locale" => locale, "_canonical_route" => name}, {"_locale" => locale} end ART.compile routes self .generator(routes, default_locale: "hr") .generate(name, {"_locale" => "en"}, :absolute_url).should eq "http://localhost/base/bar" end def test_generate_overridden_via_request_context_locale : Nil routes = ART::RouteCollection.new name = "test" {"hr" => "/foo", "en" => "/bar"}.each do |locale, path| routes.add "#{name}.#{locale}", ART::Route.new path, {"_locale" => locale, "_canonical_route" => name}, {"_locale" => locale} end ART.compile routes self .generator(routes, context: ART::RequestContext.new(base_url: "/base").set_parameter("_locale", "en"), default_locale: "hr") .generate(name, reference_type: :absolute_url).should eq "http://localhost/base/bar" end def test_generate_no_routes : Nil generator = self.generator self.routes ART::Route.new "/test" expect_raises ART::Exception::RouteNotFound do generator.generate("foo", reference_type: :absolute_url) end end def test_generate_missing_required_param : Nil generator = self.generator self.routes ART::Route.new "/test/{foo}" expect_raises ART::Exception::MissingRequiredParameters, %(Cannot generate URL for route 'test'. Missing required parameters: 'foo'.) do generator.generate("test", reference_type: :absolute_url) end end def test_generate_invalid_optional_param : Nil generator = self.generator self.routes ART::Route.new "/test/{foo}", {"foo" => "1"}, {"foo" => /\d+/} expect_raises ART::Exception::InvalidParameter, "Parameter 'foo' for route 'test' must match '(?-imsx:\\d+)' (got 'bar') to generate the corresponding URL." do generator.generate("test", {"foo" => "bar"}, :absolute_url) end end def test_generate_invalid_param : Nil generator = self.generator self.routes ART::Route.new "/test/{foo}", requirements: {"foo" => /1|2/} expect_raises ART::Exception::InvalidParameter, "Parameter 'foo' for route 'test' must match '(?-imsx:1|2)' (got '0') to generate the corresponding URL." do generator.generate("test", {"foo" => "0"}, :absolute_url) end end def test_generate_invalid_optional_param_non_strict : Nil generator = self.generator self.routes ART::Route.new "/test/{foo}", {"foo" => "1"}, {"foo" => /\d+/} generator.strict_requirements = false generator.generate("test", {"foo" => "bar"}, :absolute_url).should eq "" end def test_generate_invalid_param_disabled_checks : Nil generator = self.generator self.routes ART::Route.new "/test/{foo}", {"foo" => "1"}, {"foo" => /\d+/} generator.strict_requirements = nil generator.generate("test", {"foo" => "bar"}).should eq "/base/test/bar" end def test_generate_invalid_required_param : Nil generator = self.generator self.routes ART::Route.new "/test/{foo}", requirements: {"foo" => /1|2/} expect_raises ART::Exception::InvalidParameter do generator.generate("test", {"foo" => "0"}, :absolute_url) end end def test_generate_required_param_empty_string : Nil generator = self.generator self.routes ART::Route.new "/{slug}", requirements: {"slug" => /.+/} expect_raises ART::Exception::InvalidParameter do generator.generate "test", {"slug" => ""} end end def test_generate_scheme_requirement_does_nothing_if_same_as_current_scheme : Nil self .generator(self.routes(ART::Route.new("/", schemes: "http")), context: ART::RequestContext.new base_url: "/base", scheme: "http") .generate("test").should eq "/base/" end def test_generate_scheme_requirement_does_nothing_if_same_as_current_scheme_secure : Nil self .generator(self.routes(ART::Route.new("/", schemes: "https")), context: ART::RequestContext.new base_url: "/base", scheme: "https") .generate("test").should eq "/base/" end def test_generate_scheme_requirement_forces_absolute_url : Nil self .generator(self.routes(ART::Route.new("/", schemes: "http")), context: ART::RequestContext.new base_url: "/base", scheme: "https") .generate("test").should eq "http://localhost/base/" end def test_generate_scheme_requirement_forces_absolute_url_secure : Nil self .generator(self.routes(ART::Route.new("/", schemes: "https"))) .generate("test").should eq "https://localhost/base/" end def test_generate_scheme_requirement_creates_url_for_first_required_scheme : Nil self .generator(self.routes(ART::Route.new("/", schemes: {"Ftp", "https"}))) .generate("test").should eq "ftp://localhost/base/" end def test_path_with_two_starting_slashes : Nil self .generator(self.routes(ART::Route.new("//path-and-not-domain")), context: ART::RequestContext.new) .generate("test").should eq "/path-and-not-domain" end def test_generate_no_trailing_slash_for_multiple_optional_parameters : Nil self .generator(self.routes(ART::Route.new("/category/{slug1}/{slug2}/{slug3}", {"slug2" => nil, "slug3" => nil}))) .generate("test", {"slug1" => "foo"}).should eq "/base/category/foo" end def test_generate_nil_for_optional_parameter_is_ignored : Nil self .generator(self.routes(ART::Route.new("/test/{default}", {"default" => "0"}))) .generate("test", {"default" => nil}).should eq "/base/test" end def test_generate_query_param_same_as_default : Nil generator = self.generator self.routes ART::Route.new "/test", {"page" => 1} generator.generate("test", page: 2).should eq "/base/test?page=2" generator.generate("test", page: 1).should eq "/base/test" generator.generate("test", page: "1").should eq "/base/test" generator.generate("test").should eq "/base/test" end # TODO: Also support array defaults. def test_generate_special_route_name : Nil self .generator(self.routes(ART::Route.new("/bar"), name: "$péß^a|")) .generate("$péß^a|").should eq "/base/bar" end def test_generate_url_encoding : Nil expected_path = "/base/@:%5B%5D/%28%29*%27%22%20+,;-._~%26%24%3C%3E|%7B%7D%25%5C%5E%60!%3Ffoo=bar%23id/@:%5B%5D/%28%29*%27%22%20+,;-._~%26%24%3C%3E|%7B%7D%25%5C%5E%60!%3Ffoo=bar%23id?query=@:%5B%5D/%28%29*%27%22+%2B,;-._~%26%24%3C%3E%7C%7B%7D%25%5C%5E%60!?foo%3Dbar%23id" chars = "@:[]/()*'\" +,;-._~&$<>|{}%\\^`!?foo=bar#id" self .generator(self.routes(ART::Route.new("/#{chars}/{path}", requirements: {"path" => /.+/}))) .generate("test", {"path" => chars, "query" => chars}).should eq expected_path end def test_generate_encoding_of_relative_path_segments_double_dot : Nil self .generator(self.routes(ART::Route.new("/dir/../dir/.."))) .generate("test").should eq "/base/dir/%2E%2E/dir/%2E%2E" end def test_generate_encoding_of_relative_path_segments_single_dot : Nil self .generator(self.routes(ART::Route.new("/dir/./dir/."))) .generate("test").should eq "/base/dir/%2E/dir/%2E" end def test_generate_encoding_of_relative_path_segments_unencoded_dots : Nil self .generator(self.routes(ART::Route.new("/a./.a/a../..a/..."))) .generate("test").should eq "/base/a./.a/a../..a/..." end def test_generate_encoding_of_slash_in_path : Nil self .generator(self.routes(ART::Route.new("/dir/{path}/dir2", requirements: {"path" => /.+/}))) .generate("test", path: "foo/bar%2Fbaz").should eq "/base/dir/foo/bar%2Fbaz/dir2" end def test_generate_encoding_of_slash_in_query_params : Nil generator = self.generator(self.routes(ART::Route.new("/foo"))) generator.generate("test", query: "foo/bar").should eq "/base/foo?query=foo/bar" generator.generate("test", query: "foo%2Fbar").should eq "/base/foo?query=foo%2Fbar" end def test_generate_adjacent_variables : Nil generator = self.generator(self.routes(ART::Route.new("{x}{y}{z}.{_format}", {"z" => "default-z", "_format" => "html"}, {"y" => /\d+/}))) generator.generate("test", x: "foo", y: 123).should eq "/base/foo123" generator.generate("test", x: "foo", y: 123, z: "bar", "_format": "xml").should eq "/base/foo123bar.xml" end def test_generate_optional_variable_with_no_real_separator : Nil generator = self.generator(self.routes(ART::Route.new("/get{what}", {"what" => "All"}))) generator.generate("test").should eq "/base/get" generator.generate("test", what: "Sites").should eq "/base/getSites" end def test_generate_required_variable_with_no_real_separator : Nil self .generator(self.routes(ART::Route.new("/get{what}Suffix"))) .generate("test", what: "Sites").should eq "/base/getSitesSuffix" end def test_generate_default_requirement_of_variable : Nil self .generator(self.routes(ART::Route.new("/{page}.{_format}"))) .generate("test", page: "index", "_format": "mobile.html").should eq "/base/index.mobile.html" end def test_generate_important_variable : Nil generator = self.generator(self.routes(ART::Route.new("/{page}.{!_format}", {"_format" => "mobile.html"}))) generator.generate("test", page: "index", "_format": "xml").should eq "/base/index.xml" generator.generate("test", page: "index", "_format": "mobile.html").should eq "/base/index.mobile.html" generator.generate("test", page: "index").should eq "/base/index.mobile.html" end def test_generate_important_variable_no_default : Nil generator = self.generator self.routes ART::Route.new "/{page}.{!_format}" expect_raises ART::Exception::MissingRequiredParameters do generator.generate "test", page: "index" end end def test_generate_default_requirement_of_variable_disallows_slash : Nil generator = self.generator self.routes ART::Route.new "/{page}.{!_format}" expect_raises ART::Exception::InvalidParameter do generator.generate "test", page: "index", "_format": "sl/ash" end end def test_generate_default_requirement_of_variable_disallows_next_separator : Nil generator = self.generator self.routes ART::Route.new "/{page}.{!_format}" expect_raises ART::Exception::InvalidParameter do generator.generate "test", page: "do.it", "_format": "html" end end def test_generate_host_different_than_context : Nil self .generator(self.routes(ART::Route.new("/{name}", host: "{locale}.example.com"))) .generate("test", name: "George", locale: "fr").should eq "//fr.example.com/base/George" end def test_generate_host_same_as_context : Nil self .generator(self.routes(ART::Route.new("/{name}", host: "{locale}.example.com")), context: ART::RequestContext.new(base_url: "/base", host: "fr.example.com")) .generate("test", name: "George", locale: "fr").should eq "/base/George" end def test_generate_host_same_as_context_absolute_url : Nil self .generator(self.routes(ART::Route.new("/{name}", host: "{locale}.example.com")), context: ART::RequestContext.new(base_url: "/base", host: "fr.example.com")) .generate("test", {"name" => "George", "locale" => "fr"}, reference_type: :absolute_url).should eq "http://fr.example.com/base/George" end def test_url_invalid_parameter_in_host : Nil generator = self.generator self.routes ART::Route.new "/", requirements: {"foo" => /bar/}, host: "{foo}.example.com" expect_raises ART::Exception::InvalidParameter do generator.generate "test", foo: "baz" end end def test_url_invalid_parameter_in_host_with_default : Nil generator = self.generator self.routes ART::Route.new "/", {"foo" => "bar"}, {"foo" => /bar/}, host: "{foo}.example.com" expect_raises ART::Exception::InvalidParameter do generator.generate "test", foo: "baz" end end def test_url_invalid_parameter_in_host_with_default_and_matches_default : Nil generator = self.generator self.routes ART::Route.new "/", {"foo" => "baz"}, {"foo" => /bar/}, host: "{foo}.example.com" expect_raises ART::Exception::InvalidParameter do generator.generate "test", foo: "baz" end end def test_url_invalid_parameter_in_host_non_strict_mode : Nil generator = self.generator self.routes ART::Route.new "/", {"foo" => "bar"}, {"foo" => /bar/}, host: "{foo}.example.com" generator.strict_requirements = false generator.generate("test", foo: "baz").should be_empty end def test_generate_host_is_case_insensitive : Nil self .generator(self.routes(ART::Route.new("/", requirements: {"locale" => /en|de|fr/}, host: "{locale}.example.com"))) .generate("test", locale: "EN", reference_type: :network_path).should eq "//EN.example.com/base/" end def test_generate_default_host_is_used_when_context_host_is_empty : Nil generator = self.generator(self.routes(ART::Route.new("/path", {"domain" => "my.fallback.host"}, {"domain" => /.+/}, host: "{domain}"))) generator.context.host = "" generator.generate("test", reference_type: :absolute_url).should eq "http://my.fallback.host/base/path" end def test_generate_default_host_is_used_when_context_host_is_empty_and_path_reference_type : Nil generator = self.generator(self.routes(ART::Route.new("/path", {"domain" => "my.fallback.host"}, {"domain" => /.+/}, host: "{domain}"))) generator.context.host = "" generator.generate("test").should eq "//my.fallback.host/base/path" end def test_generate_absolute_url_fallback_to_path_if_host_is_empty_and_scheme_is_https : Nil generator = self.generator self.routes ART::Route.new "/route" generator.context.host = "" generator.context.scheme = "https" generator.generate("test", reference_type: :absolute_url).should eq "/base/route" end def test_generate_absolute_url_fallback_to_network_if_scheme_is_empty_and_host_is_not : Nil generator = self.generator self.routes ART::Route.new "/route" generator.context.host = "example.com" generator.context.scheme = "" generator.generate("test", reference_type: :absolute_url).should eq "//example.com/base/route" end def test_generate_absolute_url_fallback_to_path_if_scheme_and_host_are_empty : Nil generator = self.generator self.routes ART::Route.new "/route" generator.context.host = "" generator.context.scheme = "" generator.generate("test", reference_type: :absolute_url).should eq "/base/route" end def test_generate_absolute_url_non_http_scheme_and_empty_host : Nil generator = self.generator self.routes ART::Route.new "/route", schemes: "file" generator.context.base_url = "" generator.context.host = "" generator.generate("test", reference_type: :absolute_url).should eq "file:///route" end def test_generate_network_paths : Nil routes = self.routes ART::Route.new "/{name}", host: "{locale}.example.com", schemes: "http" self .generator(routes) .generate("test", name: "George", locale: "de", reference_type: :network_path).should eq "//de.example.com/base/George" self .generator(routes, context: ART::RequestContext.new base_url: "/base", host: "de.example.com") .generate("test", name: "George", locale: "de", query: "string", reference_type: :network_path).should eq "//de.example.com/base/George?query=string" self .generator(routes, context: ART::RequestContext.new base_url: "/base", scheme: "https") .generate("test", name: "George", locale: "de", reference_type: :network_path).should eq "http://de.example.com/base/George" self .generator(routes) .generate("test", name: "George", locale: "de", reference_type: :absolute_url).should eq "http://de.example.com/base/George" end def test_generate_relative_path : Nil routes = ART::RouteCollection.new routes.add "article", ART::Route.new "/{author}/{article}/" routes.add "comments", ART::Route.new "/{author}/{article}/comments" routes.add "host", ART::Route.new "/{article}", host: "{author}.example.com" routes.add "scheme", ART::Route.new "/{author}/blog", schemes: "https" routes.add "unrelated", ART::Route.new "/about" ART.compile routes generator = self.generator routes, context: ART::RequestContext.new base_url: "/base", host: "example.com", path: "/George/athena-is-great/" generator.generate("comments", author: "George", article: "athena-is-great", reference_type: :relative_path).should eq "comments" generator.generate("comments", author: "George", article: "athena-is-great", page: 2, reference_type: :relative_path).should eq "comments?page=2" generator.generate("article", author: "George", article: "crystal-is-great", reference_type: :relative_path).should eq "../crystal-is-great/" generator.generate("article", author: "foo", article: "shards-is-great", reference_type: :relative_path).should eq "../../foo/shards-is-great/" generator.generate("host", author: "George", article: "crystal-is-great", reference_type: :relative_path).should eq "//George.example.com/base/crystal-is-great" generator.generate("scheme", author: "George", reference_type: :relative_path).should eq "https://example.com/base/George/blog" generator.generate("unrelated", reference_type: :relative_path).should eq "../../about" end # This is primarily just sanity checking the stdlib logic to ensure the correct methods are being used. def test_generate_relative_path_internal : Nil routes = ART::RouteCollection.new routes.add "one", ART::Route.new "/a/b/c/d" routes.add "two", ART::Route.new "/a/b/c/" routes.add "three", ART::Route.new "/a/b/" routes.add "four", ART::Route.new "/a/b/c/other" routes.add "five", ART::Route.new "/a/x/y" ART.compile routes generator = self.generator routes, context: ART::RequestContext.new path: "/a/b/c/d" generator.generate("one", reference_type: :relative_path).should eq "" generator.generate("two", reference_type: :relative_path).should eq "./" generator.generate("three", reference_type: :relative_path).should eq "../" generator.generate("four", reference_type: :relative_path).should eq "other" generator.generate("five", reference_type: :relative_path).should eq "../../x/y" end def test_generate_with_fragment : Nil generator = self.generator(self.routes(ART::Route.new("/"))) generator.generate("test", "_fragment": "some text").should eq "/base/#some%20text" generator.generate("test", "_fragment": "0").should eq "/base/#0" end def test_generate_with_fragment_does_not_escape_valid_chars : Nil self .generator(self.routes(ART::Route.new("/"))) .generate("test", "_fragment": "?/").should eq "/base/#?/" end def test_generate_with_fragment_via_default : Nil self .generator(self.routes(ART::Route.new("/", {"_fragment" => "fragment"}))) .generate("test").should eq "/base/#fragment" end @[DataProvider("look_around_provider")] def test_generate_look_around_requirements_in_path(expected : String, path : String, requirement : Regex) : Nil self .generator(self.routes(ART::Route.new(path, requirements: {"foo" => requirement, "baz" => /.+?/}))) .generate("test", foo: "a/b", baz: "c/d/e").should eq expected end def look_around_provider : Tuple { {"/base/a/b/b%28ar/c/d/e", "/{foo}/b(ar/{baz}", /.+(?=\/b\(ar\/)/}, {"/base/a/b/bar/c/d/e", "/{foo}/bar/{baz}", /.+(?!$)/}, {"/base/bar/a/b/bam/c/d/e", "/bar/{foo}/bam/{baz}", /(?<=\/bar\/).+/}, {"/base/bar/a/b/bam/c/d/e", "/bar/{foo}/bam/{baz}", /(? "John", "a" => "foo", "b" => "bar", "c" => "baz", "_query" => { "a" => "123", "d" => "789", }, } ).should eq "/base/user/John?a=123&b=bar&c=baz&d=789" end def test_generate_route_host_parameter_and_query_parameter_with_same_name : Nil self .generator(self.routes(ART::Route.new("/admin/stats", requirements: {"domain" => /.+/}, host: "{siteCode}.{domain}"))) .generate( "test", { "siteCode" => "fr", "domain" => "example.com", "_query" => { "siteCode" => "us", }, }, :network_path ).should eq "//fr.example.com/base/admin/stats?siteCode=us" end def test_generate_route_path_parameter_and_query_parameter_with_same_name : Nil self .generator(self.routes(ART::Route.new("/user/{id}"))) .generate( "test", { "id" => "123", "_query" => { "id" => "456", }, } ).should eq "/base/user/123?id=456" end def test_generate_query_parameter_cannot_substitute_route_parameter : Nil expect_raises ART::Exception::MissingRequiredParameters, "Cannot generate URL for route 'test'. Missing required parameters: 'id'." do self .generator(self.routes ART::Route.new "/user/{id}") .generate("test", {"_query" => {"id" => 456}}) end end private def generator(routes : ART::RouteCollection, *, context : ART::RequestContext? = nil, default_locale : String? = nil) : ART::Generator::URLGenerator context = context || ART::RequestContext.new "/base" ART::Generator::URLGenerator.new context, default_locale end private def routes(route : ART::Route, *, name : String = "test") : ART::RouteCollection routes = ART::RouteCollection.new routes.add name, route ART.compile routes routes end end ================================================ FILE: src/components/routing/spec/matcher/abstract_url_matcher_test_case.cr ================================================ require "../spec_helper" abstract struct AbstractURLMatcherTestCase < ASPEC::TestCase private abstract def get_matcher(routes : ART::RouteCollection, context : ART::RequestContext = ART::RequestContext.new) : ART::Matcher::URLMatcher def test_match_no_method : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo" end self.get_matcher(routes).match("/foo").should eq({"_route" => "foo"}) end def test_match_request : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo" end self.get_matcher(routes).match(::HTTP::Request.new "GET", "/foo").should eq({"_route" => "foo"}) end def test_match_method_not_allowed : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo", methods: "post" end ex = expect_raises ART::Exception::MethodNotAllowed do self.get_matcher(routes).match "/foo" end ex.allowed_methods.should eq ["POST"] end def test_nilable_match_method_not_allowed : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo", methods: "post" end self.get_matcher(routes).match?("/foo").should be_nil end def test_nilable_match_request_method_not_allowed : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo", methods: "post" end self.get_matcher(routes).match?(::HTTP::Request.new("GET", "/foo")).should be_nil end def test_match_method_not_allowed_root : Nil routes = self.build_collection do add "foo", ART::Route.new "/", methods: "get" end ex = expect_raises ART::Exception::MethodNotAllowed do self.get_matcher(routes, ART::RequestContext.new method: "POST").match "/" end ex.allowed_methods.should eq ["GET"] end def test_match_head_allowed_when_requirements_includes_get : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo", methods: "get" end self.get_matcher(routes, ART::RequestContext.new method: "HEAD").match("/foo").should eq({"_route" => "foo"}) end def test_match_method_not_allowed_aggregates_allowed_methods : Nil routes = self.build_collection do add "foo1", ART::Route.new "/foo", methods: "post" add "foo2", ART::Route.new "/foo", methods: {"PUT", "DELETE"} end ex = expect_raises ART::Exception::MethodNotAllowed do self.get_matcher(routes).match "/foo" end ex.allowed_methods.should eq ["POST", "PUT", "DELETE"] end def test_nilable_match_returns_matched_pattern : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/{bar}" end self.get_matcher(routes).match?("/no-match").should be_nil end def test_nilable_match_request_returns_matched_pattern : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/{bar}" end self.get_matcher(routes).match?(::HTTP::Request.new "GET", "/no-match").should be_nil end def test_match_returns_matched_pattern : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/{bar}" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/no-match" end self.get_matcher(routes).match("/foo/baz").should eq({"_route" => "foo", "bar" => "baz"}) end def test_match_defaults_are_merged : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/{bar}", {"def" => "test"} end self.get_matcher(routes).match("/foo/baz").should eq({"_route" => "foo", "bar" => "baz", "def" => "test"}) end def test_match_returned_results_do_not_mutate_the_original_static_route : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo", {"def" => "test"} end matcher = self.get_matcher routes parameters = matcher.match("/foo") parameters.should eq({"_route" => "foo", "def" => "test"}) parameters.delete "_route" matcher.match("/foo").should eq({"_route" => "foo", "def" => "test"}) end def test_match_returned_results_do_not_mutate_the_original_dynamic_route : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/{id}" end matcher = self.get_matcher routes parameters = matcher.match("/foo/10") parameters.should eq({"_route" => "foo", "id" => "10"}) parameters.delete "_route" matcher.match("/foo/10").should eq({"_route" => "foo", "id" => "10"}) end def test_match_method_is_ignored_if_none_are_provided : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo", methods: {"GET", "HEAD"} end self.get_matcher(routes).match("/foo").should eq({"_route" => "foo"}) expect_raises ART::Exception::MethodNotAllowed do self.get_matcher(routes, ART::RequestContext.new method: "POST").match "/foo" end self.get_matcher(routes).match("/foo").should eq({"_route" => "foo"}) self.get_matcher(routes, ART::RequestContext.new method: "HEAD").match("/foo").should eq({"_route" => "foo"}) end def test_match_optional_variable_as_first_segment : Nil routes = self.build_collection do add "bar", ART::Route.new "/{bar}/foo", {"bar" => "bar"}, {"bar" => /foo|bar/} end matcher = self.get_matcher routes matcher.match("/bar/foo").should eq({"_route" => "bar", "bar" => "bar"}) matcher.match("/foo/foo").should eq({"_route" => "bar", "bar" => "foo"}) routes = self.build_collection do add "bar", ART::Route.new "/{bar}", {"bar" => "bar"}, {"bar" => /foo|bar/} end ART::RouteProvider.reset matcher = self.get_matcher routes matcher.match("/foo").should eq({"_route" => "bar", "bar" => "foo"}) matcher.match("/").should eq({"_route" => "bar", "bar" => "bar"}) end def test_match_only_optional_variable : Nil routes = self.build_collection do add "bar", ART::Route.new "/{foo}/{bar}", {"bar" => "bar", "foo" => "foo"} end matcher = self.get_matcher routes matcher.match("/").should eq({"_route" => "bar", "bar" => "bar", "foo" => "foo"}) matcher.match("/a").should eq({"_route" => "bar", "bar" => "bar", "foo" => "a"}) matcher.match("/a/b").should eq({"_route" => "bar", "bar" => "b", "foo" => "a"}) end def test_match_with_prefix : Nil routes = self.build_collection do add "foo", ART::Route.new "/{foo}" add_prefix "/b" add_prefix "/a" end self.get_matcher(routes).match("/a/b/foo").should eq({"_route" => "foo", "foo" => "foo"}) end def test_match_with_dynamic_prefix : Nil routes = self.build_collection do add "foo", ART::Route.new "/{foo}" add_prefix "/b" add_prefix "/{_locale}" end self.get_matcher(routes).match("/de/b/foo").should eq({"_route" => "foo", "_locale" => "de", "foo" => "foo"}) end def test_match_special_route_name : Nil routes = self.build_collection do add "$péß^a|", ART::Route.new "/bar" end self.get_matcher(routes).match("/bar").should eq({"_route" => "$péß^a|"}) end def test_match_important_variables : Nil routes = self.build_collection do add "index", ART::Route.new "/index.{!_format}", {"_format" => "xml"} end self.get_matcher(routes).match("/index.xml").should eq({"_route" => "index", "_format" => "xml"}) end def test_match_short_path_does_not_match_important_variable : Nil routes = self.build_collection do add "index", ART::Route.new "/index.{!_format}", {"_format" => "xml"} end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/index" end end def test_match_short_path_matches_non_important_variable : Nil routes = self.build_collection do add "index", ART::Route.new "/index.{_format}", {"_format" => "xml"} end self.get_matcher(routes).match("/index.xml").should eq({"_route" => "index", "_format" => "xml"}) end def test_match_trailing_encoded_new_line_is_not_overlooked : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/foo%0a" end end def test_match_non_alphanum : Nil chars = "!\"$%éà &'()*+,./:;<=>@ABCDEFGHIJKLMNOPQRSTUVWXYZ\\[]^_`abcdefghijklmnopqrstuvwxyz{|}~-" routes = self.build_collection do add "foo", ART::Route.new "/{foo}/bar", requirements: {"foo" => /#{Regex.escape chars}/} end matcher = self.get_matcher routes matcher.match("/#{URI.encode_path_segment chars}/bar").should eq({"_route" => "foo", "foo" => chars}) matcher.match(%(/#{chars.tr "%", "%25"}/bar)).should eq({"_route" => "foo", "foo" => chars}) end def test_match_with_dot_in_requirements : Nil routes = self.build_collection do add "foo", ART::Route.new "/{foo}/bar", requirements: {"foo" => /.+/} end self.get_matcher(routes).match("/#{URI.encode_path_segment "\n"}/bar").should eq({"_route" => "foo", "foo" => "\n"}) end def test_match_regression : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/{foo}" add "bar", ART::Route.new "/foo/bar/{foo}" end self.get_matcher(routes).match("/foo/bar/bar").should eq({"_route" => "bar", "foo" => "bar"}) routes = self.build_collection do add "foo", ART::Route.new "/{bar}" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/" end end def test_match_multiple_params : Nil routes = self.build_collection do add "foo1", ART::Route.new "/foo/{a}/{b}" add "foo2", ART::Route.new "/foo/{a}/test/test/{b}" add "foo3", ART::Route.new "/foo/{a}/{b}/{c}/{d}" end self.get_matcher(routes).match("/foo/test/test/test/bar").should eq({"_route" => "foo2", "a" => "test", "b" => "bar"}) end def test_match_default_requirements_for_optional_variables : Nil routes = self.build_collection do add "test", ART::Route.new "/{page}.{_format}", {"page" => "index", "_format" => "html"} end self.get_matcher(routes).match("/my-page.xml").should eq({"_route" => "test", "page" => "my-page", "_format" => "xml"}) end def test_match_match_overridden_route : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo" end routes2 = self.build_collection do add "foo", ART::Route.new "/foo1" end routes.add routes2 self.get_matcher(routes).match("/foo1").should eq({"_route" => "foo"}) expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/foo" end end def test_match_matching_is_eager : Nil routes = self.build_collection do add "test", ART::Route.new "/{foo}-{bar}-", requirements: {"foo" => /.+/, "bar" => ".+"} end self.get_matcher(routes).match("/text1-text2-text3-text4-").should eq({"_route" => "test", "foo" => "text1-text2-text3", "bar" => "text4"}) end def test_match_adjacent_variables : Nil routes = self.build_collection do add "test", ART::Route.new "/{w}{x}{y}{z}.{_format}", {"z" => "default-z", "_format" => "html"}, {"y" => /y|Y/} end matcher = self.get_matcher routes matcher.match("/wwwwwxYZ.xml").should eq({"_route" => "test", "_format" => "xml", "w" => "wwwww", "x" => "x", "y" => "Y", "z" => "Z"}) matcher.match("/wwwwwxyZZZ").should eq({"_route" => "test", "_format" => "html", "w" => "wwwww", "x" => "x", "y" => "y", "z" => "ZZZ"}) matcher.match("/wwwwwxy").should eq({"_route" => "test", "_format" => "html", "w" => "wwwww", "x" => "x", "y" => "y", "z" => "default-z"}) expect_raises ART::Exception::ResourceNotFound do matcher.match "/wxy.html" end end def test_match_optional_variable_with_no_real_separator : Nil routes = self.build_collection do add "test", ART::Route.new "/get{what}", {"what" => "All"} end matcher = self.get_matcher routes matcher.match("/get").should eq({"_route" => "test", "what" => "All"}) matcher.match("/getSites").should eq({"_route" => "test", "what" => "Sites"}) end def test_match_required_variable_with_no_real_separator : Nil routes = self.build_collection do add "test", ART::Route.new "/get{what}Suffix" end self.get_matcher(routes).match("/getSitesSuffix").should eq({"_route" => "test", "what" => "Sites"}) end def test_match_default_requirement_of_variable : Nil routes = self.build_collection do add "test", ART::Route.new "/{page}.{_format}" end self.get_matcher(routes).match("/index.mobile.html").should eq({"_route" => "test", "page" => "index", "_format" => "mobile.html"}) end def test_match_default_requirement_of_variable_disallows_slash : Nil routes = self.build_collection do add "test", ART::Route.new "/{page}.{_format}" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/index.sl/ash" end end def test_match_default_requirement_of_variable_disallows_next_separator : Nil routes = self.build_collection do add "test", ART::Route.new "/{page}.{_format}", requirements: {"_format" => /html|xml/} end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/do.t.html" end end def test_match_missing_trailing_slash : Nil routes = self.build_collection do add "test", ART::Route.new "/foo/" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/foo" end end def test_match_extra_trailing_slash : Nil routes = self.build_collection do add "test", ART::Route.new "/foo" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/foo/" end end def test_match_missing_trailing_slash_non_safe_method : Nil routes = self.build_collection do add "test", ART::Route.new "/foo/" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes, ART::RequestContext.new method: "POST").match "/foo" end end def test_match_extra_trailing_slash_non_safe_method : Nil routes = self.build_collection do add "test", ART::Route.new "/foo" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes, ART::RequestContext.new method: "POST").match "/foo/" end end def test_match_scheme_requirement : Nil routes = self.build_collection do add "test", ART::Route.new "/foo", schemes: "https" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/foo" end end def test_match_scheme_requirement_non_safe_method : Nil routes = self.build_collection do add "test", ART::Route.new "/foo", schemes: "https" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes, ART::RequestContext.new method: "POST").match "/foo" end end def test_match_same_path_with_different_scheme : Nil routes = self.build_collection do add "https_route", ART::Route.new "/", schemes: "https" add "http_route", ART::Route.new "/", schemes: "http" end self.get_matcher(routes).match("/").should eq({"_route" => "http_route"}) end def test_match_condition : Nil routes = self.build_collection do route = ART::Route.new "/foo" route.condition do |ctx| "POST" == ctx.method end add "foo", route end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/foo" end end def test_match_request_condition : Nil routes = self.build_collection do route = ART::Route.new "/foo/{bar}" route.condition do |_, request| request.path.starts_with? "/foo" end add "foo", route route = ART::Route.new "/foo/{bar}" route.condition do |_, request| "/foo/foo" == request.path end add "bar", route end self.get_matcher(routes).match("/foo/bar").should eq({"_route" => "foo", "bar" => "bar"}) end def test_match_decode_once : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/{bar}" end self.get_matcher(routes).match("/foo/bar%2523").should eq({"_route" => "foo", "bar" => "bar%23"}) end def test_match_cannot_rely_on_prefix : Nil routes = self.build_collection do sub_routes = self.build_collection do add "bar", ART::Route.new "/bar" add_prefix "/prefix" itself["bar"].path = "/new" end add sub_routes end self.get_matcher(routes).match("/new").should eq({"_route" => "bar"}) end def test_match_with_host : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/{foo}", host: "{locale}.example.com" end self.get_matcher(routes, ART::RequestContext.new host: "de.example.com").match("/foo/bar").should eq({"_route" => "foo", "foo" => "bar", "locale" => "de"}) end def test_match_with_host_on_collection : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/{foo}" add "bar", ART::Route.new "/bar/{foo}", host: "{locale}.example.com" set_host "{locale}.example.com" end matcher = self.get_matcher routes, ART::RequestContext.new host: "en.example.com" matcher.match("/foo/bar").should eq({"_route" => "foo", "foo" => "bar", "locale" => "en"}) matcher = self.get_matcher routes, ART::RequestContext.new host: "en.example.com" matcher.match("/bar/bar").should eq({"_route" => "bar", "foo" => "bar", "locale" => "en"}) end def test_match_variation_in_trailing_slash_with_host : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/", host: "foo.example.com" add "bar", ART::Route.new "/foo", host: "bar.example.com" end matcher = self.get_matcher routes, ART::RequestContext.new host: "foo.example.com" matcher.match("/foo/").should eq({"_route" => "foo"}) matcher = self.get_matcher routes, ART::RequestContext.new host: "bar.example.com" matcher.match("/foo").should eq({"_route" => "bar"}) end def test_match_variation_in_trailing_slash_with_host_reversed : Nil routes = self.build_collection do add "bar", ART::Route.new "/foo", host: "bar.example.com" add "foo", ART::Route.new "/foo/", host: "foo.example.com" end matcher = self.get_matcher routes, ART::RequestContext.new host: "foo.example.com" matcher.match("/foo/").should eq({"_route" => "foo"}) matcher = self.get_matcher routes, ART::RequestContext.new host: "bar.example.com" matcher.match("/foo").should eq({"_route" => "bar"}) end def test_match_variation_in_trailing_slash_with_host_and_variable : Nil routes = self.build_collection do add "foo", ART::Route.new "/{foo}/", host: "foo.example.com" add "bar", ART::Route.new "/{foo}", host: "bar.example.com" end matcher = self.get_matcher routes, ART::RequestContext.new host: "foo.example.com" matcher.match("/bar/").should eq({"_route" => "foo", "foo" => "bar"}) matcher = self.get_matcher routes, ART::RequestContext.new host: "bar.example.com" matcher.match("/bar").should eq({"_route" => "bar", "foo" => "bar"}) end def test_match_variation_in_trailing_slash_with_host_and_variable_reversed : Nil routes = self.build_collection do add "bar", ART::Route.new "/{foo}", host: "bar.example.com" add "foo", ART::Route.new "/{foo}/", host: "foo.example.com" end matcher = self.get_matcher routes, ART::RequestContext.new host: "foo.example.com" matcher.match("/bar/").should eq({"_route" => "foo", "foo" => "bar"}) matcher = self.get_matcher routes, ART::RequestContext.new host: "bar.example.com" matcher.match("/bar").should eq({"_route" => "bar", "foo" => "bar"}) end def test_match_variation_in_trailing_slash_with_host_and_method : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/", methods: "POST" add "bar", ART::Route.new "/foo", methods: "GET" end matcher = self.get_matcher routes, ART::RequestContext.new method: "POST" matcher.match("/foo/").should eq({"_route" => "foo"}) matcher = self.get_matcher routes, ART::RequestContext.new method: "GET" matcher.match("/foo").should eq({"_route" => "bar"}) end def test_match_variation_in_trailing_slash_with_host_and_method_reversed : Nil routes = self.build_collection do add "bar", ART::Route.new "/foo", methods: "GET" add "foo", ART::Route.new "/foo/", methods: "POST" end matcher = self.get_matcher routes, ART::RequestContext.new method: "POST" matcher.match("/foo/").should eq({"_route" => "foo"}) matcher = self.get_matcher routes, ART::RequestContext.new method: "GET" matcher.match("/foo").should eq({"_route" => "bar"}) end def test_match_variable_variation_in_trailing_slash_with_method : Nil routes = self.build_collection do add "foo", ART::Route.new "/{foo}/", methods: "POST" add "bar", ART::Route.new "/{foo}", methods: "GET" end matcher = self.get_matcher routes, ART::RequestContext.new method: "POST" matcher.match("/bar/").should eq({"_route" => "foo", "foo" => "bar"}) matcher = self.get_matcher routes, ART::RequestContext.new method: "GET" # pp ART::RouteProvider matcher.match("/bar").should eq({"_route" => "bar", "foo" => "bar"}) end def test_match_variable_variation_in_trailing_slash_with_method_reversed : Nil routes = self.build_collection do add "bar", ART::Route.new "/{foo}", methods: "GET" add "foo", ART::Route.new "/{foo}/", methods: "POST" end matcher = self.get_matcher routes, ART::RequestContext.new method: "POST" matcher.match("/bar/").should eq({"_route" => "foo", "foo" => "bar"}) matcher = self.get_matcher routes, ART::RequestContext.new method: "GET" matcher.match("/bar").should eq({"_route" => "bar", "foo" => "bar"}) end def test_match_mix_of_static_and_variable_variation_in_trailing_slash_with_hosts : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/", host: "foo.example.com" add "bar", ART::Route.new "/{foo}", host: "bar.example.com" end matcher = self.get_matcher routes, ART::RequestContext.new host: "foo.example.com" matcher.match("/foo/").should eq({"_route" => "foo"}) matcher = self.get_matcher routes, ART::RequestContext.new host: "bar.example.com" matcher.match("/bar").should eq({"_route" => "bar", "foo" => "bar"}) end def test_match_mix_of_static_and_variable_variation_in_trailing_slash_with_methods : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/", methods: "POST" add "bar", ART::Route.new "/{foo}", methods: "GET" end matcher = self.get_matcher routes, ART::RequestContext.new method: "POST" matcher.match("/foo/").should eq({"_route" => "foo"}) matcher = self.get_matcher routes, ART::RequestContext.new method: "GET" matcher.match("/foo").should eq({"_route" => "bar", "foo" => "foo"}) matcher.match("/bar").should eq({"_route" => "bar", "foo" => "bar"}) end def test_match_with_host_does_not_match routes = self.build_collection do add "foo", ART::Route.new "/foo/{foo}", host: "{locale}.example.com" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes, ART::RequestContext.new host: "example.com").match "/foo/bar" end end def test_match_path_is_case_sensitive routes = self.build_collection do add "foo", ART::Route.new "/{locale}", requirements: {"locale" => /EN|FR|DE/} end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/en" end end def test_match_host_is_case_insensitive routes = self.build_collection do add "foo", ART::Route.new "/", requirements: {"locale" => /EN|FR|DE/}, host: "{locale}.example.com" end self.get_matcher(routes, ART::RequestContext.new host: "en.example.com").match("/").should eq({"_route" => "foo", "locale" => "en"}) end def test_match_no_configuration : Nil expect_raises ART::Exception::NoConfiguration do self.get_matcher(ART::RouteCollection.new).match "/" end end def test_match_nested_collection : Nil routes = self.build_collection do sub_collection = self.build_collection do add "a", ART::Route.new "/a" add "b", ART::Route.new "/b" add "c", ART::Route.new "/c" add_prefix "/p" end add sub_collection add "baz", ART::Route.new "/{baz}" sub_collection = self.build_collection do add "buz", ART::Route.new "/buz" add_prefix "/prefix" end add sub_collection end matcher = self.get_matcher routes matcher.match("/p/a").should eq({"_route" => "a"}) matcher.match("/p").should eq({"_route" => "baz", "baz" => "p"}) matcher.match("/prefix/buz").should eq({"_route" => "buz"}) end def test_match_scheme_and_method_mismatch : Nil routes = self.build_collection do add "foo", ART::Route.new "/", schemes: "https", methods: "POST" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/" end end def test_sibling_routes : Nil routes = self.build_collection do add "a", ART::Route.new "/a{a}", methods: "POST" add "b", ART::Route.new "/a{a}", methods: "PUT" add "c", ART::Route.new "/a{a}" add "d", ART::Route.new("/b{a}").condition { false } add "e", ART::Route.new("/{b}{a}").condition { false } add "f", ART::Route.new "/{b}{a}", requirements: {"b" => /b/} end matcher = self.get_matcher routes matcher.match("/aa").should eq({"_route" => "c", "a" => "a"}) matcher.match("/be").should eq({"_route" => "f", "a" => "e", "b" => "b"}) end def test_match_requirements_with_capture_groups : Nil routes = self.build_collection do add "a", ART::Route.new "/{a}/{b}", requirements: {"a" => /(a|b)/} end self.get_matcher(routes).match("/a/b").should eq({"_route" => "a", "a" => "a", "b" => "b"}) end def test_match_dot_all_with_catch_all : Nil routes = self.build_collection do add "a", ART::Route.new "/{id}.html", requirements: {"id" => /.+/} add "b", ART::Route.new "/{all}", requirements: {"all" => /.+/} end self.get_matcher(routes).match("/foo/bar.html").should eq({"_route" => "a", "id" => "foo/bar"}) end def test_match_host_pattern : Nil routes = self.build_collection do add "a", ART::Route.new "/{app}/{action}/{unused}", host: "{host}" end self .get_matcher(routes, ART::RequestContext.new host: "foo") .match("/app/action/unused").should eq({"_route" => "a", "app" => "app", "action" => "action", "unused" => "unused", "host" => "foo"}) end def test_match_host_with_dot : Nil routes = self.build_collection do add "a", ART::Route.new "/foo", host: "foo.example.com" add "b", ART::Route.new "/bar/{baz}" end self.get_matcher(routes).match("/bar/abc.123").should eq({"_route" => "b", "baz" => "abc.123"}) end def test_match_slash_variant : Nil routes = self.build_collection do add "a", ART::Route.new "/foo/{bar}", requirements: {"bar" => /.*/} end self.get_matcher(routes).match("/foo/").should eq({"_route" => "a", "bar" => ""}) self.get_matcher(routes).match("/foo/bar/").should eq({"_route" => "a", "bar" => "bar/"}) end def test_match_slash_with_verb : Nil routes = self.build_collection do add "a", ART::Route.new "/{foo}", methods: {"put", "delete"} add "b", ART::Route.new "/bar/" end self.get_matcher(routes).match("/bar/").should eq({"_route" => "b"}) end def test_match_slash_with_verb_match_all : Nil routes = self.build_collection do add "a", ART::Route.new "/dav/{foo}", requirements: {"foo" => /.*/}, methods: {"get", "options"} end self .get_matcher(routes, ART::RequestContext.new method: "OPTIONS") .match("/dav/files/bar/").should eq({"_route" => "a", "foo" => "files/bar/"}) end def test_match_slash_and_verb_precedence : Nil routes = self.build_collection do add "a", ART::Route.new "/api/customers/{customerId}/contactpersons/", methods: "POST" add "b", ART::Route.new "/api/customers/{customerId}/contactpersons", methods: "GET" end self.get_matcher(routes).match("/api/customers/123/contactpersons").should eq({"_route" => "b", "customerId" => "123"}) end def test_match_slash_and_verb_precedence_reversed : Nil routes = self.build_collection do add "a", ART::Route.new "/api/customers/{customerId}/contactpersons/", methods: "GET" add "b", ART::Route.new "/api/customers/{customerId}/contactpersons", methods: "POST" end self.get_matcher(routes, ART::RequestContext.new method: "POST").match("/api/customers/123/contactpersons").should eq({"_route" => "b", "customerId" => "123"}) end def test_match_greedy_trailing_requirement : Nil routes = self.build_collection do add "a", ART::Route.new "/{a}", requirements: {"a" => /.+/} end self.get_matcher(routes).match("/foo").should eq({"_route" => "a", "a" => "foo"}) self.get_matcher(routes).match("/foo/").should eq({"_route" => "a", "a" => "foo/"}) end def test_match_greedy_trailing_requirement_with_default : Nil routes = self.build_collection do add "a", ART::Route.new "/fr-fr/{a}", {"a" => "aaa"}, {"a" => /.+/} add "b", ART::Route.new "/en-en/{b}", {"b" => "bbb"}, {"b" => /.+/} end self.get_matcher(routes).match("/fr-fr").should eq({"_route" => "a", "a" => "aaa"}) self.get_matcher(routes).match("/fr-fr/AAA").should eq({"_route" => "a", "a" => "AAA"}) self.get_matcher(routes).match("/en-en").should eq({"_route" => "b", "b" => "bbb"}) self.get_matcher(routes).match("/en-en/BBB").should eq({"_route" => "b", "b" => "BBB"}) end def test_match_greedy_trailing_requirement_default1 : Nil routes = self.build_collection do add "a", ART::Route.new "/fr-fr/{a}", {"a" => "aaa"}, {"a" => /.+/} end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes).match "/fr-fr/" end end def test_match_greedy_trailing_requirement_default2 : Nil routes = self.build_collection do add "b", ART::Route.new "/en-en/{b}", {"b" => "bbb"}, {"b" => /.*/} end self.get_matcher(routes).match("/en-en/").should eq({"_route" => "b", "b" => ""}) end def test_match_restrictive_trailing_requirement_with_static_route_after : Nil routes = self.build_collection do add "a", ART::Route.new "/hello{_}", requirements: {"_" => /\/(?!\/)/} add "b", ART::Route.new "/hello" end self.get_matcher(routes).match("/hello/").should eq({"_route" => "a", "_" => "/"}) end private def build_collection(&) : ART::RouteCollection routes = ART::RouteCollection.new with routes yield routes end end ================================================ FILE: src/components/routing/spec/matcher/redirectable_url_matcher_spec.cr ================================================ require "../spec_helper" require "./abstract_url_matcher_test_case" private class MockRedirectableURLMatcher < ART::Matcher::URLMatcher include ART::Matcher::RedirectableURLMatcherInterface class_setter return_value : ART::Parameters = ART::Parameters.new class_setter was_called : Bool = true class_setter expected_path : String? = nil class_setter expected_route : String? = nil class_setter expected_scheme : String? = nil def redirect(path : String, route : String, scheme : String? = nil) : ART::Parameters? @@was_called.should be_true if ep = @@expected_path path.should eq ep end if er = @@expected_route route.should eq er end if es = @@expected_scheme scheme.should eq es end @@return_value.dup ensure @@return_value = ART::Parameters.new @@was_called = true @@expected_path = nil @@expected_route = nil @@expected_scheme = nil end end struct RedirectableURLMatcherTest < AbstractURLMatcherTestCase def test_match_missing_trailing_slash : Nil routes = self.build_collection do add "test", ART::Route.new "/foo/" end self.get_matcher(routes).match("/foo").to_h.should eq({"_route" => "test"}) end def test_match_extra_trailing_slash : Nil routes = self.build_collection do add "test", ART::Route.new "/foo" end self.get_matcher(routes).match("/foo/").to_h.should eq({"_route" => "test"}) end def test_redirect_when_no_slash_for_non_safe_method : Nil routes = self.build_collection do add "test", ART::Route.new "/foo/" end expect_raises ART::Exception::ResourceNotFound do self.get_matcher(routes, ART::RequestContext.new method: "POST").match "/foo" end end # TODO: Uncomment scheme check when supported def test_scheme_redirect_redirects_to_first_scheme : Nil routes = self.build_collection do add "test", ART::Route.new "/foo", schemes: {"FTP", "HTTPS"} end MockRedirectableURLMatcher.expected_path = "/foo" MockRedirectableURLMatcher.expected_route = "test" # MockRedirectableURLMatcher.expected_scheme = "ftp" self.get_matcher(routes).match("/foo").to_h.should eq({"_route" => "test"}) end # TODO: Enable when schemes are supported def ptest_no_schema_redirect_if_one_of_multiple_schemas_matches : Nil routes = self.build_collection do add "test", ART::Route.new "/foo", schemes: {"https", "http"} end MockRedirectableURLMatcher.was_called = false self.get_matcher(routes).match("/foo").to_h.should eq({"_route" => "test"}) end # TODO: Enable when schemes are supported def ptest_scheme_redirect_with_params : Nil routes = self.build_collection do add "test", ART::Route.new "/foo/{bar}", schemes: "https" end rv = ART::Parameters.new rv["redirect"] = "value" MockRedirectableURLMatcher.return_value = rv MockRedirectableURLMatcher.expected_path = "/foo/baz" MockRedirectableURLMatcher.expected_route = "test" # MockRedirectableURLMatcher.expected_scheme = "https" self.get_matcher(routes).match("/foo/baz").to_h.should eq({"_route" => "test", "bar" => "baz", "redirect" => "value"}) end def test_scheme_redirect_for_root : Nil routes = self.build_collection do add "test", ART::Route.new "/", schemes: "https" end rv = ART::Parameters.new rv["redirect"] = "value" MockRedirectableURLMatcher.return_value = rv MockRedirectableURLMatcher.expected_path = "/" MockRedirectableURLMatcher.expected_route = "test" # MockRedirectableURLMatcher.expected_scheme = "https" self.get_matcher(routes).match("/").to_h.should eq({"_route" => "test", "redirect" => "value"}) end def test_slash_redirect_with_params : Nil routes = self.build_collection do add "test", ART::Route.new "/foo/{bar}/" end rv = ART::Parameters.new rv["redirect"] = "value" MockRedirectableURLMatcher.return_value = rv MockRedirectableURLMatcher.expected_path = "/foo/baz/" MockRedirectableURLMatcher.expected_route = "test" self.get_matcher(routes).match("/foo/baz").to_h.should eq({"_route" => "test", "bar" => "baz", "redirect" => "value"}) end def test_redirect_preserves_url_encoding : Nil routes = self.build_collection do add "test", ART::Route.new "/foo:bar/" end MockRedirectableURLMatcher.expected_path = "/foo%3Abar/" self.get_matcher(routes).match("/foo%3Abar").to_h.should eq({"_route" => "test"}) end def test_match_scheme_requirement : Nil routes = self.build_collection do add "test", ART::Route.new "/foo", schemes: "https" end self.get_matcher(routes).match("/foo").to_h.should eq({"_route" => "test"}) end def test_fallback_page1 : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/" add "bar", ART::Route.new "/{name}" end MockRedirectableURLMatcher.expected_path = "/foo/" MockRedirectableURLMatcher.expected_route = "foo" self.get_matcher(routes).match("/foo").to_h.should eq({"_route" => "foo"}) end def test_fallback_page2 : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo" add "bar", ART::Route.new "/{name}/" end MockRedirectableURLMatcher.expected_path = "/foo" MockRedirectableURLMatcher.expected_route = "foo" self.get_matcher(routes).match("/foo/").to_h.should eq({"_route" => "foo"}) end def test_missing_trailing_slash_and_scheme : Nil routes = self.build_collection do add "foo", ART::Route.new "/foo/", schemes: "https" end MockRedirectableURLMatcher.expected_path = "/foo/" MockRedirectableURLMatcher.expected_route = "foo" # MockRedirectableURLMatcher.expected_scheme = "https" self.get_matcher(routes).match("/foo").to_h.should eq({"_route" => "foo"}) end def test_slash_and_verb_precedence_with_redirection : Nil routes = self.build_collection do add "a", ART::Route.new "/api/customers/{customerId}/contactpersons", methods: "POST" add "b", ART::Route.new "/api/customers/{customerId}/contactpersons/", methods: "GET" end matcher = self.get_matcher routes expected = {"_route" => "b", "customerId" => "123"} matcher.match("/api/customers/123/contactpersons/").to_h.should eq expected MockRedirectableURLMatcher.expected_path = "/api/customers/123/contactpersons/" matcher.match("/api/customers/123/contactpersons").to_h.should eq expected end def test_non_greedy_trailing_requirement : Nil routes = self.build_collection do add "a", ART::Route.new "/{a}", requirements: {"a" => /\d+/} end MockRedirectableURLMatcher.expected_path = "/123" self.get_matcher(routes).match("/123/").to_h.should eq({"_route" => "a", "a" => "123"}) end def test_match_greedy_trailing_requirement_default1 : Nil routes = self.build_collection do add "a", ART::Route.new "/fr-fr/{a}", {"a" => "aaa"}, {"a" => /.+/} end self.get_matcher(routes).match("/fr-fr/").to_h.should eq({"_route" => "a", "a" => "aaa"}) end private def get_matcher(routes : ART::RouteCollection, context : ART::RequestContext = ART::RequestContext.new) : ART::Matcher::URLMatcher ART.compile routes MockRedirectableURLMatcher.new context end end ================================================ FILE: src/components/routing/spec/matcher/traceable_url_matcher_spec.cr ================================================ require "../spec_helper" require "./abstract_url_matcher_test_case" struct TraceableURLMatcherTest < AbstractURLMatcherTestCase private def get_matcher(routes : ART::RouteCollection, context : ART::RequestContext = ART::RequestContext.new) : ART::Matcher::URLMatcher ART::Matcher::TraceableURLMatcher.new routes, context end def test_traces : Nil condition_route = ART::Route.new "/foo2", host: "baz" condition_route.condition do |ctx| "GET" == ctx.method end routes = self.build_collection do add "foo", ART::Route.new "/foo", methods: "POST" add "bar", ART::Route.new "/bar/{id}", requirements: {"id" => /\d+/} add "bar1", ART::Route.new "/bar/{name}", requirements: {"id" => /\w+/}, methods: "POST" add "bar2", ART::Route.new "/foo", host: "baz" add "bar3", ART::Route.new "/foo1", host: "baz" add "bar4", condition_route end context = ART::RequestContext.new host: "baz" matcher = ART::Matcher::TraceableURLMatcher.new routes, context traces = matcher.traces("/babar") self.get_levels(traces).should eq [0, 0, 0, 0, 0, 0] traces = matcher.traces("/foo") self.get_levels(traces).should eq [1, 0, 0, 2] traces = matcher.traces("/bar/12") self.get_levels(traces).should eq [0, 2] traces = matcher.traces("/bar/dd") self.get_levels(traces).should eq [0, 1, 1, 0, 0, 0] traces = matcher.traces("/foo1") self.get_levels(traces).should eq [0, 0, 0, 0, 2] context.method = "POST" traces = matcher.traces("/foo") self.get_levels(traces).should eq [2] traces = matcher.traces("/bar/dd") self.get_levels(traces).should eq [0, 1, 2] # Test Request overload (method is set via context) traces = matcher.traces(::HTTP::Request.new "GET", "/bar/dd") self.get_levels(traces).should eq [0, 1, 2] traces = matcher.traces("/foo2") self.get_levels(traces).should eq [0, 0, 0, 0, 0, 1] end def test_traces_match_route_on_multiple_hosts : Nil routes = self.build_collection do add "first", ART::Route.new "/mypath/", {"_controller" => "SomeController#first"}, host: "some.example.com" add "second", ART::Route.new "/mypath/", {"_controller" => "SomeController#second"}, host: "another.example.com" end context = ART::RequestContext.new host: "baz" matcher = ART::Matcher::TraceableURLMatcher.new routes, context traces = matcher.traces("/mypath/") self.get_levels(traces).should eq [1, 1] end private def get_levels(traces : Array(ART::Matcher::TraceableURLMatcher::Trace)) : Array(Int32) traces.map &.level.value end private def build_collection(&) : ART::RouteCollection routes = ART::RouteCollection.new with routes yield routes end end ================================================ FILE: src/components/routing/spec/matcher/url_matcher_spec.cr ================================================ require "../spec_helper" require "./abstract_url_matcher_test_case" struct URLMatcherTest < AbstractURLMatcherTestCase private def get_matcher(routes : ART::RouteCollection, context : ART::RequestContext = ART::RequestContext.new) : ART::Matcher::URLMatcher ART.compile routes ART::Matcher::URLMatcher.new context end end ================================================ FILE: src/components/routing/spec/parameters_spec.cr ================================================ require "./spec_helper" struct ParametersTest < ASPEC::TestCase # Constructors def test_initialize_empty : Nil params = ART::Parameters.new params.empty?.should be_true params.size.should eq 0 end def test_new_from_hash : Nil params = ART::Parameters.new({"foo" => "bar", "baz" => "qux"}) params.size.should eq 2 params["foo"].should eq "bar" params["baz"].should eq "qux" end def test_new_from_hash_with_different_types : Nil params = ART::Parameters.new({"count" => 42, "enabled" => true}) params.size.should eq 2 params.get("count", Int32).should eq 42 params.get("enabled", Bool).should be_true end def test_new_from_parameters : Nil original = ART::Parameters.new({"foo" => "bar"}) copy = ART::Parameters.new(original) copy.should eq original end # has_key? def test_has_key_with_existing_key : Nil params = ART::Parameters.new({"foo" => "bar"}) params.has_key?("foo").should be_true end def test_has_key_with_missing_key : Nil params = ART::Parameters.new({"foo" => "bar"}) params.has_key?("missing").should be_false end def test_has_key_empty : Nil params = ART::Parameters.new params.has_key?("anything").should be_false end # []? (returns String?) def test_bracket_question_with_string_value : Nil params = ART::Parameters.new({"foo" => "bar"}) params["foo"]?.should eq "bar" end def test_bracket_question_with_missing_key : Nil params = ART::Parameters.new({"foo" => "bar"}) params["missing"]?.should be_nil end def test_bracket_question_with_non_string_value : Nil params = ART::Parameters.new params["count"] = 42 params["count"]?.should be_nil end # [] (returns String, raises KeyError) def test_bracket_with_string_value : Nil params = ART::Parameters.new({"foo" => "bar"}) params["foo"].should eq "bar" end def test_bracket_with_missing_key : Nil params = ART::Parameters.new expect_raises(KeyError, "No parameter exists with the name 'missing'.") do params["missing"] end end def test_bracket_with_non_string_value : Nil params = ART::Parameters.new params["count"] = 42 expect_raises(TypeCastError) do params["count"] end end # raw? def test_raw_with_string_value : Nil params = ART::Parameters.new({"foo" => "bar"}) params.raw?("foo").should eq "bar" end def test_raw_with_int_value : Nil params = ART::Parameters.new params["count"] = 42 params.raw?("count").should eq 42 end def test_raw_with_bool_value : Nil params = ART::Parameters.new params["enabled"] = true params.raw?("enabled").should be_true end def test_raw_with_missing_key : Nil params = ART::Parameters.new params.raw?("missing").should be_nil end # get? (typed retrieval, returns T?) def test_get_question_with_correct_type : Nil params = ART::Parameters.new params["count"] = 42 params.get?("count", Int32).should eq 42 end def test_get_question_with_wrong_type : Nil params = ART::Parameters.new({"foo" => "bar"}) params.get?("foo", Int32).should be_nil end def test_get_question_with_missing_key : Nil params = ART::Parameters.new params.get?("missing", String).should be_nil end def test_get_question_string : Nil params = ART::Parameters.new({"foo" => "bar"}) params.get?("foo", String).should eq "bar" end # get (typed retrieval, raises KeyError) def test_get_with_correct_type : Nil params = ART::Parameters.new params["count"] = 42 params.get("count", Int32).should eq 42 end def test_get_with_missing_key : Nil params = ART::Parameters.new expect_raises(KeyError, "No parameter exists with the name 'missing'.") do params.get("missing", Int32) end end def test_get_with_wrong_type : Nil params = ART::Parameters.new({"foo" => "bar"}) expect_raises(TypeCastError) do params.get("foo", Int32) end end # keys def test_keys : Nil params = ART::Parameters.new({"foo" => "bar", "baz" => "qux"}) params.keys.should eq ["foo", "baz"] end def test_keys_empty : Nil params = ART::Parameters.new params.keys.should be_empty end # empty? def test_empty_true : Nil params = ART::Parameters.new params.empty?.should be_true end def test_empty_false : Nil params = ART::Parameters.new({"foo" => "bar"}) params.empty?.should be_false end # size def test_size_empty : Nil params = ART::Parameters.new params.size.should eq 0 end def test_size_with_values : Nil params = ART::Parameters.new({"a" => "1", "b" => "2", "c" => "3"}) params.size.should eq 3 end # []= def test_set_string_value : Nil params = ART::Parameters.new params["foo"] = "bar" params["foo"].should eq "bar" end def test_set_int_value : Nil params = ART::Parameters.new params["count"] = 42 params.get("count", Int32).should eq 42 end def test_set_overwrites_existing : Nil params = ART::Parameters.new({"foo" => "bar"}) params["foo"] = "updated" params["foo"].should eq "updated" end def test_set_nil_value : Nil params = ART::Parameters.new params["nullable"] = nil params.raw?("nullable").should be_nil params.has_key?("nullable").should be_true end # delete def test_delete_existing_key : Nil params = ART::Parameters.new({"foo" => "bar", "baz" => "qux"}) params.delete("foo") params.has_key?("foo").should be_false params.size.should eq 1 end def test_delete_missing_key : Nil params = ART::Parameters.new({"foo" => "bar"}) params.delete("missing") params.size.should eq 1 end # merge! def test_merge : Nil params = ART::Parameters.new({"foo" => "bar"}) other = ART::Parameters.new({"baz" => "qux"}) result = params.merge!(other) result.should eq params params["foo"].should eq "bar" params["baz"].should eq "qux" params.size.should eq 2 end def test_merge_overwrites : Nil params = ART::Parameters.new({"foo" => "original"}) other = ART::Parameters.new({"foo" => "updated"}) params.merge!(other) params["foo"].should eq "updated" end def test_merge_nil : Nil params = ART::Parameters.new({"foo" => "bar"}) result = params.merge!(nil) result.should eq params params.size.should eq 1 end # each def test_each : Nil params = ART::Parameters.new({"foo" => "bar", "baz" => "qux"}) collected = {} of String => String params.each do |key, value| collected[key] = value.as(String) end collected.should eq({"foo" => "bar", "baz" => "qux"}) end def test_each_with_different_types : Nil params = ART::Parameters.new params["name"] = "test" params["count"] = 42 keys = [] of String params.each do |key, _| keys << key end keys.should eq ["name", "count"] end # dup def test_dup : Nil params = ART::Parameters.new({"foo" => "bar"}) copy = params.dup copy["foo"].should eq "bar" copy["new"] = "value" params.has_key?("new").should be_false end # clone def test_clone : Nil params = ART::Parameters.new({"foo" => "bar"}) copy = params.clone copy["foo"].should eq "bar" copy["new"] = "value" params.has_key?("new").should be_false end # to_h def test_to_h_with_strings : Nil params = ART::Parameters.new({"foo" => "bar", "baz" => "qux"}) params.to_h.should eq({"foo" => "bar", "baz" => "qux"}) end def test_to_h_with_non_string_values : Nil params = ART::Parameters.new params["count"] = 42 params["enabled"] = true hash = params.to_h hash["count"].should eq "42" hash["enabled"].should eq "true" end def test_to_h_with_nil_value : Nil params = ART::Parameters.new params["nullable"] = nil hash = params.to_h hash["nullable"].should be_nil end def test_to_h_empty : Nil params = ART::Parameters.new params.to_h.should be_empty end # == (Parameters) def test_equality_same_values : Nil params1 = ART::Parameters.new({"foo" => "bar"}) params2 = ART::Parameters.new({"foo" => "bar"}) (params1 == params2).should be_true end def test_equality_different_values : Nil params1 = ART::Parameters.new({"foo" => "bar"}) params2 = ART::Parameters.new({"foo" => "different"}) (params1 == params2).should be_false end def test_equality_different_keys : Nil params1 = ART::Parameters.new({"foo" => "bar"}) params2 = ART::Parameters.new({"baz" => "bar"}) (params1 == params2).should be_false end def test_equality_different_sizes : Nil params1 = ART::Parameters.new({"foo" => "bar"}) params2 = ART::Parameters.new({"foo" => "bar", "extra" => "value"}) (params1 == params2).should be_false end def test_equality_empty : Nil params1 = ART::Parameters.new params2 = ART::Parameters.new (params1 == params2).should be_true end # == (Hash) def test_equality_with_hash_same : Nil params = ART::Parameters.new({"foo" => "bar"}) (params == {"foo" => "bar"}).should be_true end def test_equality_with_hash_different : Nil params = ART::Parameters.new({"foo" => "bar"}) (params == {"foo" => "different"}).should be_false end def test_equality_with_hash_converts_types : Nil params = ART::Parameters.new params["count"] = 42 (params == {"count" => "42"}).should be_true end end ================================================ FILE: src/components/routing/spec/request_context_spec.cr ================================================ require "./spec_helper" struct RequestContextTest < ASPEC::TestCase def test_constructor : Nil request_context = ART::RequestContext.new( "foo", "post", "foo.bar", "HTTPS", 8080, 444, "/baz", "bar=foobar", ) request_context.base_url.should eq "foo" request_context.method.should eq "POST" request_context.host.should eq "foo.bar" request_context.scheme.should eq "https" request_context.http_port.should eq 8080 request_context.https_port.should eq 444 request_context.path.should eq "/baz" request_context.query_string.should eq "bar=foobar" end def test_getters_setters : Nil request_context = ART::RequestContext.new request_context.base_url = "foo" request_context.method = "POST" request_context.host = "foo.bar" request_context.scheme = "https" request_context.http_port = 8080 request_context.https_port = 444 request_context.path = "/baz" request_context.query_string = "bar=foobar" request_context.base_url.should eq "foo" request_context.method.should eq "POST" request_context.host.should eq "foo.bar" request_context.scheme.should eq "https" request_context.http_port.should eq 8080 request_context.https_port.should eq 444 request_context.path.should eq "/baz" request_context.query_string.should eq "bar=foobar" end def test_from_uri_with_base_url : Nil request_context = ART::RequestContext.from_uri "https://test.com:444/index.html" request_context.method.should eq "GET" request_context.host.should eq "test.com" request_context.scheme.should eq "https" request_context.http_port.should eq 80 request_context.https_port.should eq 444 request_context.base_url.should eq "/index.html" request_context.path.should eq "/" end def test_from_uri_trailing_slash : Nil request_context = ART::RequestContext.from_uri "http://test.com:8080/" request_context.scheme.should eq "http" request_context.host.should eq "test.com" request_context.http_port.should eq 8080 request_context.https_port.should eq 443 request_context.base_url.should eq "/" request_context.path.should eq "/" end def test_from_uri_without_trailing_slash : Nil request_context = ART::RequestContext.from_uri "https://test.com" request_context.scheme.should eq "https" request_context.host.should eq "test.com" request_context.base_url.should be_empty request_context.path.should eq "/" end def test_from_uri_empty : Nil request_context = ART::RequestContext.from_uri "" request_context.scheme.should eq "http" request_context.host.should eq "localhost" request_context.base_url.should be_empty request_context.path.should eq "/" end @[TestWith( {"http://foo.com\\bar"}, {"\\\\foo.com/bar"}, {"a\rb"}, {"a\nb"}, {"a\tb"}, {"\0foo"}, {"foo\0"}, {" foo"}, {"foo "}, # {":"}, )] def test_from_uri_invalid(uri : String) : Nil request_context = ART::RequestContext.from_uri uri request_context.scheme.should eq "http" request_context.host.should eq "localhost" request_context.base_url.should be_empty request_context.path.should eq "/" end def test_from_request : Nil request = ART::Request.new "GET", "/foo?bar=baz", headers: ::HTTP::Headers{"host" => "test.com:444"} request_context = ART::RequestContext.new request_context.apply request request_context.base_url.should be_empty request_context.method.should eq "GET" request_context.host.should eq "test.com" request_context.path.should eq "/foo" request_context.query_string.should eq "bar=baz" # Don't really have a way to determine these via `::HTTP::Request` at the moment :/ request_context.scheme.should eq "http" request_context.http_port.should eq 80 request_context.https_port.should eq 443 end def test_parameters : Nil request_context = ART::RequestContext.new request_context.parameters.should be_empty request_context.parameters = {"foo" => "bar"} of String => String? request_context.parameters.should eq({"foo" => "bar"}) request_context.parameter("foo").should eq "bar" end def test_has_parameter : Nil request_context = ART::RequestContext.new request_context.has_parameter?("foo").should be_false request_context.set_parameter "foo", "bar" request_context.has_parameter?("foo").should be_true end end ================================================ FILE: src/components/routing/spec/requirement/enum_spec.cr ================================================ require "../spec_helper" enum EnumRequirementEnum A B C end @[Flags] enum EnumRequirementEnumFlags A B C end struct EnumRequirementTest < ASPEC::TestCase def test_to_s_no_members : Nil ART::Requirement::Enum(EnumRequirementEnum).new.to_s.should eq "a|b|c" ART::Requirement::Enum(EnumRequirementEnumFlags).new.to_s.should eq "a|b|c" end def test_to_s_with_members : Nil ART::Requirement::Enum(EnumRequirementEnum).new(:a, :c).to_s.should eq "a|c" ART::Requirement::Enum(EnumRequirementEnumFlags).new(:b, :c).to_s.should eq "b|c" end @[Tags("compiled")] def test_constructor_non_enum_type : Nil self.assert_compile_time_error "'Int32' is not an Enum type.", <<-CR require "../spec_helper" ART::Requirement::Enum(Int32).new CR end end ================================================ FILE: src/components/routing/spec/requirement/requirement_spec.cr ================================================ require "../spec_helper" struct RequirementsTest < ASPEC::TestCase @[TestWith( {"FOO"}, {"foo"}, {"1987"}, {"42-42"}, {"for2o-bar"}, {"foo-bA198r-Ccc"}, {"for10O-bar-CCc-fooba187rccc"}, )] def test_ascii_slug_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::ASCII_SLUG}).compile.regex end @[TestWith( {""}, {"-"}, {"fôo"}, {"-FOO"}, {"foo-"}, {"-foo-"}, {"-foo-bar-"}, {"foo--bar"}, )] def test_ascii_slug_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::ASCII_SLUG}).compile.regex end @[TestWith( {"foo"}, {"foo/bar/ccc"}, {"///"}, )] def test_catch_all_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::CATCH_ALL}).compile.regex end @[TestWith( {""}, )] def test_catch_all_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::CATCH_ALL}).compile.regex end @[TestWith( {"0000-01-01"}, {"9999-12-31"}, {"2022-04-15"}, {"2024-02-29"}, {"1243-04-31"}, )] def test_date_ymd_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::DATE_YMD}).compile.regex end @[TestWith( {""}, {"foo"}, {"0000-01-00"}, {"9999-00-31"}, {"2022-02-30"}, {"2022-02-31"}, )] def test_date_ymd_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::DATE_YMD}).compile.regex end @[TestWith( {"0"}, {"012"}, {"1"}, {"42"}, {"42198"}, {"999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999"}, )] def test_digits_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::DIGITS}).compile.regex end @[TestWith( {""}, {"foo"}, {"-1"}, {"3.14"}, )] def test_digits_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::DIGITS}).compile.regex end @[TestWith( {"1"}, {"42"}, {"42198"}, {"999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999"}, )] def test_positive_int_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::POSITIVE_INT}).compile.regex end @[TestWith( {""}, {"0"}, {"045"}, {"foo"}, {"-1"}, {"3.14"}, )] def test_positive_int_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::POSITIVE_INT}).compile.regex end @[TestWith( {"00000000000000000000000000"}, {"ZZZZZZZZZZZZZZZZZZZZZZZZZZ"}, {"01G0P4XH09KW3RCF7G4Q57ESN0"}, {"05CSACM1MS9RB9H5F61BYA146Q"}, )] def test_uid_base_32_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UID_BASE32}).compile.regex end @[TestWith( {""}, {"foo"}, {"01G0P4XH09KW3RCF7G4Q57ESN"}, {"01G0P4XH09KW3RCF7G4Q57ESNU"}, )] def test_uid_base_32_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UID_BASE32}).compile.regex end @[TestWith( {"1111111111111111111111"}, {"zzzzzzzzzzzzzzzzzzzzzz"}, {"1BkPBX6T19U8TUAjBTtgwH"}, {"1fg491dt8eQpf2TU42o2bY"}, )] def test_uid_base_58_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UID_BASE58}).compile.regex end @[TestWith( {""}, {"foo"}, {"1BkPBX6T19U8TUAjBTtgw"}, {"1BkPBX6T19U8TUAjBTtgwI"}, )] def test_uid_base_58_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UID_BASE58}).compile.regex end @[TestWith( {"00000000-0000-0000-0000-000000000000"}, {"ffffffff-ffff-ffff-ffff-ffffffffffff"}, {"01802c4e-c409-9f07-863c-f025ca7766a0"}, {"056654ca-0699-4e16-9895-e60afca090d7"}, )] def test_uid_rfc4122_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UID_RFC4122}).compile.regex end @[TestWith( {""}, {"foo"}, {"01802c4e-c409-9f07-863c-f025ca7766a"}, {"01802c4e-c409-9f07-863c-f025ca7766ag"}, {"01802c4ec4099f07863cf025ca7766a0"}, )] def test_uid_rfc4122_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UID_RFC4122}).compile.regex end @[TestWith( {"00000000000000000000000000"}, {"7ZZZZZZZZZZZZZZZZZZZZZZZZZ"}, {"01G0P4ZPM69QTD4MM4ENAEA4EW"}, )] def test_ulid_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::ULID}).compile.regex end @[TestWith( {""}, {"foo"}, {"8ZZZZZZZZZZZZZZZZZZZZZZZZZ"}, {"01G0P4ZPM69QTD4MM4ENAEA4E"}, )] def test_ulid_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::ULID}).compile.regex end @[TestWith( {"00000000-0000-1000-8000-000000000000"}, {"ffffffff-ffff-8fff-bfff-ffffffffffff"}, {"8c670a1c-bc95-11ec-8422-0242ac120002"}, {"21902510-bc96-21ec-8422-0242ac120002"}, {"61c86569-e477-3ed9-9e3b-1562edb03277"}, {"e55a29be-ba25-46e0-a5e5-85b78a6f9a11"}, {"bad98960-f1a1-530e-9a82-07d0b6c4e62f"}, {"1ecbc9a8-432d-6b14-af93-715adc3b830c"}, {"216fff40-98d9-71e3-a5e2-0800200c9a66"}, {"216fff40-98d9-81e3-a5e2-0800200c9a66"}, )] def test_uuid_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID}).compile.regex end @[TestWith( {""}, {"foo"}, {"01802c74-d78c-b085-0cdf-7cbad87c70a3"}, {"e55a29be-bb25-46e0-a5e5-85b78a6f9a1"}, {"e55a29bh-bb25-46e0-a5e5-85b78a6f9a11"}, {"e55a29beba2546e0a5e585b78a6f9a11"}, )] def test_uuid_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID}).compile.regex end @[TestWith( {"00000000-0000-1000-8000-000000000000"}, {"ffffffff-ffff-1fff-bfff-ffffffffffff"}, {"21902510-bc96-11ec-8422-0242ac120002"}, {"a8ff8f60-088e-1099-a09d-53afc49918d1"}, {"b0ac612c-9117-17a1-901f-53afc49918d1"}, )] def test_uuid_v1_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V1}).compile.regex end @[TestWith( {""}, {"foo"}, {"a3674b89-0170-3e30-8689-52939013e39c"}, {"e0040090-3cb0-4bf9-a868-407770c964f9"}, {"2e2b41d9-e08c-53d2-b435-818b9c323942"}, {"2a37b67a-5eaa-6424-b5d6-ffc9ba0f2a13"}, )] def test_uuid_v1_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V1}).compile.regex end @[TestWith( {"00000000-0000-3000-8000-000000000000"}, {"ffffffff-ffff-3fff-bfff-ffffffffffff"}, {"2b3f1427-33b2-30a9-8759-07355007c204"}, {"c38e7b09-07f7-3901-843d-970b0186b873"}, )] def test_uuid_v3_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V3}).compile.regex end @[TestWith( {""}, {"foo"}, {"e24d9c0e-bc98-11ec-9924-53afc49918d1"}, {"1c240248-7d0b-41a4-9d20-61ad2915a58c"}, {"4816b668-385b-5a65-808d-bca410f45090"}, {"1d2f3104-dff6-64c6-92ff-0f74b1d0e2af"}, )] def test_uuid_v3_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V3}).compile.regex end @[TestWith( {"00000000-0000-4000-8000-000000000000"}, {"ffffffff-ffff-4fff-bfff-ffffffffffff"}, {"b8f15bf4-46e2-4757-bbce-11ae83f7a6ea"}, {"eaf51230-1ce2-40f1-ab18-649212b26198"}, )] def test_uuid_v4_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V4}).compile.regex end @[TestWith( {""}, {"foo"}, {"15baaab2-f310-11d2-9ecf-53afc49918d1"}, {"acd44dc8-d2cc-326c-9e3a-80a3305a25e8"}, {"7fc2705f-a8a4-5b31-99a8-890686d64189"}, {"1ecbc991-3552-6920-998e-efad54178a98"}, )] def test_uuid_v4_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V4}).compile.regex end @[TestWith( {"00000000-0000-5000-8000-000000000000"}, {"ffffffff-ffff-5fff-bfff-ffffffffffff"}, {"49f4d32c-28b3-5802-8717-a2896180efbd"}, {"58b3c62e-a7df-5a82-93a6-fbe5fda681c1"}, )] def test_uuid_v5_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V5}).compile.regex end @[TestWith( {""}, {"foo"}, {"b99ad578-fdd3-1135-9d3b-53afc49918d1"}, {"b3ee3071-7a2b-3e17-afdf-6b6aec3acf85"}, {"2ab4f5a7-6412-46c1-b3ab-1fe1ed391e27"}, {"135fdd3d-e193-653e-865d-67e88cf12e44"}, )] def test_uuid_v5_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V5}).compile.regex end @[TestWith( {"00000000-0000-6000-8000-000000000000"}, {"ffffffff-ffff-6fff-bfff-ffffffffffff"}, {"2c51caad-c72f-66b2-b6d7-8766d36c73df"}, {"17941ebb-48fa-6bfe-9bbd-43929f8784f5"}, {"1ecbc993-f6c2-67f2-8fbe-295ed594b344"}, )] def test_uuid_v6_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V6}).compile.regex end @[TestWith( {""}, {"foo"}, {"821040f4-7b67-12a3-9770-53afc49918d1"}, {"802dc245-aaaa-3649-98c6-31c549b0df86"}, {"92d2e5ad-bc4e-4947-a8d9-77706172ca83"}, {"6e124559-d260-511e-afdc-e57c7025fed0"}, )] def test_uuid_v6_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V6}).compile.regex end @[TestWith( {"00000000-0000-7000-8000-000000000000"}, {"ffffffff-ffff-7fff-bfff-ffffffffffff"}, {"216fff40-98d9-71e3-a5e2-0800200c9a66"}, )] def test_uuid_v7_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V7}).compile.regex end @[TestWith( {""}, {"foo"}, {"821040f4-7b67-12a3-9770-53afc49918d1"}, {"802dc245-aaaa-3649-98c6-31c549b0df86"}, {"92d2e5ad-bc4e-4947-a8d9-77706172ca83"}, {"6e124559-d260-511e-afdc-e57c7025fed0"}, {"17941ebb-48fa-6bfe-9bbd-43929f8784f5"}, {"216fff40-98d9-81e3-a5e2-0800200c9a66"}, )] def test_uuid_v7_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V7}).compile.regex end @[TestWith( {"00000000-0000-8000-8000-000000000000"}, {"ffffffff-ffff-8fff-bfff-ffffffffffff"}, {"216fff40-98d9-81e3-a5e2-0800200c9a66"}, )] def test_uuid_v8_valid(path : String) : Nil "/#{path}".should match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V8}).compile.regex end @[TestWith( {""}, {"foo"}, {"821040f4-7b67-12a3-9770-53afc49918d1"}, {"802dc245-aaaa-3649-98c6-31c549b0df86"}, {"92d2e5ad-bc4e-4947-a8d9-77706172ca83"}, {"6e124559-d260-511e-afdc-e57c7025fed0"}, {"17941ebb-48fa-6bfe-9bbd-43929f8784f5"}, {"216fff40-98d9-71e3-a5e2-0800200c9a66"}, )] def test_uuid_v8_invalid(path : String) : Nil "/#{path}".should_not match ART::Route.new("/{path}", requirements: {"path" => ART::Requirement::UUID_V8}).compile.regex end end ================================================ FILE: src/components/routing/spec/route_collection_spec.cr ================================================ require "./spec_helper" struct RouteCollectionTest < ASPEC::TestCase def test_route_interactions : Nil collection = ART::RouteCollection.new route = ART::Route.new "/foo" collection.add "foo", route collection.routes.should eq({"foo" => route}) collection["foo"].should be route collection["foo"]?.should be route collection["bar"]?.should be_nil expect_raises ART::Exception::RouteNotFound, "No route with the name 'bar' exists." do collection["bar"] end end def test_overridden_route : Nil collection = ART::RouteCollection.new route1 = ART::Route.new "/foo" route2 = ART::Route.new "/bar" collection.add "foo", route1 collection.add "foo", route2 collection["foo"].should be route2 end def test_each_iterator : Nil collection = ART::RouteCollection.new route1 = ART::Route.new "/foo" route2 = ART::Route.new "/bar" collection.add "foo", route1 collection.add "bar", route2 assert_iterates_iterator [{"foo", route1}, {"bar", route2}], collection.each end def test_deep_overridden_route : Nil collection = ART::RouteCollection.new collection.add "foo", ART::Route.new "/foo" collection1 = ART::RouteCollection.new collection1.add "foo", ART::Route.new "/foo1" collection2 = ART::RouteCollection.new collection2.add "foo", ART::Route.new "/foo2" collection1.add collection2 collection.add collection1 collection1["foo"].path.should eq "/foo2" collection["foo"].path.should eq "/foo2" end def test_size : Nil collection = ART::RouteCollection.new collection.add "foo", ART::Route.new "/foo" collection1 = ART::RouteCollection.new collection1.add "bar", ART::Route.new "/bar" collection.add collection1 collection.size.should eq 2 end def test_add_collection : Nil collection = ART::RouteCollection.new collection.add "foo", foo = ART::Route.new "/foo" collection1 = ART::RouteCollection.new collection1.add "bar", bar = ART::Route.new "/bar" collection2 = ART::RouteCollection.new collection2.add "baz", baz = ART::Route.new "/baz" collection1.add collection2 collection.add collection1 collection.routes.should eq({"foo" => foo, "bar" => bar, "baz" => baz}) end def test_add_defaults : Nil collection = ART::RouteCollection.new collection.add "foo", ART::Route.new "/{placeholder}" collection1 = ART::RouteCollection.new collection1.add "bar", ART::Route.new "/{placeholder}", {"placeholder" => "default", "foo" => "bar"}, {"placeholder" => /.+/} collection.add collection1 collection.add_defaults({"placeholder" => "new-default"}) collection["foo"].defaults.should eq({"placeholder" => "new-default"}) collection["bar"].defaults.should eq({"placeholder" => "new-default", "foo" => "bar"}) end def test_add_requirements : Nil collection = ART::RouteCollection.new collection.add "foo", ART::Route.new "/{placeholder}" collection1 = ART::RouteCollection.new collection1.add "bar", ART::Route.new "/{placeholder}", {"placeholder" => "default", "foo" => "bar"}, {"placeholder" => /.+/} collection.add collection1 collection.add_requirements({"placeholder" => /\d+/}) collection["foo"].requirements.should eq({"placeholder" => /\d+/}) collection["bar"].requirements.should eq({"placeholder" => /\d+/}) end def test_add_prefix : Nil collection = ART::RouteCollection.new collection.add "foo", ART::Route.new "/foo" collection1 = ART::RouteCollection.new collection1.add "bar", ART::Route.new "/bar" collection.add collection1 collection.add_prefix " / " collection["foo"].path.should eq "/foo" collection.add_prefix "/{admin}", {"admin" => "admin"}, {"admin" => /\d+/} collection["foo"].path.should eq "/{admin}/foo" collection["bar"].path.should eq "/{admin}/bar" collection["foo"].defaults.should eq({"admin" => "admin"}) collection["bar"].defaults.should eq({"admin" => "admin"}) collection["foo"].requirements.should eq({"admin" => /\d+/}) collection["bar"].requirements.should eq({"admin" => /\d+/}) collection.add_prefix "0" collection["foo"].path.should eq "/0/{admin}/foo" collection.add_prefix "/ /" collection["foo"].path.should eq "/ /0/{admin}/foo" collection["bar"].path.should eq "/ /0/{admin}/bar" end def test_add_prefix_overrides_requirements : Nil collection = ART::RouteCollection.new collection.add "foo", ART::Route.new "/foo.{_format}" collection.add "bar", ART::Route.new "/bar.{_format}", requirements: {"_format" => "json"} collection.add_prefix "/admin", requirements: {"_format" => "html"} collection["foo"].requirement("_format").should eq /html/ collection["bar"].requirement("_format").should eq /html/ end def test_unique_route_with_given_name : Nil collection1 = ART::RouteCollection.new collection1.add "foo", ART::Route.new "/old" collection2 = ART::RouteCollection.new collection3 = ART::RouteCollection.new collection3.add "foo", route = ART::Route.new "/new" collection2.add collection3 collection1.add collection2 collection1["foo"].should be route collection1.size.should eq 1 end def test_remove : Nil collection1 = ART::RouteCollection.new collection1.add "foo", ART::Route.new "/foo" collection2 = ART::RouteCollection.new collection2.add "bar", bar = ART::Route.new "/bar" collection1.add collection2 collection1.add "last", last = ART::Route.new "/last" collection1.remove "foo" collection1.routes.should eq({"bar" => bar, "last" => last}) collection1.remove "bar", "last" collection1.routes.should be_empty end def test_set_host : Nil collection = ART::RouteCollection.new collection.add "a", a = ART::Route.new "/a" collection.add "b", b = ART::Route.new "/b", host: "{locale}.example.net" collection.set_host "{locale}.example.com" a.host.should eq "{locale}.example.com" b.host.should eq "{locale}.example.com" end def test_clone : Nil collection = ART::RouteCollection.new collection.add "a", ART::Route.new "/a" collection.add "b", ART::Route.new "/b", {"placeholder" => "default"}, {"placeholder" => /.+/} cloned_collection = collection.clone cloned_collection.size.should eq 2 cloned_collection["a"].should eq collection["a"] cloned_collection["a"].should_not be collection["a"] cloned_collection["b"].should eq collection["b"] cloned_collection["b"].should_not be collection["b"] end def test_set_scheme : Nil collection = ART::RouteCollection.new collection.add "a", a = ART::Route.new "/a", schemes: "http" collection.add "b", b = ART::Route.new "/b" collection.schemes = {"http", "https"} a.schemes.should eq Set{"http", "https"} b.schemes.should eq Set{"http", "https"} end def test_set_methods : Nil collection = ART::RouteCollection.new collection.add "a", a = ART::Route.new "/a", methods: {"get", "POST"} collection.add "b", b = ART::Route.new "/b" collection.methods = "put" a.methods.should eq Set{"PUT"} b.methods.should eq Set{"PUT"} end def test_add_name_prefix : Nil collection = ART::RouteCollection.new collection.add "foo", foo = ART::Route.new "/foo" collection.add "bar", bar = ART::Route.new "/bar" collection.add "api_foo", api_foo = ART::Route.new "/api/foo" collection.add_name_prefix "api_" collection["api_foo"].should be foo collection["api_bar"].should be bar collection["api_api_foo"].should be api_foo collection["foo"]?.should be_nil collection["bar"]?.should be_nil end def test_add_name_prefix_canonical_route_name : Nil collection = ART::RouteCollection.new collection.add "foo", ART::Route.new "/foo", {"_canonical_route" => "foo"} collection.add "bar", ART::Route.new "/bar", {"_canonical_route" => "bar"} collection.add "api_foo", ART::Route.new "/api/foo", {"_canonical_route" => "api_foo"} collection.add_name_prefix "api_" collection["api_foo"].default("_canonical_route").should eq "api_foo" collection["api_bar"].default("_canonical_route").should eq "api_bar" collection["api_api_foo"].default("_canonical_route").should eq "api_api_foo" end def test_add_with_priority : Nil collection = ART::RouteCollection.new collection.add "foo", foo = ART::Route.new("/foo"), 0 collection.add "bar", bar = ART::Route.new("/bar"), 1 collection.add "baz", baz = ART::Route.new "/baz" collection.routes.should eq({ "bar" => bar, "foo" => foo, "baz" => baz, }) collection2 = ART::RouteCollection.new collection2.add "foo2", foo2 = ART::Route.new("/foo"), 0 collection2.add "bar2", bar2 = ART::Route.new("/bar"), 1 collection2.add "baz2", baz2 = ART::Route.new "/baz" collection2.add collection collection2.routes.should eq({ "bar2" => bar2, "bar" => bar, "foo2" => foo2, "baz2" => baz2, "baz" => baz, "foo" => foo, }) end def test_add_with_priority_and_prefix : Nil collection = ART::RouteCollection.new collection.add "foo", foo = ART::Route.new("/foo"), 0 collection.add "bar", bar = ART::Route.new("/bar"), 1 collection.add "baz", baz = ART::Route.new "/baz" collection.add_name_prefix "prefix_" collection.routes.should eq({ "prefix_bar" => bar, "prefix_foo" => foo, "prefix_baz" => baz, }) end end ================================================ FILE: src/components/routing/spec/route_compiler_spec.cr ================================================ require "./spec_helper" struct RouteCompilerTest < ASPEC::TestCase @[DataProvider("compiler_provider")] def test_compile(route : ART::Route, prefix : String, regex : Regex, variables : Set(String), tokens : Array(ART::CompiledRoute::Token)) : Nil compiled_route = route.compile compiled_route.static_prefix.should eq prefix compiled_route.regex.should eq regex compiled_route.variables.should eq variables compiled_route.tokens.should eq tokens end def compiler_provider : Hash { "static" => { ART::Route.new("/foo"), "/foo", /^\/foo$/, Set(String).new, [ ART::CompiledRoute::Token.new(:text, "/foo"), ], }, "single variable" => { ART::Route.new("/foo/{bar}"), "/foo", /^\/foo\/(?P[^\/]++)$/, Set{"bar"}, [ ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "bar"), ART::CompiledRoute::Token.new(:text, "/foo"), ], }, "variable with default value" => { ART::Route.new("/foo/{bar}", {"bar" => "bar"}), "/foo", /^\/foo(?:\/(?P[^\/]++))?$/, Set{"bar"}, [ ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "bar"), ART::CompiledRoute::Token.new(:text, "/foo"), ], }, "several variable" => { ART::Route.new("/foo/{bar}/{foobar}"), "/foo", /^\/foo\/(?P[^\/]++)\/(?P[^\/]++)$/, Set{"bar", "foobar"}, [ ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "foobar"), ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "bar"), ART::CompiledRoute::Token.new(:text, "/foo"), ], }, "several variables with defaults" => { ART::Route.new("/foo/{bar}/{foobar}", {"bar" => "bar", "foobar" => ""}), "/foo", /^\/foo(?:\/(?P[^\/]++)(?:\/(?P[^\/]++))?)?$/, Set{"bar", "foobar"}, [ ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "foobar"), ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "bar"), ART::CompiledRoute::Token.new(:text, "/foo"), ], }, "several variables with some having defaults" => { ART::Route.new("/foo/{bar}/{foobar}", {"bar" => "bar"}), "/foo", /^\/foo\/(?P[^\/]++)\/(?P[^\/]++)$/, Set{"bar", "foobar"}, [ ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "foobar"), ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "bar"), ART::CompiledRoute::Token.new(:text, "/foo"), ], }, "optional variable as the first segment with default" => { ART::Route.new("/{bar}", {"bar" => "bar"}), "", /^\/(?P[^\/]++)?$/, Set{"bar"}, [ ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "bar"), ], }, "optional variable as the first segment with requirement" => { ART::Route.new("/{bar}", {"bar" => "bar"}, {"bar" => /(foo|bar)/}), "", /^\/(?P(?:foo|bar))?$/, Set{"bar"}, [ ART::CompiledRoute::Token.new(:variable, "/", /(?:foo|bar)/, "bar"), ], }, "only optional variables with defaults" => { ART::Route.new("/{foo}/{bar}", {"foo" => "foo", "bar" => "bar"}), "", /^\/(?P[^\/]++)?(?:\/(?P[^\/]++))?$/, Set{"foo", "bar"}, [ ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "bar"), ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "foo"), ], }, "variable in last position" => { ART::Route.new("/foo-{bar}"), "/foo-", /^\/foo\-(?P[^\/]++)$/, Set{"bar"}, [ ART::CompiledRoute::Token.new(:variable, "-", /[^\/]++/, "bar"), ART::CompiledRoute::Token.new(:text, "/foo"), ], }, "nested placeholders" => { ART::Route.new("/{static{var}static}"), "/{static", /^\/\{static(?P[^\/]+)static\}$/, Set{"var"}, [ ART::CompiledRoute::Token.new(:text, "static}"), ART::CompiledRoute::Token.new(:variable, "", /[^\/]+/, "var"), ART::CompiledRoute::Token.new(:text, "/{static"), ], }, "separator between variables" => { ART::Route.new("/{w}{x}{y}{z}.{_format}", {"z" => "default-z", "_format" => "html"}, {"y" => /(y|Y)/}), "", /^\/(?P[^\/\.]+)(?P[^\/\.]+)(?P(?:y|Y))(?:(?P[^\/\.]++)(?:\.(?P<_format>[^\/]++))?)?$/, Set{"w", "x", "y", "z", "_format"}, [ ART::CompiledRoute::Token.new(:variable, ".", /[^\/]++/, "_format"), ART::CompiledRoute::Token.new(:variable, "", /[^\/\.]++/, "z"), ART::CompiledRoute::Token.new(:variable, "", /(?:y|Y)/, "y"), ART::CompiledRoute::Token.new(:variable, "", /[^\/\.]+/, "x"), ART::CompiledRoute::Token.new(:variable, "/", /[^\/\.]+/, "w"), ], }, "with format" => { ART::Route.new("/foo/{bar}.{_format}"), "/foo", /^\/foo\/(?P[^\/\.]++)\.(?P<_format>[^\/]++)$/, Set{"bar", "_format"}, [ ART::CompiledRoute::Token.new(:variable, ".", /[^\/]++/, "_format"), ART::CompiledRoute::Token.new(:variable, "/", /[^\/\.]++/, "bar"), ART::CompiledRoute::Token.new(:text, "/foo"), ], }, } end def test_route_with_same_variable_twice : Nil expect_raises ART::Exception::InvalidArgument, "Route pattern '/{foo}/{foo}' cannot reference variable name 'foo' more than once." do ART::Route.new("/{foo}/{foo}").compile end end def test_route_with_fragment_as_path_parameter : Nil expect_raises ART::Exception::InvalidArgument, "Route pattern '/{_fragment}' cannot contain '_fragment' as a path parameter." do ART::Route.new("/{_fragment}").compile end end def test_route_with_too_long_parameter_name : Nil expect_raises ART::Exception::InvalidArgument, "Variable name 'abcdefghijklmnopqrstuvqxyz0123456789' cannot be longer than 32 characters in route pattern '/{abcdefghijklmnopqrstuvqxyz0123456789}'." do ART::Route.new("/{abcdefghijklmnopqrstuvqxyz0123456789}").compile end end @[DataProvider("names_starting_with_digit_provider")] def test_route_with_variable_name_starting_with_digit(name : String) : Nil expect_raises ART::Exception::InvalidArgument, "Variable name '#{name}' cannot start with a digit in route pattern '/{#{name}}'." do ART::Route.new("/{#{name}}").compile end end def names_starting_with_digit_provider : Tuple { {"09"}, {"123"}, {"1e2"}, } end @[DataProvider("capture_group_provider")] def test_remove_capture_groups(expected : Regex, actual : Regex) : Nil ART::Route.new("/{foo}", requirements: {"foo" => actual}).compile.regex.should eq expected end def capture_group_provider : Tuple { { /^\/(?Pa(?:b|c)(?:d|e)f)$/, /a(b|c)(d|e)f/, }, { /^\/(?Pa\(b\)c)$/, /a\(b\)c/, }, { /^\/(?P(?:b))$/, /(?:b)/, }, { /^\/(?P(*F))$/, /(*F)/, }, { /^\/(?P(?:(?:foo)))$/, /((foo))/, }, } end @[DataProvider("compiler_host_data_provider")] def test_compile_host_data( route : ART::Route, prefix : String, regex : Regex, variables : Set(String), path_variables : Set(String), tokens : Array(ART::CompiledRoute::Token), host_regex : Regex, host_variables : Set(String), host_tokens : Array(ART::CompiledRoute::Token), ) : Nil compiled_route = route.compile compiled_route.static_prefix.should eq prefix compiled_route.regex.should eq regex compiled_route.variables.should eq variables compiled_route.path_variables.should eq path_variables compiled_route.tokens.should eq tokens compiled_route.host_regex.should eq host_regex compiled_route.host_variables.should eq host_variables compiled_route.host_tokens.should eq host_tokens end def compiler_host_data_provider : Hash { "static value" => { ART::Route.new("/hello", host: "www.example.com"), "/hello", /^\/hello$/, Set(String).new, Set(String).new, [ ART::CompiledRoute::Token.new(:text, "/hello"), ], /^www\.example\.com$/i, Set(String).new, [ ART::CompiledRoute::Token.new(:text, "www.example.com"), ], }, "with variable" => { ART::Route.new("/hello/{name}", host: "www.example.{tld}"), "/hello", /^\/hello\/(?P[^\/]++)$/, Set{"tld", "name"}, Set{"name"}, [ ART::CompiledRoute::Token.new(:variable, "/", /[^\/]++/, "name"), ART::CompiledRoute::Token.new(:text, "/hello"), ], /^www\.example\.(?P[^\.]++)$/i, Set{"tld"}, [ ART::CompiledRoute::Token.new(:variable, ".", /[^\.]++/, "tld"), ART::CompiledRoute::Token.new(:text, "www.example"), ], }, "variable at beginning and end" => { ART::Route.new("/hello", host: "{locale}.example.{tld}"), "/hello", /^\/hello$/, Set{"locale", "tld"}, Set(String).new, [ ART::CompiledRoute::Token.new(:text, "/hello"), ], /^(?P[^\.]++)\.example\.(?P[^\.]++)$/i, Set{"locale", "tld"}, [ ART::CompiledRoute::Token.new(:variable, ".", /[^\.]++/, "tld"), ART::CompiledRoute::Token.new(:text, ".example"), ART::CompiledRoute::Token.new(:variable, "", /[^\.]++/, "locale"), ], }, "variable with a default value" => { ART::Route.new("/hello", {"locale" => "a", "tld" => "b"}, host: "{locale}.example.{tld}"), "/hello", /^\/hello$/, Set{"locale", "tld"}, Set(String).new, [ ART::CompiledRoute::Token.new(:text, "/hello"), ], /^(?P[^\.]++)\.example\.(?P[^\.]++)$/i, Set{"locale", "tld"}, [ ART::CompiledRoute::Token.new(:variable, ".", /[^\.]++/, "tld"), ART::CompiledRoute::Token.new(:text, ".example"), ART::CompiledRoute::Token.new(:variable, "", /[^\.]++/, "locale"), ], }, } end end ================================================ FILE: src/components/routing/spec/route_provider_spec.cr ================================================ require "./spec_helper" require "digest/md5" struct RouteProviderTest < ASPEC::TestCase private COLLECTIONS = [ ART::RouteCollection.new, self.default_collection, self.redirection_collection, self.root_prefix_collection, self.head_match_case_collection, self.group_optimized_collection, self.trailing_slash_collection, self.trailing_slash_collection, self.host_tree_collection, self.chunked_collection, self.demo_collection, self.suffix_collection, self.host_collection, ] {% begin %} {% for test_case in 0..12 %} def test_compile_{{test_case}} : Nil \{% begin %} ART::RouteProvider.compile COLLECTIONS[{{test_case}}] \{% data = read_file("#{__DIR__}/fixtures/route_provider/route_collection{{test_case}}.cr").split("####") %} ART::RouteProvider.match_host?.should eq (\{{data[0].id}}) ART::RouteProvider.static_routes.should eq (\{{data[1].id}}) ART::RouteProvider.route_regexes.should eq (\{{data[2].id}}) ART::RouteProvider.dynamic_routes.should eq (\{{data[3].id}}) ART::RouteProvider.conditions.size.should eq (\{{data[4].id}}) \{% end %} end {% end %} {% end %} def test_inspect : Nil routes = ART::RouteCollection.new routes.add "static", ART::Route.new "/static" routes.add "dynamic", ART::Route.new "/user/{id}" ART::RouteProvider.compile routes data = ART::RouteProvider.inspect data.should contain "Match Host: false" data.should contain "Static Routes: {\"/static\" =>" data.should contain "Regexes: {0 =>" data.should contain "Dynamic Routes: {\"21\" =>" data.should contain "Route Generation Data: {\"static\" =>" end def self.default_collection : ART::RouteCollection collection = ART::RouteCollection.new collection.add "overridden", ART::Route.new "/overridden" # Defaults and requirements collection.add "foo", ART::Route.new "/foo/{bar}", {"def" => "test"}, {"bar" => /baz|athenaa/} # Method requirement collection.add "bar", ART::Route.new "/bar/{foo}", methods: {"GET", "head"} # GET also adds HEAD as valid collection.add "barhead", ART::Route.new "/barhead/{foo}", methods: {"GET"} # Simple collection.add "baz", ART::Route.new "/test/baz" # Simple with extension collection.add "baz2", ART::Route.new "/test/baz.html" # Trailing slash collection.add "baz3", ART::Route.new "/test/baz3/" # Trailing slash with variable collection.add "baz4", ART::Route.new "/test/{foo}/" # Trailing slash and method collection.add "baz5", ART::Route.new "/test/{foo}/", methods: "post" # Complex name collection.add "baz.baz6", ART::Route.new "/test/{foo}/", methods: "put" # Defaults without variable collection.add "foofoo", ART::Route.new "/foofoo", {"def" => "test"} # Pattern with quotes collection.add "quoter", ART::Route.new "/{quoter}", requirements: {"quoter" => /[']+/} # Space in pattern collection.add "space", ART::Route.new "/spa ce" # Prefixes collection1 = ART::RouteCollection.new collection1.add "overridden", ART::Route.new "/overridden1" collection1.add "foo1", ART::Route.new("/{foo}").methods=("PUT") collection1.add "bar1", ART::Route.new "/{bar}" collection1.add_prefix "/b\"b" collection2 = ART::RouteCollection.new collection2.add collection1 collection2.add "overridden", ART::Route.new "/{var}", requirements: {"var" => /.*/} collection1 = ART::RouteCollection.new collection1.add "foo2", ART::Route.new "/{foo1}" collection1.add "bar2", ART::Route.new "/{bar1}" collection1.add_prefix "/b\"b" collection2.add collection1 collection2.add_prefix "/a" collection.add collection2 # Overridden through add (collection) and multiple sub-collections with no own prefix collection1 = ART::RouteCollection.new collection1.add "overridden2", ART::Route.new "/old" collection1.add "helloWorld", ART::Route.new "/hello/{who}", {"who" => "World!"} collection2 = ART::RouteCollection.new collection3 = ART::RouteCollection.new collection3.add "overridden2", ART::Route.new "/new" collection3.add "hey", ART::Route.new "/hey/" collection2.add collection3 collection1.add collection2 collection1.add_prefix "/multi" collection.add collection1 # "dynamic" prefix" collection1 = ART::RouteCollection.new collection1.add "foo3", ART::Route.new "/{foo}" collection1.add "bar3", ART::Route.new "/{bar}" collection1.add_prefix "/b" collection1.add_prefix "{_locale}" collection.add collection1 # Route between collections collection.add "ababa", ART::Route.new "/ababa" # Collection with static prefix but only one route" collection1 = ART::RouteCollection.new collection1.add "foo4", ART::Route.new "/{foo}" collection1.add_prefix "/aba" collection.add collection1 # Prefix and host collection1 = ART::RouteCollection.new collection1.add "route1", ART::Route.new "/route1", host: "a.example.com" collection1.add "route2", ART::Route.new "/c2/route2", host: "a.example.com" collection1.add "route3", ART::Route.new "/c2/route3", host: "b.example.com" collection1.add "route4", ART::Route.new "/route4", host: "a.example.com" collection1.add "route5", ART::Route.new "/route5", host: "c.example.com" collection1.add "route6", ART::Route.new "/route6", host: nil collection.add collection1 # Host and variables collection1 = ART::RouteCollection.new collection1.add "route11", ART::Route.new "/route11", host: "{var1}.example.com" collection1.add "route12", ART::Route.new "/route12", {"var1" => "val"}, host: "{var1}.example.com" collection1.add "route13", ART::Route.new "/route13/{name}", host: "{var1}.example.com" collection1.add "route14", ART::Route.new "/route14/{name}", {"var1" => "val"}, host: "{var1}.example.com" collection1.add "route15", ART::Route.new "/route15/{name}", host: "c.example.com" collection1.add "route16", ART::Route.new "/route16/{name}", {"var1" => "val"}, host: nil collection1.add "route17", ART::Route.new "/route17", host: nil collection.add collection1 # Multiple sub-collections with a single route and prefix each collection1 = ART::RouteCollection.new collection1.add "a", ART::Route.new "/a..." collection2 = ART::RouteCollection.new collection2.add "b", ART::Route.new "/{var}" collection3 = ART::RouteCollection.new collection3.add "c", ART::Route.new "/{var}" collection3.add_prefix "/c" collection2.add collection3 collection2.add_prefix "/b" collection1.add collection2 collection1.add_prefix "/a" collection.add collection1 collection end def self.redirection_collection : ART::RouteCollection collection = self.default_collection.dup collection.add "secure", ART::Route.new "/secure", schemes: "https" collection.add "nonsecure", ART::Route.new "/nonsecure", schemes: "http" collection end def self.root_prefix_collection : ART::RouteCollection collection = ART::RouteCollection.new collection.add "static", ART::Route.new "/test" collection.add "dynamic", ART::Route.new "/{var}" collection.add_prefix "rootprefix" route = ART::Route.new "/with-condition" route.condition do |request| "GET" == request.method end collection.add "with-condition", route collection end def self.head_match_case_collection : ART::RouteCollection collection = ART::RouteCollection.new collection.add "just_head", ART::Route.new "/just_head", methods: "HEAD" collection.add "head_and_get", ART::Route.new "/head_and_get", methods: {"HEAD", "GET"} collection.add "get_and_head", ART::Route.new "/get_and_head", methods: {"GET", "HEAD"} collection.add "post_and_head", ART::Route.new "/post_and_head", methods: {"POST", "HEAD"} collection.add "put_and_post", ART::Route.new "/put_and_post", methods: {"PUT", "POST"} collection.add "put_and_get_and_head", ART::Route.new "/put_and_post", methods: {"PUT", "GET", "HEAD"} collection end def self.group_optimized_collection : ART::RouteCollection collection = ART::RouteCollection.new collection.add "a_first", ART::Route.new "/a/11" collection.add "a_second", ART::Route.new "/a/22" collection.add "a_third", ART::Route.new "/a/33" collection.add "a_wildcard", ART::Route.new "/{param}" collection.add "a_fourth", ART::Route.new "/a/44/" collection.add "a_fifth", ART::Route.new "/a/55/" collection.add "a_sixth", ART::Route.new "/a/66/" collection.add "nested_wildcard", ART::Route.new "/nested/{param}" collection.add "nested_a", ART::Route.new "/nested/group/a/" collection.add "nested_b", ART::Route.new "/nested/group/b/" collection.add "nested_c", ART::Route.new "/nested/group/c/" collection.add "slashed_a", ART::Route.new "/slashed/group/" collection.add "slashed_b", ART::Route.new "/slashed/group/b/" collection.add "slashed_c", ART::Route.new "/slashed/group/c/" collection end def self.trailing_slash_collection : ART::RouteCollection collection = ART::RouteCollection.new collection.add "simple_trailing_slash_no_methods", ART::Route.new "/trailing/simple/no-methods/" collection.add "simple_trailing_slash_GET_method", ART::Route.new "/trailing/simple/get-method/", methods: "GET" collection.add "simple_trailing_slash_HEAD_method", ART::Route.new "/trailing/simple/head-method/", methods: "HEAD" collection.add "simple_trailing_slash_POST_method", ART::Route.new "/trailing/simple/post-method/", methods: "POST" collection.add "regex_trailing_slash_no_methods", ART::Route.new "/trailing/regex/no-methods/{param}/" collection.add "regex_trailing_slash_GET_method", ART::Route.new "/trailing/regex/get-method/{param}/", methods: "GET" collection.add "regex_trailing_slash_HEAD_method", ART::Route.new "/trailing/regex/head-method/{param}/", methods: "HEAD" collection.add "regex_trailing_slash_POST_method", ART::Route.new "/trailing/regex/post-method/{param}/", methods: "POST" collection.add "simple_not_trailing_slash_no_methods", ART::Route.new "/not-trailing/simple/no-methods" collection.add "simple_not_trailing_slash_GET_method", ART::Route.new "/not-trailing/simple/get-method", methods: "GET" collection.add "simple_not_trailing_slash_HEAD_method", ART::Route.new "/not-trailing/simple/head-method", methods: "HEAD" collection.add "simple_not_trailing_slash_POST_method", ART::Route.new "/not-trailing/simple/post-method", methods: "POST" collection.add "regex_not_trailing_slash_no_methods", ART::Route.new "/not-trailing/regex/no-methods/{param}" collection.add "regex_not_trailing_slash_GET_method", ART::Route.new "/not-trailing/regex/get-method/{param}", methods: "GET" collection.add "regex_not_trailing_slash_HEAD_method", ART::Route.new "/not-trailing/regex/head-method/{param}", methods: "HEAD" collection.add "regex_not_trailing_slash_POST_method", ART::Route.new "/not-trailing/regex/post-method/{param}", methods: "POST" collection end def self.host_tree_collection : ART::RouteCollection collection = ART::RouteCollection.new collection.add "a", ART::Route.new "/", host: "{d}.e.c.b.a" collection.add "b", ART::Route.new "/", host: "d.c.b.a" collection.add "c", ART::Route.new "/", host: "{e}.e.c.b.a" collection end def self.chunked_collection : ART::RouteCollection collection = ART::RouteCollection.new 1000.times do |idx| hash = Digest::MD5.hexdigest(idx.to_s)[0...6] collection.add "_#{idx}", ART::Route.new "/#{hash}/{a}/{b}/{c}/#{hash}" end collection end def self.demo_collection : ART::RouteCollection collection = ART::RouteCollection.new collection.add "a", ART::Route.new "/admin/post/" collection.add "b", ART::Route.new "/admin/post/new" collection.add "c", ART::Route.new "/admin/post/{id}", requirements: {"id" => /\d+/} collection.add "d", ART::Route.new "/admin/post/{id}/edit", requirements: {"id" => /\d+/} collection.add "e", ART::Route.new "/admin/post/{id}/delete", requirements: {"id" => /\d+/} collection.add "f", ART::Route.new "/blog/" collection.add "g", ART::Route.new "/blog/rss.xml" collection.add "h", ART::Route.new "/blog/page/{page}", requirements: {"id" => /\d+/} collection.add "i", ART::Route.new "/blog/posts/{page}", requirements: {"id" => /\d+/} collection.add "j", ART::Route.new "/blog/comments/{id}/new", requirements: {"id" => /\d+/} collection.add "k", ART::Route.new "/blog/search" collection.add "l", ART::Route.new "/login" collection.add "m", ART::Route.new "/logout" collection.add_prefix "/{_locale}" collection.add "n", ART::Route.new "/{_locale}" collection.add_requirements({"_locale" => /en|fr/}) collection.add_defaults({"_locale" => "en"}) collection end def self.suffix_collection : ART::RouteCollection collection = ART::RouteCollection.new collection.add "r1", ART::Route.new "abc{foo}/1" collection.add "r2", ART::Route.new "abc{foo}/2" collection.add "r10", ART::Route.new "abc{foo}/10" collection.add "r20", ART::Route.new "abc{foo}/20" collection.add "r100", ART::Route.new "abc{foo}/100" collection.add "r200", ART::Route.new "abc{foo}/200" collection end def self.host_collection : ART::RouteCollection collection = ART::RouteCollection.new collection.add "r1", ART::Route.new "abc{foo}", host: "{foo}.example.com" collection.add "r2", ART::Route.new "abc{foo}", host: "{foo}.example.com" collection end end ================================================ FILE: src/components/routing/spec/route_spec.cr ================================================ require "./spec_helper" struct RouteTest < ASPEC::TestCase def test_constructor : Nil route = ART::Route.new "/{foo}", {"foo" => "bar"}, {"foo" => /\d+/}, host: "{locale}.example.com" route.path.should eq "/{foo}" route.defaults.should eq({"foo" => "bar"}) route.requirements.should eq({"foo" => /\d+/}) route.host.should eq "{locale}.example.com" route = ART::Route.new "/", schemes: {"Https"}, methods: {"POST", "put"} route.schemes.should eq Set{"https"} route.methods.should eq Set{"POST", "PUT"} route.has_scheme?("https").should be_true route.has_scheme?("HTTPS").should be_true route.has_scheme?("HTTP").should be_false route = ART::Route.new "/", schemes: "Https", methods: "Post" route.schemes.should eq Set{"https"} route.methods.should eq Set{"POST"} route = ART::Route.new "/foo", host: /foo.com/ route.host.should eq "foo.com" route.host = /bar.net/ route.host.should eq "bar.net" end @[DataProvider("path_provider")] def test_path(path : String, expected : String) : Nil route = ART::Route.new("/{foo}").path = path route.path.should eq expected end def path_provider : Hash { "simple" => {"/{bar}", "/{bar}"}, "adds missing /" => {"bar", "/bar"}, "defaults to /" => {"", "/"}, "strips leading /" => {"//path", "/path"}, "keeps !" => {"/path/{!foo}", "/path/{!foo}"}, "strips inline requirements" => {"/path/{bar}", "/path/{bar}"}, "strips inline defaults" => {"/path/{foo?value}", "/path/{foo}"}, "strips all inline settings" => {"/path/{!bar<\\d+>?value}", "/path/{!bar}"}, } end def test_defaults : Nil route = ART::Route.new "/{foo}" route.defaults = {"foo" => "bar"} route.defaults.should eq({"foo" => "bar"}) route.defaults = Hash(String, String).new route.defaults.should be_empty route.set_default "foo", "bar" route.default("foo").should eq "bar" route.set_default "foo2", "bar2" route.default("foo2").should eq "bar2" route.default("missing").should be_nil route.defaults = {"foo" => "foo"} route.add_defaults({"bar" => "bar"}) route.defaults.should eq({"foo" => "foo", "bar" => "bar"}) route.has_default?("foo").should be_true route.has_default?("missing").should be_false route.defaults = ART::Parameters.new route.defaults.should be_empty # Test add_defaults with ART::Parameters params = ART::Parameters.new({"key1" => "value1", "key2" => "value2"}) route.add_defaults(params) route.defaults.should eq({"key1" => "value1", "key2" => "value2"}) # Test typed default getter route.set_default "count", 42 route.default("count", Int32).should eq 42 route.default("missing", Int32).should be_nil route.default("key1", String).should eq "value1" end def test_requirements : Nil route = ART::Route.new "/{foo}" route.requirements = {"foo" => /\d+/, "bar" => "foo"} route.requirements.should eq({"foo" => /\d+/, "bar" => /foo/}) route.requirement("foo").should eq /\d+/ route.requirement("missing").should be_nil # Removes ^|\A and $|\z from the pattern route.requirements = {"foo" => /^\d+$/, "bar" => /\A\d+\z/} route.requirements.should eq({"foo" => /\d+/, "bar" => /\d+/}) route.has_requirement?("foo").should be_true route.has_requirement?("missing").should be_false route.requirements = Hash(String, Regex | String).new route.set_requirement "foo", "foo" route.set_requirement "bar", /bar/ route.requirements.should eq({"foo" => /foo/, "bar" => /bar/}) end def test_compile : Nil route = ART::Route.new "/{foo}" compiled_route = route.compile route.compile.should eq compiled_route route.set_requirement "foo", /\d+/ route.compile.should_not eq compiled_route end @[DataProvider("inline_settings_provider")] def test_inline_defaults_and_requirements(expected : ART::Route, actual : ART::Route) : Nil expected.should eq actual end def inline_settings_provider : Tuple { { ART::Route.new("/foo/{bar}").set_default("bar", nil), ART::Route.new("/foo/{bar?}"), }, { ART::Route.new("/foo/{bar}").set_default("bar", "baz"), ART::Route.new("/foo/{bar?baz}"), }, { ART::Route.new("/foo/{bar}").set_default("bar", "baz"), ART::Route.new("/foo/{bar?baz}"), }, { ART::Route.new("/foo/{!bar}").set_default("bar", "baz"), ART::Route.new("/foo/{!bar?baz}"), }, { ART::Route.new("/foo/{bar}").set_default("bar", "baz"), ART::Route.new("/foo/{bar?}", {"bar" => "baz"}), }, { ART::Route.new("/foo/{bar}").set_requirement("bar", ".*"), ART::Route.new("/foo/{bar<.*>}"), }, { ART::Route.new("/foo/{bar}").set_requirement("bar", ">"), ART::Route.new("/foo/{bar<>>}"), }, { ART::Route.new("/foo/{bar}").set_requirement("bar", /\d+/), ART::Route.new("/foo/{bar<.*>}", requirements: {"bar" => /\d+/}), }, { ART::Route.new("/foo/{bar}").set_requirement("bar", "[a-z]{2}"), ART::Route.new("/foo/{bar<[a-z]{2}>}"), }, { ART::Route.new("/foo/{!bar}").set_requirement("bar", "\\d+"), ART::Route.new("/foo/{!bar<\\d+>}"), }, { ART::Route.new("/foo/{bar}").set_default("bar", nil).set_requirement("bar", ".*"), ART::Route.new("/foo/{bar<.*>?}"), }, { ART::Route.new("/foo/{bar}").set_default("bar", "<>").set_requirement("bar", ">"), ART::Route.new("/foo/{bar<>>?<>}"), }, { ART::Route.new("/{foo}/{!bar}").set_default("bar", "<>").set_default("foo", "\\").set_requirement("bar", /\\/).set_requirement("foo", "."), ART::Route.new("/{foo<.>?\\}/{!bar<\\>?<>}"), }, { ART::Route.new("/").set_default("bar", nil).host=("{bar}"), ART::Route.new("/").host=("{bar?}"), }, { ART::Route.new("/").set_default("bar", "baz").host=("{bar}"), ART::Route.new("/").host=("{bar?baz}"), }, { ART::Route.new("/").set_default("bar", "baz").host=("{bar}"), ART::Route.new("/").host=("{bar?baz}"), }, { ART::Route.new("/").set_default("bar", nil).host=("{bar}"), ART::Route.new("/", {"bar" => "baz"}).host=("{bar?}"), }, { ART::Route.new("/").set_requirement("bar", ".*").host=("{bar}"), ART::Route.new("/").host=("{bar<.*>}"), }, { ART::Route.new("/").set_requirement("bar", ">").host=("{bar}"), ART::Route.new("/").host=("{bar<>>}"), }, { ART::Route.new("/").set_requirement("bar", ".*").host=("{bar}"), ART::Route.new("/", requirements: {"bar" => /\d+/}).host=("{bar<.*>}"), }, { ART::Route.new("/").set_requirement("bar", "[a-z]{2}").host=("{bar}"), ART::Route.new("/").host=("{bar<[a-z]{2}>}"), }, { ART::Route.new("/").set_default("bar", nil).set_requirement("bar", ".*").host=("{bar}"), ART::Route.new("/").host=("{bar<.*>?}"), }, { ART::Route.new("/").set_default("bar", "<>").set_requirement("bar", ">").host=("{bar}"), ART::Route.new("/").host=("{bar<>>?<>}"), }, } end @[DataProvider("non_localized_routes_provider")] def test_locale_default_with_non_localized_routes(route : ART::Route) : Nil route.default("_locale").should_not eq "fr" route.set_default "_locale", "fr" route.default("_locale").should eq "fr" end @[DataProvider("localized_routes_provider")] def test_locale_default_with_localized_routes(route : ART::Route) : Nil expected = route.default("_locale").should_not be_nil expected.should_not eq "fr" route.set_default "_locale", "fr" route.default("_locale").should eq expected end @[DataProvider("non_localized_routes_provider")] def test_locale_requirement_with_non_localized_routes(route : ART::Route) : Nil route.requirement("_locale").should_not eq "fr" route.set_requirement "_locale", "fr" route.requirement("_locale").should eq /fr/ end @[DataProvider("localized_routes_provider")] def test_locale_requirement_with_localized_routes(route : ART::Route) : Nil expected = route.requirement("_locale").should_not be_nil expected.should_not eq "fr" route.set_requirement "_locale", "fr" route.requirement("_locale").should eq expected end def non_localized_routes_provider : Tuple { {ART::Route.new("/foo")}, {ART::Route.new("/foo").set_default("_locale", "en")}, {ART::Route.new("/foo").set_default("_locale", "en").set_default("_canonical_route", "foo")}, {ART::Route.new("/foo").set_default("_locale", "en").set_default("_canonical_route", "foo").set_requirement("_locale", "foobar")}, } end def localized_routes_provider : Tuple { {ART::Route.new("/foo").set_default("_locale", "en").set_default("_canonical_route", "foo").set_requirement("_locale", "en")}, } end end ================================================ FILE: src/components/routing/spec/router_spec.cr ================================================ require "./spec_helper" struct RouterTest < ASPEC::TestCase def test_generate : Nil self.router.generate("foo").should eq "/foo" self.router.generate("foo", id: "1").should eq "/foo?id=1" end def test_match : Nil self.router.match("/foo").should eq({"_route" => "foo"}) self.router.match(ART::Request.new "GET", "/bar").should eq({"_route" => "bar"}) end def test_match? : Nil self.router.match?("/foo").should eq({"_route" => "foo"}) self.router.match?(ART::Request.new "GET", "/bar").should eq({"_route" => "bar"}) self.router.match?("/baz").should be_nil self.router.match?(ART::Request.new "GET", "/baz").should be_nil end private def router : ART::Router collection = ART::RouteCollection.new route1 = ART::Route.new "/foo" route2 = ART::Route.new "/bar" collection.add "foo", route1 collection.add "bar", route2 ART.compile collection router = ART::Router.new collection router.context = ART::RequestContext.new router end end ================================================ FILE: src/components/routing/spec/routing_handler_spec.cr ================================================ require "./spec_helper" private class MockURLMatcher include Athena::Routing::Matcher::RequestMatcherInterface include Athena::Routing::Matcher::URLMatcherInterface property context : ART::RequestContext def initialize(@route : String, @exception : ::Exception? = nil) @context = ART::RequestContext.new end # :inherit: def match(path : String) : ART::Parameters if ex = @exception raise ex end params = ART::Parameters.new params["_route"] = @route params end def match?(path : String) : ART::Parameters? end # :inherit: def match?(@request : ART::Request) : ART::Parameters? self.match? @request.not_nil!.path ensure @request = nil end # :inherit: def match(@request : ART::Request) : ART::Parameters self.match @request.not_nil!.path ensure @request = nil end end describe ART::RoutingHandler do describe "#add" do it "raises when trying to add another collection" do expect_raises ArgumentError, "Cannot add an existing collection to a routing handler." do ART::RoutingHandler.new.add ART::RouteCollection.new end end it "captures the provided route" do handler = ART::RoutingHandler.new handler.add "a_route", ART::Route.new "/foo" handler.size.should eq 1 end end describe "#call" do describe "when not bubbling exceptions" do it "happy path" do value = 0 handler = ART::RoutingHandler.new MockURLMatcher.new "foo" handler.add "foo", ART::Route.new "/foo" do |ctx, params| ctx.request.method.should eq "GET" ctx.request.path.should eq "/foo" value += 10 params.to_h.should eq({"_route" => "foo"}) end handler.call ::HTTP::Server::Context.new ::HTTP::Request.new("GET", "/foo"), ::HTTP::Server::Response.new(IO::Memory.new) value.should eq 10 end it "missing route" do handler = ART::RoutingHandler.new MockURLMatcher.new "foo", ART::Exception::ResourceNotFound.new "Missing" handler.add("foo", ART::Route.new("/foo")) { } Log.capture do |logs| handler.call ::HTTP::Server::Context.new ::HTTP::Request.new("GET", "/foo"), resp = ::HTTP::Server::Response.new(IO::Memory.new) resp.status.should eq ::HTTP::Status::NOT_FOUND logs.empty end end it "unsupported method" do handler = ART::RoutingHandler.new MockURLMatcher.new "foo", ART::Exception::MethodNotAllowed.new ["PUT", "SEARCH"], "Not Allowed" handler.add("foo", ART::Route.new("/foo")) { } Log.capture do |logs| handler.call ::HTTP::Server::Context.new ::HTTP::Request.new("GET", "/foo"), resp = ::HTTP::Server::Response.new(IO::Memory.new) resp.status.should eq ::HTTP::Status::METHOD_NOT_ALLOWED logs.empty end end it "domain exception" do handler = ART::RoutingHandler.new MockURLMatcher.new "foo" handler.add "foo", ART::Route.new "/foo" do |ctx| ctx.request.method.should eq "GET" ctx.request.path.should eq "/foo" raise "Oh no!" end Log.capture do |logs| handler.call ::HTTP::Server::Context.new ::HTTP::Request.new("GET", "/foo"), resp = ::HTTP::Server::Response.new(IO::Memory.new) resp.status.should eq ::HTTP::Status::INTERNAL_SERVER_ERROR logs.check :error, "Unhandled exception" end end end describe "when bubbling exceptions" do it "happy path" do value = 0 handler = ART::RoutingHandler.new MockURLMatcher.new("foo"), bubble_exceptions: true handler.add "foo", ART::Route.new "/foo" do |ctx, params| ctx.request.method.should eq "GET" ctx.request.path.should eq "/foo" value += 10 params.to_h.should eq({"_route" => "foo"}) end handler.call ::HTTP::Server::Context.new ::HTTP::Request.new("GET", "/foo"), ::HTTP::Server::Response.new(IO::Memory.new) value.should eq 10 end it "missing route" do handler = ART::RoutingHandler.new MockURLMatcher.new("foo", ART::Exception::ResourceNotFound.new("Missing")), bubble_exceptions: true handler.add("foo", ART::Route.new("/foo")) { } expect_raises ART::Exception::ResourceNotFound do handler.call ::HTTP::Server::Context.new ::HTTP::Request.new("GET", "/foo"), ::HTTP::Server::Response.new(IO::Memory.new) end end it "unsupported method" do handler = ART::RoutingHandler.new MockURLMatcher.new("foo", ART::Exception::MethodNotAllowed.new(["PUT", "SEARCH"], "Not Allowed")), bubble_exceptions: true handler.add("foo", ART::Route.new("/foo")) { } ex = expect_raises ART::Exception::MethodNotAllowed do handler.call ::HTTP::Server::Context.new ::HTTP::Request.new("GET", "/foo"), ::HTTP::Server::Response.new(IO::Memory.new) end ex.allowed_methods.should eq ["PUT", "SEARCH"] end it "domain exception" do handler = ART::RoutingHandler.new MockURLMatcher.new("foo"), bubble_exceptions: true handler.add "foo", ART::Route.new "/foo" do |ctx| ctx.request.method.should eq "GET" ctx.request.path.should eq "/foo" raise "Oh no!" end Log.capture do |logs| expect_raises ::Exception, "Oh no!" do handler.call ::HTTP::Server::Context.new ::HTTP::Request.new("GET", "/foo"), ::HTTP::Server::Response.new(IO::Memory.new) end logs.empty end end end end describe "#compile" do it "compiles the wrapped collection" do handler = ART::RoutingHandler.new handler.add "a_route", ART::Route.new "/foo" handler.compile ART::RoutingHandler::RouteProvider.compiled?.should be_true ART::RoutingHandler::RouteProvider.static_routes.size.should eq 1 ART::RouteProvider.compiled?.should be_false end end end ================================================ FILE: src/components/routing/spec/spec_helper.cr ================================================ require "spec" require "spec/helpers/iterate" require "athena-spec" require "../src/athena-routing" require "log/spec" ASPEC.run_all Spec.before_each do ART::RouteProvider.reset end Log.setup :none # FIXME: Refactor these specs to not depend on calling a protected method. include Athena::Routing ================================================ FILE: src/components/routing/spec/static_prefix_collection_spec.cr ================================================ require "./spec_helper" struct StaticPrefixCollectionTest < ASPEC::TestCase @[DataProvider("route_provider")] def test_grouping(routes : Array(Tuple(String, String)), expected : String) : Nil collection = ART::RouteProvider::StaticPrefixCollection.new "/" routes.each do |(path, name)| static_prefix = (route = ART::Route.new(path)).compile.static_prefix collection.add_route static_prefix, ART::RouteProvider::StaticPrefixCollection::StaticTreeNamedRoute.new name, route end self.dump(collection).should eq expected end def route_provider : Hash { "simple - not nested" => { [ {"/", "root"}, {"/prefix/segment/", "prefix_segment"}, {"/leading/segment/", "leading_segment"}, ], "root\nprefix_segment\nleading_segment", }, "simple - one level nesting" => { [ {"/", "root"}, {"/group/segment/", "nested_segment"}, {"/group/thing/", "some_segment"}, {"/group/other/", "other_segment"}, ], "root\n/group/\n-> nested_segment\n-> some_segment\n-> other_segment", }, "nested - small group" => { [ {"/", "root"}, {"/prefix/segment/", "prefix_segment"}, {"/prefix/segment/bb", "leading_segment"}, ], "root\n/prefix/segment/\n-> prefix_segment\n-> leading_segment", }, "nested - contains item at intersection" => { [ {"/", "root"}, {"/prefix/segment/", "prefix_segment"}, {"/prefix/segment/bb", "leading_segment"}, ], "root\n/prefix/segment/\n-> prefix_segment\n-> leading_segment", }, "Retains matching order within groups" => { [ {"/group/aa/", "aa"}, {"/group/bb/", "bb"}, {"/group/cc/", "cc"}, {"/(.*)", "root"}, {"/group/dd/", "dd"}, {"/group/ee/", "ee"}, {"/group/ff/", "ff"}, ], "/group/\n-> aa\n-> bb\n-> cc\nroot\n/group/\n-> dd\n-> ee\n-> ff", }, "Retains complex matching order with groups at base" => { [ {"/aaa/111/", "first_aaa"}, {"/prefixed/group/aa/", "aa"}, {"/prefixed/group/bb/", "bb"}, {"/prefixed/group/cc/", "cc"}, {"/prefixed/(.*)", "root"}, {"/prefixed/group/dd/", "dd"}, {"/prefixed/group/ee/", "ee"}, {"/prefixed/", "parent"}, {"/prefixed/group/ff/", "ff"}, {"/aaa/222/", "second_aaa"}, {"/aaa/333/", "third_aaa"}, ], "/aaa/\n-> first_aaa\n-> second_aaa\n-> third_aaa\n/prefixed/\n-> /prefixed/group/\n-> -> aa\n-> -> bb\n-> -> cc\n-> root\n-> /prefixed/group/\n-> -> dd\n-> -> ee\n-> -> ff\n-> parent", }, "Group regardless of segments" => { [ {"/aaa-111/", "a1"}, {"/aaa-222/", "a2"}, {"/aaa-333/", "a3"}, {"/group-aa/", "g1"}, {"/group-bb/", "g2"}, {"/group-cc/", "g3"}, ], "/aaa-\n-> a1\n-> a2\n-> a3\n/group-\n-> g1\n-> g2\n-> g3", }, } end private def dump(collection : ART::RouteProvider::StaticPrefixCollection, prefix : String = "") : String lines = [] of String collection.items.each do |item| if item.is_a? ART::RouteProvider::StaticPrefixCollection lines << "#{prefix}#{item.prefix}" lines << self.dump(item, "#{prefix}-> ") else lines << "#{prefix}#{item.name}" end end lines.join "\n" end end ================================================ FILE: src/components/routing/src/annotations.cr ================================================ # Contains all the `Athena::Routing` based annotations. # See `ARTA::Route` for more information. # # NOTE: These are primarily to define a common type/documentation to use in custom implementations. # As of now, they are not leveraged internally, but a future iteration could provide a built in way to resolve them into an `ART::RouteCollection`. module Athena::Routing::Annotations # Same as `ARTA::Route`, but only matches the `DELETE` method. annotation Delete; end # Same as `ARTA::Route`, but only matches the `GET` method. annotation Get; end # Same as `ARTA::Route`, but only matches the `HEAD` method. annotation Head; end # Same as `ARTA::Route`, but only matches the `LINK` method. annotation Link; end # Same as `ARTA::Route`, but only matches the `PATCH` method. annotation Patch; end # Same as `ARTA::Route`, but only matches the `POST` method. annotation Post; end # Same as `ARTA::Route`, but only matches the `PUT` method. annotation Put; end # Annotation representation of an `ART::Route`. # Most commonly this will be applied to a method to define it as the controller for the related route, # but could also be applied to a controller class to apply defaults to all other `ARTA::Route` within it. # Custom implementations may support alternate APIs. # See `ART::Route` for more information. # # ## Configuration # # Various fields can be used within this annotation to control how the route is created. # All fields are optional unless otherwise noted. # # WARNING: Not all fields may be supported by the underlying implementation. # # #### path # # **Type:** `String | Hash(String, String)` - **required** # # The path of the route. # # #### name # # **Type:** `String` # # The unique name of the route. If not provided, a unique name should be created automatically. # # #### requirements # # **Type:** `Hash(String, String | Regex)` # # A `Hash` of patterns that each parameter must match in order for the route to match. # # #### defaults # # **Type:** `Hash(String, _)` # # The values that should be applied to the route parameters if they were not supplied within the request. # # #### host # # **Type:** `String | Regex` # # Require the [host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) header to match this value in order for the route to match. # # #### methods # # **Type:** `String | Enumerable(String)` # # A whitelist of the HTTP methods this route supports. # # #### schemes # # **Type:** `String | Enumerable(String)` # # A whitelist of the HTTP schemes this route supports. # # #### condition # # **Type:** `ART::Route::Condition` # # A callback used to dynamically determine if the request matches the route. # # #### priority # # **Type:** `Int32` # # A value used to control the order the routes are registered in. # A higher value means that route will be registered earlier. # # #### locale # # **Type:** `String` # # Allows setting the locale this route supports. # Sets the special `_locale` route parameter. # # #### format # # **Type:** `String` # # Allows setting the format this route supports. # Sets the special `_format` route parameter. # # #### stateless # # **Type:** `Bool` # # If the route should be cached or not. annotation Route; end # Same as `ARTA::Route`, but only matches the `UNLINK` method. annotation Unlink; end end ================================================ FILE: src/components/routing/src/athena-routing.cr ================================================ require "./ext/regex" require "http/request" require "./annotations" require "./compiled_route" require "./parameters" require "./request_context" require "./request_context_aware_interface" require "./route" require "./route_collection" require "./route_compiler" require "./route_provider" require "./routing_handler" require "./router" require "./exception/*" require "./generator/*" require "./matcher/*" require "./requirement/*" # Convenience alias to make referencing `Athena::Routing` types easier. alias ART = Athena::Routing # Convenience alias to make referencing `ART::Annotations` types easier. alias ARTA = ART::Annotations # Provides a performant and robust HTTP based routing library/framework. module Athena::Routing VERSION = "0.2.0" {% if @top_level.has_constant?("Athena") && Athena.has_constant?("HTTP") && Athena::HTTP.has_constant?("Request") %} # Represents the type of the *request* parameter within an `ART::Route::Condition`. # # Will be an [AHTTP::Request](/HTTP/Request) instance if used within the Athena Framework, otherwise [HTTP::Request](https://crystal-lang.org/api/HTTP/Request.html). alias Request = Athena::HTTP::Request {% else %} # Represents the type of the *request* parameter within an `ART::Route::Condition`. # # Will be an [AHTTP::Request](/HTTP/Request) instance if used within the Athena Framework, otherwise [HTTP::Request](https://crystal-lang.org/api/HTTP/Request.html). alias Request = ::HTTP::Request {% end %} # Includes types related to generating URLs. module Generator; end # Includes types related to matching a path/request to a route. module Matcher; end # Both acts as a namespace for exceptions related to the `Athena::Routing` component, as well as a way to check for exceptions from the component. module Exception; end # Before `ART::Route`s can be matched or generated, they must first be compiled. # This process compiles each route into its `ART::CompiledRoute` representation, # then merges them all together into a more efficient cacheable format. # # A custom *route_provider* type may be provided to compile the routes into a different provider. # By default, the default global `ART::RouteProvider` is used. def self.compile(routes : ART::RouteCollection, *, route_provider : ART::RouteProvider.class = ART::RouteProvider) : Nil route_provider.compile routes end end ================================================ FILE: src/components/routing/src/compiled_route.cr ================================================ # Represents an immutable snapshot of an `ART::Route` that exposes the `Regex` patterns and variables used to match/generate the route. struct Athena::Routing::CompiledRoute # An immutable representation of a segment of a route used to reconstruct a valid URL from an `ART::CompiledRoute`. struct Token # Represents if a `ART::CompiledRoute::Token` is static text, or has a variable portion. enum Type # Static text. TEXT # Variable data. VARIABLE end # Returns the type this token represents. getter type : Type # Returns that static prefix related to this token. getter prefix : String # Returns the pattern this `ART::CompiledRoute::Token::Type::VARIABLE` token requires. getter regex : Regex? # Returns the name of parameter this `ART::CompiledRoute::Token::Type::VARIABLE` token represents. getter var_name : String? # Returns `true` if this token should always be included within the generated URL, otherwise `false`. getter? important : Bool def initialize( @type : Type, @prefix : String, @regex : Regex? = nil, @var_name : String? = nil, @important : Bool = false, ) end # :nodoc: def_clone end # Returns the static text prefix of this route. getter static_prefix : String # Returns the regex pattern used to match this route. getter regex : Regex # Returns the tokens that make up the path of this route. getter tokens : Array(ART::CompiledRoute::Token) # Returns the names of the route parameters within this route. getter path_variables : Set(String) # Returns the regex pattern used to match the hostname of this route. getter host_regex : Regex? # Returns the tokens that make up the hostname of this route. getter host_tokens : Array(ART::CompiledRoute::Token) # Returns the names of the route parameters within the hostname pattern this route. getter host_variables : Set(String) # Returns the compiled parameter names from the path and hostname patterns. getter variables : Set(String) def initialize( @static_prefix : String, @regex : Regex, @tokens : Array(ART::CompiledRoute::Token), @path_variables : Set(String), @host_regex : Regex? = nil, @host_tokens : Array(ART::CompiledRoute::Token) = Array(ART::CompiledRoute::Token).new, @host_variables : Set(String) = Set(String).new, @variables : Set(String) = Set(String).new, ) end # :nodoc: def_clone end ================================================ FILE: src/components/routing/src/exception/invalid_argument.cr ================================================ class Athena::Routing::Exception::InvalidArgument < ArgumentError include Athena::Routing::Exception end ================================================ FILE: src/components/routing/src/exception/invalid_parameter.cr ================================================ class Athena::Routing::Exception::InvalidParameter < ArgumentError include Athena::Routing::Exception end ================================================ FILE: src/components/routing/src/exception/method_not_allowed.cr ================================================ class Athena::Routing::Exception::MethodNotAllowed < RuntimeError include Athena::Routing::Exception getter allowed_methods : Array(String) def initialize(allowed_methods : Enumerable(String), message : String? = nil, cause : ::Exception? = nil) @allowed_methods = allowed_methods.map &.upcase super message, cause end end ================================================ FILE: src/components/routing/src/exception/missing_required_parameters.cr ================================================ class Athena::Routing::Exception::MissingRequiredParameters < ArgumentError include Athena::Routing::Exception end ================================================ FILE: src/components/routing/src/exception/no_configuration.cr ================================================ require "./resource_not_found" class Athena::Routing::Exception::NoConfiguration < Athena::Routing::Exception::ResourceNotFound include Athena::Routing::Exception end ================================================ FILE: src/components/routing/src/exception/resource_not_found.cr ================================================ class Athena::Routing::Exception::ResourceNotFound < RuntimeError include Athena::Routing::Exception end ================================================ FILE: src/components/routing/src/exception/route_not_found.cr ================================================ class Athena::Routing::Exception::RouteNotFound < ArgumentError include Athena::Routing::Exception end ================================================ FILE: src/components/routing/src/ext/regex.cr ================================================ lib LibPCRE2 fun jit_match = pcre2_jit_match_8(code : Code*, subject : UInt8*, length : LibC::SizeT, startoffset : LibC::SizeT, options : UInt32, match_data : MatchData*, mcontext : MatchContext*) : Int fun get_mark = pcre2_get_mark_8(match_data : MatchData*) : UInt8* end # Customizations to stdlib Regex logic to support fast path API and MARK verb class Regex def self.fast_path(source : String, options : Options = Options::None) new(_source: source, _options: options, _force_jit: true) end end module Regex::PCRE2 module MatchData getter mark : String? def initialize( @regex : Regex, @code : LibPCRE2::Code*, @string : String, @pos : Int32, @ovector : LibC::SizeT*, @group_size : Int32, @mark : String?, ) end end @force_jit : Bool = false def initialize(*, _source @source : String, _options @options, _force_jit @force_jit : Bool = false) options = pcre2_compile_options(options) | LibPCRE2::UTF | LibPCRE2::DUPNAMES | LibPCRE2::UCP @re = PCRE2.compile(source, options) do |error_message| raise ArgumentError.new(error_message) end @jit = jit_compile end private def match_data(str, byte_index, options) # TODO: Remove and make 1.19 min supported version match_data = {% if compare_versions(Crystal::VERSION, "1.19.0-dev") >= 0 %} Regex::PCRE2.current_match_data.value {% else %} self.match_data {% end %} # CUSTOMIZE - Leverage JIT Fast Path mode if available match_count = if @jit && @force_jit LibPCRE2.jit_match(@re, str, str.bytesize, byte_index, pcre2_match_options(options), match_data, PCRE2.match_context) else LibPCRE2.match(@re, str, str.bytesize, byte_index, pcre2_match_options(options), match_data, PCRE2.match_context) end if match_count < 0 case error = LibPCRE2::Error.new(match_count) when .nomatch? return when .badutfoffset?, .utf8_validity? error_message = PCRE2.get_error_message(error) raise ArgumentError.new("Regex match error: #{error_message}") else error_message = PCRE2.get_error_message(error) raise Regex::Error.new("Regex match error: #{error_message}") end end match_data end private def match_impl(str, byte_index, options) match_data = match_data(str, byte_index, options) || return # TODO: Remove and make 1.19 min supported version ovector_count = {% if compare_versions(Crystal::VERSION, "1.19.0-dev") >= 0 %} # We reuse the same `match_data` allocation, so we must reimplement the # behavior of pcre2_match_data_create_from_pattern (get_ovector_count always # returns 65535, aka the maximum). capture_count_impl &+ 1 {% else %} LibPCRE2.get_ovector_count(match_data) {% end %} ovector = Slice.new(LibPCRE2.get_ovector_pointer(match_data), ovector_count &* 2) # We need to dup the ovector because `match_data` is re-used for subsequent # matches. We only dup the match data (not everything). ovector = ovector.dup ::Regex::MatchData.new( self, @re, str, byte_index, ovector.to_unsafe, ovector_count.to_i32 &- 1, # CUSTOMIZE - Get MARK verb ((mark = LibPCRE2.get_mark(match_data)) ? String.new(mark) : nil) ) end end module Athena::Routing protected def self.create_regex(source : String) : ::Regex ::Regex.fast_path source, ::Regex::CompileOptions[:dotall, :dollar_endonly, :no_utf8_check] end end ================================================ FILE: src/components/routing/src/generator/configurable_requirements_interface.cr ================================================ # Represents a URL generator that can be configured whether an exception should be generated when the parameters do not match the requirements. module Athena::Routing::Generator::ConfigurableRequirementsInterface # Sets how invalid parameters should be treated: # # * `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. abstract def strict_requirements=(enabled : Bool?) # Returns the current strict requirements mode. abstract def strict_requirements? : Bool? end ================================================ FILE: src/components/routing/src/generator/interface.cr ================================================ # Allows generating a URL for a given `ART::Route`. # # ``` # routes = ART::RouteCollection.new # routes.add "blog_show", ART::Route.new "/blog/{slug}" # # generator = ART::Generator::URLGenerator.new context # generator.generate "blog_show", slug: "bar-baz" # => "/blog/bar-baz" # ``` # # ## Query Parameters # # If a parameter passed in via *params* does not map to a known route parameter (path, hostname, etc) it'll be added as a query parameter. # For example, using the route defined above: # # ``` # generator.generate "blog_show", slug: "bar-baz", source: "Crystal" # => "/blog/bar-baz?source=Crystal" # ``` # # The special `_query` parameter may be used to explicitly add query parameters. # This can be useful when a query parameter may conflict with a route parameter of the same name. # For example, given a route like `https://{siteCode}.{domain}/admin/stats`: # # ``` # generator # .generate( # "admin_stats", # { # "siteCode" => "fr", # "domain" => "example.com", # "_query" => { # "siteCode" => "us", # }, # }, # ) # => "https://fr.example.com/admin/stats?siteCode=us" # ``` # # ## Parameter Default Values # # By default parameters with a default value the same as the provided parameter will be excluded from the generated URL. # For example: # # ``` # routes = ART::RouteCollection.new # routes.add "articles", ART::Route.new "/articles/{page}", {"page" => "1"} # # ART.compile routes # # generator = ART::Generator::URLGenerator.new ART::RequestContext.new # generator.generate "articles" # => "/articles" # generator.generate "articles", page: 1 # => "/articles" # generator.generate "articles", page: 2 # => "/articles/2" # ``` # # If you want to always include a parameter, add a `!` before the `ART::Route#path`, for example: # # ``` # routes.add "users", ART::Route.new "/users/{!page}", {"page" => "1"} # # generator.generate "users" # => "/users/1" # generator.generate "users", page: 1 # => "/users/1" # generator.generate "users", page: 2 # => "/users/2" # ``` # # ## URL Types # # `Athena::Routing` supports various ways to generate the URL, via the *reference_type* parameter. # See `ART::Generator::ReferenceType` for description/examples of the possible types. module Athena::Routing::Generator::Interface include Athena::Routing::RequestContextAwareInterface # Generates a URL for the provided *route*, optionally with the provided *params* and *reference_type*. abstract def generate(route : String, params : Hash = Hash(String, String?).new, reference_type : ART::Generator::ReferenceType = :absolute_path) : String # :ditto: abstract def generate(route : String, reference_type : ART::Generator::ReferenceType = :absolute_path, **params) : String end ================================================ FILE: src/components/routing/src/generator/reference_type.cr ================================================ # Represents the type of URLs that are able to be generated via an `ART::Generator::Interface`. enum Athena::Routing::Generator::ReferenceType # Includes an absolute URL including protocol, hostname, and path: `https://api.example.com/add/10/5`. ABSOLUTE_URL # The default type, includes an absolute path from the root to the generated route: `/add/10/5`. ABSOLUTE_PATH # Returns a path relative to the path of the request. # For example: # # ``` # routes = ART::RouteCollection.new # routes.add "one", ART::Route.new "/a/b/c/d" # routes.add "two", ART::Route.new "/a/b/c/" # routes.add "three", ART::Route.new "/a/b/" # routes.add "four", ART::Route.new "/a/b/c/other" # routes.add "five", ART::Route.new "/a/x/y" # # ART.compile routes # # context = ART::RequestContext.new path: "/a/b/c/d" # # generator = ART::Generator::URLGenerator.new context # # generator.generate "one", reference_type: :relative_path # => "" # generator.generate "two", reference_type: :relative_path # => "./" # generator.generate "three", reference_type: :relative_path # => "../" # generator.generate "four", reference_type: :relative_path # => "other" # generator.generate "five", reference_type: :relative_path # => "../../x/y" # ``` RELATIVE_PATH # Similar to `ABSOLUTE_URL`, but reuses the current protocol: `//api.example.com/add/10/5`. NETWORK_PATH end ================================================ FILE: src/components/routing/src/generator/url_generator.cr ================================================ # Default implementation of `ART::Generator::Interface`. class Athena::Routing::Generator::URLGenerator include Athena::Routing::Generator::Interface include Athena::Routing::Generator::ConfigurableRequirementsInterface # Maps some chars that should be displayed in their raw form and _NOT_ percent encoded, for reasons below. private DECODED_CHARS = { # the slash can be used to designate a hierarchical structure and we want allow using it with this meaning # some webservers don't allow the slash in encoded form in the path for security reasons anyway # see http://stackoverflow.com/questions/4069002/http-400-if-2f-part-of-get-url-in-jboss "%2F" => "/", "%252F" => "%2F", # the following chars are general delimiters in the URI specification but have only special meaning in the authority component # so they can safely be used in the path in unencoded form "%40" => "@", "%3A" => ":", # these chars are only sub-delimiters that have no predefined meaning and can therefore be used literally # so URI producing applications can use these chars to delimit subcomponents in a path segment without being encoded for better readability "%3B" => ";", "%2C" => ",", "%3D" => "=", "%2B" => "+", "%21" => "!", "%2A" => "*", "%7C" => "|", } private DECODED_QUERY_FRAGMENT_CHARS = { # RFC 3986 explicitly allows those in the query/fragment to reference other URIs unencoded "%2F" => "/", "%252F" => "%2F", "%3F" => "?", # reserved chars that have no special meaning for HTTP URIs in a query or fragment # this excludes esp. "&", "=" and also "+" because PHP would treat it as a space (form-encoded) "%40" => "@", "%3A" => ":", "%21" => "!", "%3B" => ";", "%2C" => ",", "%2A" => "*", } # :inherit: property context : ART::RequestContext # :inherit: getter? strict_requirements : Bool? = true def initialize( @context : ART::RequestContext, @default_locale : String? = nil, @route_provider : ART::RouteProvider.class = ART::RouteProvider, ) end def strict_requirements=(enabled : Bool?) @strict_requirements = enabled end # :inherit: def generate(route : String, params : Hash = Hash(String, String?).new, reference_type : ART::Generator::ReferenceType = :absolute_path) : String if locale = params["_locale"]? || @context.parameters["_locale"]? || @default_locale if (locale_route = @route_provider.route_generation_data["#{route}.#{locale}"]?) && (route == locale_route[1]["_canonical_route"]?) route = "#{route}.#{locale}" end end unless generation_data = @route_provider.route_generation_data[route]? raise ART::Exception::RouteNotFound.new "No route with the name '#{route}' exists." end variables, defaults, requirements, tokens, host_tokens, schemes = generation_data if defaults.has_key?("_canonical_route") && defaults.has_key?("_locale") if !variables.includes? "_locale" params.delete "_locale" elsif !params.has_key?("_locale") params = params.merge({"_locale" => defaults["_locale"]?.try(&.to_s)}) end end self.do_generate variables, defaults, requirements, tokens, params, route, reference_type, host_tokens, schemes end # :inherit: def generate(route : String, reference_type : ART::Generator::ReferenceType = :absolute_path, **params) : String self.generate route, params.to_h.transform_keys(&.to_s), reference_type end # OPTIMIZE: We could probably make use of `URI` for a lot of this stuff. # # ameba:disable Metrics/CyclomaticComplexity private def do_generate( variables : Set(String), defaults : ART::Parameters, requirements : Hash(String, Regex), tokens : Array(ART::CompiledRoute::Token), params : Hash, name : String, reference_type : ART::Generator::ReferenceType, host_tokens : Array(ART::CompiledRoute::Token), required_schemes : Set(String)?, ) : String query_parameters = Hash(String, String).new if (qp = params["_query"]?).is_a?(Hash) query_parameters = qp.transform_values(&.to_s) params.delete "_query" end # Normalize params types after handling `_query` params = params.transform_values(&.to_s) merged_params = Hash(String, String?).new merged_params.merge! defaults.to_h merged_params.merge! @context.parameters merged_params.merge! params unless (missing_params = variables - merged_params.keys).empty? raise ART::Exception::MissingRequiredParameters.new %(Cannot generate URL for route '#{name}'. Missing required parameters: #{missing_params.join(", ") { |p| "'#{p}'" }}.) end url = "" optional = true message = "Parameter '%s' for route '%s' must match '%s' (got '%s') to generate the corresponding URL." tokens.each do |token| case token.type in .variable? var_name = token.var_name.not_nil! important = token.important? if !optional || important || !defaults.has_key?(var_name) || ((mv = merged_params[var_name]?.presence) && mv.to_s != defaults[var_name].to_s) if !@strict_requirements.nil? && (r = token.regex) && !(merged_params[token.var_name]? || "").to_s.matches?(/^#{r.source.gsub /\(\?(?:=|<=|!| "/%2E%2E/", "/./" => "/%2E/"}).keys), hash if url.ends_with? "/.." url = url.sub (-2..-1), "%2E%2E" elsif url.ends_with? "/." url = url.sub -1, "%2E" end scheme_authority = "" host = @context.host scheme = @context.scheme if required_schemes unless required_schemes.includes? scheme reference_type = ART::Generator::ReferenceType::ABSOLUTE_URL scheme = required_schemes.to_a.first end end unless host_tokens.empty? route_host = "" host_tokens.each do |token| case token.type in .variable? if !@strict_requirements.nil? && (r = token.regex) && !(merged_params[token.var_name]? || "").to_s.matches?(/^#{r.source.gsub /\(\?(?:=|<=|!| pattern} cr = r.compile if cr.variables.includes?(k) && !path.matches?(cr.regex) @traces << Trace.new "Requirement for '#{k}' does not match (#{pattern.source})", :partial, name, route break end end next end has_trailing_var = trimmed_path != path && route.path.matches?(/\{[\w\x80-\xFF]+\}\/?$/) if has_trailing_var && (has_trailing_slash || ((n = match[compiled_route.path_variables.size]?).nil?) || ('/' != (n.try &.[-1]? || '/'))) && (sub_match = regex.match(trimmed_path)) if has_trailing_slash match = sub_match else has_trailing_var = false end end if (host_pattern = compiled_route.host_regex) && !(host_match = host_pattern.match @context.host) @traces << Trace.new "Host '#{@context.host}' does not match the requirement ('#{route.host}')", :partial, name, route next end attributes = self.get_attributes route, name, host_match ? match.to_h.merge(host_match.to_h) : match.to_h condition_match = self.handle_route_requirements path, name, route, attributes unless condition_match @traces << Trace.new "Route condition for '#{name}' does not evaluate to 'true'", :partial, name, route next end if "/" != path && !has_trailing_var && has_trailing_slash == (trimmed_path == path) if supports_trailing_slash && (required_methods && (required_methods.empty? || required_methods.includes? "GET")) @traces << Trace.new "Route matches!", :full, name, route return end @traces << Trace.new "Path '#{route.path}' does not match", :none, name, route next end if (schemes = route.schemes) && !route.has_scheme?(@context.scheme) allow_schemes.concat schemes @traces << Trace.new "Scheme '#{@context.scheme}' does not match any of the required schemes (#{schemes.join ", "})", :partial, name, route next end if required_methods && !required_methods.includes? method allow.concat required_methods @traces << Trace.new "Method '#{@context.method}' does not match any of the required methods (#{required_methods.join ", "})", :partial, name, route next end @traces << Trace.new "Route matches!", :full, name, route return attributes end end private def get_attributes(route : ART::Route, name : String, attributes : Hash(String | Int32, String?)) : ART::Parameters defaults = route.defaults.dup if canonical_route = defaults["_canonical_route"]? name = canonical_route defaults.delete "_canonical_route" end defaults["_route"] = name self.merge_defaults attributes, defaults end private def merge_defaults(params : Hash(String | Int32, String?), defaults : ART::Parameters) : ART::Parameters params.each do |k, v| if !k.is_a?(Int) && !v.nil? defaults[k] = v end end defaults end private def handle_route_requirements(path : String, name : String, route : ART::Route, attributes : ART::Parameters) : Bool if (condition = route.condition) && !condition.call(@context, @request || self.build_request(path)) return false end true end end ================================================ FILE: src/components/routing/src/matcher/url_matcher.cr ================================================ require "./url_matcher_interface" # Default implementation of `ART::Matcher::RequestMatcherInterface` and `ART::Matcher::URLMatcherInterface`. class Athena::Routing::Matcher::URLMatcher include Athena::Routing::Matcher::RequestMatcherInterface include Athena::Routing::Matcher::URLMatcherInterface property context : ART::RequestContext @request : ART::Request? = nil def initialize( @context : ART::RequestContext, @route_provider : ART::RouteProvider.class = ART::RouteProvider, ); end # :inherit: def match(@request : ART::Request) : ART::Parameters self.match @request.not_nil!.path ensure @request = nil end # :inherit: def match?(@request : ART::Request) : ART::Parameters? self.match? @request.not_nil!.path ensure @request = nil end # :inherit: def match(path : String) : ART::Parameters allow = Array(String).new allow_schemes = Array(String).new if match = self.do_match path, allow, allow_schemes return match end unless allow.empty? raise ART::Exception::MethodNotAllowed.new allow end unless self.is_a? ART::Matcher::RedirectableURLMatcherInterface raise ART::Exception::ResourceNotFound.new "No routes found for '#{path}'." end if !@context.method.in? "GET", "HEAD" # no-op elsif !allow_schemes.empty? redirect_schema elsif "/" != (trimmed_path = (path.rstrip('/').presence || "/")) path = trimmed_path == path ? "#{path}/" : trimmed_path if match = self.do_match path, allow, allow_schemes return match.merge! self.redirect(path, match["_route"]) end unless allow_schemes.empty? redirect_schema end end raise ART::Exception::ResourceNotFound.new "No routes found for '#{path}'." end # :inherit: def match?(path : String) : ART::Parameters? self.do_match path, Array(String).new, Array(String).new end # ameba:disable Metrics/CyclomaticComplexity private def do_match(path : String, allow : Array(String) = [] of String, allow_schemes : Array(String) = [] of String) : ART::Parameters? allow.clear allow_schemes.clear path = URI.decode(path).presence || "/" path = path.presence || "/" trimmed_path = path.rstrip('/').presence || "/" request_method = canonical_method = @context.method host = @context.host.downcase if @route_provider.match_host? canonical_method = "GET" if "HEAD" == request_method supports_redirect = "GET" == canonical_method && self.is_a? ART::Matcher::RedirectableURLMatcherInterface @route_provider.static_routes[trimmed_path]?.try &.each do |data, required_host, required_methods, required_schemes, has_trailing_slash, _, condition| if condition && !(@route_provider.conditions[condition].call(@context, @request || self.build_request(path))) next end # Dup the data hash so we don't mutate the original. data = data.dup if h = required_host case h in String then next if h != host in Regex if match = host.try &.match h host_matches = match.named_captures host_matches["_route"] = data["_route"] host_matches.each do |key, value| data[key] = value unless value.nil? end else next end end end if "/" != path && has_trailing_slash == (trimmed_path == path) if supports_redirect && (!required_methods || (required_methods.empty? || required_methods.includes? "GET")) allow.clear allow_schemes.clear return end next end # TODO: Check schemas has_required_scheme = required_schemes.nil? || required_schemes.includes? @context.scheme if has_required_scheme && required_methods && !required_methods.includes?(canonical_method) && !required_methods.includes?(request_method) allow.concat required_methods next end if !has_required_scheme required_schemes.try do |schemes| allow_schemes.concat schemes end next end return data end matched_path = @route_provider.match_host? ? "#{host}.#{path}" : path @route_provider.route_regexes.each do |offset, regex| while match = regex.match matched_path @route_provider.dynamic_routes[matched_mark = match.mark.not_nil!]?.try &.each do |data, vars, required_methods, required_schemes, has_trailing_slash, has_trailing_var, condition| # Dup the data hash so we don't mutate the original. data = data.dup if condition && !(@route_provider.conditions[condition].call(@context, @request || self.build_request(path))) next end has_trailing_var = trimmed_path != path && has_trailing_var if has_trailing_var && (has_trailing_slash || (!vars || (n = match[vars.size]?).nil?) || ('/' != (n.try &.[-1]? || '/'))) && (sub_match = regex.match(@route_provider.match_host? ? "#{host}.#{trimmed_path}" : trimmed_path)) && (matched_mark == sub_match.mark.not_nil!) if has_trailing_slash match = sub_match else has_trailing_var = false end end if "/" != path && !has_trailing_var && has_trailing_slash == (trimmed_path == path) if supports_redirect && (!required_methods || (required_methods.empty? || required_methods.includes? "GET")) allow.clear allow_schemes.clear return end next end vars.try &.each_with_index do |var, idx| if m = match[idx + 1]? data[var] = m end end if required_schemes && required_schemes.includes? @context.scheme allow_schemes.concat required_schemes next end if required_methods && !required_methods.includes?(canonical_method) && !required_methods.includes?(request_method) allow.concat required_methods next end return data end regex = ART.create_regex regex.source.sub "(*:#{matched_mark})", "(*F)" offset += matched_mark.size end end if "/" == path && allow.empty? && allow_schemes.empty? raise ART::Exception::NoConfiguration.new end nil end private def build_request(path : String) : ART::Request request = ::HTTP::Request.new( @context.method, "#{@context.base_url}#{path}", headers: ::HTTP::Headers{ "host" => %(#{@context.host}:#{"http" == @context.scheme ? @context.http_port : @context.https_port}), } ) {% if @top_level.has_constant?("Athena") && Athena.has_constant?("HTTP") && Athena::HTTP.has_constant?("Request") %} request = Athena::HTTP::Request.new request {% end %} request end private macro redirect_schema scheme = @context.scheme @context.scheme = allow_schemes.last? || "" begin if match = self.do_match path return match.merge! self.redirect(path, match["_route"], @context.scheme) end ensure @context.scheme = scheme end end end ================================================ FILE: src/components/routing/src/matcher/url_matcher_interface.cr ================================================ # Allows matching a request path, or `ART::Request` in the case of `ART::Matcher::RequestMatcherInterface`, to its related route. # # ``` # # Create a new route collection and add a route with a single parameter to it. # routes = ART::RouteCollection.new # routes.add "blog_show", ART::Route.new "/blog/{slug}" # # # Compile the routes. # ART.compile routes # # # Represents the request in an agnostic data format. # # In practice this would be created from the current `ART::Request`. # context = ART::RequestContext.new # # # Match a request by path. # matcher = ART::Matcher::URLMatcher.new context # matcher.match "/blog/foo-bar" # => ART::Parameters{"_route" => "blog_show", "slug" => "foo-bar"} # ``` module Athena::Routing::Matcher::URLMatcherInterface include Athena::Routing::RequestContextAwareInterface # Tries to match the provided *path* to its related route. # Returns an `ART::Parameters` containing the route's defaults and parameters resolved from the *path*. # # Raises an `ART::Exception::ResourceNotFound` if no route could be matched. # # Raises an `ART::Exception::MethodNotAllowed` if a route exists but not for the current HTTP method. abstract def match(path : String) : ART::Parameters # Tries to match the provided *path* to its related route. # Returns an `ART::Parameters` containing the route's defaults and parameters resolved from the *path*. # # Returns `nil` if no route could be matched or a route exists but not for the current HTTP method. abstract def match?(path : String) : ART::Parameters? end ================================================ FILE: src/components/routing/src/parameters.cr ================================================ # A container representing parameters defined via `ART::Route#defaults`, or returned when matching a route. # Allows the value to be of any type. class Athena::Routing::Parameters private abstract struct Param abstract def value abstract def type_name : String def inspect(io : IO) : Nil if self.value.is_a?(String | Bool | Number::Primitive) return self.value.inspect io end io << "#" end end private record Parameter(T) < Param, value : T do def type_name : String {{ T.stringify }} end end @parameters : Hash(String, Param) = Hash(String, Param).new def initialize end def self.new(parameters : self) : self parameters end def self.new(hash : Hash(String, _)) params = new hash.each do |key, value| params[key] = value end params end # Returns `true` if a parameter with the provided *name* exists, otherwise `false`. def has_key?(name : String) : Bool @parameters.has_key? name end # Returns the value of the parameter with the provided *name* as a `String`. # # Raises a `KeyError` if no parameter with that name exists. def [](name : String) : String @parameters.fetch(name) { raise KeyError.new "No parameter exists with the name '#{name}'." }.value.as(String) end # Returns the value of the parameter with the provided *name* as a `String` if it exists, otherwise `nil`. def []?(name : String) : String? @parameters[name]?.try &.value.as?(String) end # Returns the value of the parameter with the provided *name* casted to the provided *type* if it exists, otherwise `nil`. def get?(name : String, type : T.class) : T? forall T @parameters[name]?.try &.value.as?(T) end # Returns the value of the parameter with the provided *name*, casted to the provided *type*. # # Raises a `KeyError` if no parameter with that name exists. def get(name : String, type : T.class) : T forall T @parameters.fetch(name) { raise KeyError.new "No parameter exists with the name '#{name}'." }.value.as(T) end # Sets a parameter with the provided *name* to *value*. def []=(name : String, value : T) : Nil forall T @parameters[name] = Parameter(T).new value end # Removes the parameter with the provided *name*. def delete(name : String) : Nil @parameters.delete name end # :nodoc: def raw?(name : String) @parameters[name]?.try &.value end # :nodoc: def keys : Array(String) @parameters.keys end # Returns `true` if empty. def empty? : Bool @parameters.empty? end # :nodoc: def size : Int32 @parameters.size end # :nodoc: def each(&) : Nil @parameters.each do |key, param| yield key, param.value end end # :nodoc: def dup : self copy = self.class.new @parameters.each do |key, param| copy.@parameters[key] = param end copy end # :nodoc: def clone : self self.dup end def merge!(other : ART::Parameters) : self other.@parameters.each do |key, param| @parameters[key] = param end self end # :nodoc: def merge!(other : ART::Parameters?) : self if other other.@parameters.each do |key, param| @parameters[key] = param end end self end # Returns a `Hash(String, String?)` representation of these parameters. # Values that are not `String?` are converted via `#to_s`. def to_h : Hash(String, String?) @parameters.to_h do |(key, param)| value = param.value {key, value.nil? ? nil : value.to_s} end end # :nodoc: def ==(other : self) : Bool return false unless @parameters.size == other.@parameters.size @parameters.each do |key, param| return false unless other.@parameters[key]?.try { |p| p.value == param.value } end true end # :nodoc: def ==(other : Hash(String, String?)) : Bool self.to_h == other end end ================================================ FILE: src/components/routing/src/request_context.cr ================================================ # Represents data from a request in an agnostic manner, primarily used to augment URL matching and generation with additional context. class Athena::Routing::RequestContext # Represents the path of the URL _before_ `#path`. # E.g. a path that should be prefixed to all other `#path`s. getter base_url : String getter method : String getter path : String getter host : String getter scheme : String getter http_port : Int32 getter https_port : Int32 # Returns the query string of the current request. getter query_string : String # Returns the global parameters that should be used as part of the URL generation logic. getter parameters : Hash(String, String?) = Hash(String, String?).new # Creates a new instance of self from the provided *uri*. # The *host*, *scheme*, *http_port*, and *https_port* optionally act as fallbacks if they are not contained within the *uri*. # # ameba:disable Metrics/CyclomaticComplexity: def self.from_uri(uri : String, host : String = "localhost", scheme : String = "http", http_port : Int32 = 80, https_port : Int32 = 443) : self if (idx = uri.index('\\')) && (i = uri.index(/\?|\#/) || uri.bytesize) && idx < i uri = "" end if (u = uri.presence) && (u[0].ord <= 32 || u[-1].ord <= 32 || ((idx = u.index(/\r|\n|\t/) || u.bytesize) && (u.bytesize != idx))) uri = "" end self.from_uri URI.parse(uri), host, scheme, http_port, https_port end # :ditto: def self.from_uri(uri : URI, host : String = "localhost", scheme : String = "http", http_port : Int32 = 80, https_port : Int32 = 443) : self scheme = uri.scheme || scheme if port = uri.port if "http" == scheme http_port = port elsif "https" == scheme https_port = port end end new( uri.path, "GET", uri.hostname || host, scheme, http_port, https_port ) end def initialize( @base_url : String = "", @method : String = "GET", @host : String = "localhost", @scheme : String = "http", @http_port : Int32 = 80, @https_port : Int32 = 443, @path : String = "/", @query_string : String = "", ) self.method = @method self.host = @host self.scheme = @scheme end # Updates the properties within `self` based on the provided *request*. def apply(request : ART::Request) : self self.method = request.method self.host = if (h = request.hostname) && (h != "localhost") h elsif h = @host h else "localhost" end self.path = request.path self.query_string = request.query || "" # TODO: Support this once it's exposed. # self.scheme = request.scheme self end def base_url=(@base_url : String) : self self end def path=(@path : String) : self self end def method=(method : String) : self @method = method.upcase self end def host=(host : String) : self @host = host.downcase self end def scheme=(scheme : String) : self @scheme = scheme.downcase self end def query_string=(query_string : String?) : self @query_string = query_string.to_s self end def http_port=(@http_port : Int32) : self self end def https_port=(@https_port : Int32) : self self end def parameter(name : String) @parameters[name] end def set_parameter(name : String, value : String?) : self @parameters[name] = value self end def parameters=(@parameters : Hash(String, String?)) : self self end def has_parameter?(name : String) : Bool @parameters.has_key? name end end ================================================ FILE: src/components/routing/src/request_context_aware_interface.cr ================================================ # Represents a type that has access to the current `ART::RequestContext`. module Athena::Routing::RequestContextAwareInterface # Returns the request context. abstract def context : ART::RequestContext # Sets the request context. abstract def context=(context : ART::RequestContext) end ================================================ FILE: src/components/routing/src/requirement/enum.cr ================================================ # Provides an easier way to define a [route requirement][Athena::Routing::Route--parameter-validation] for all, or a subset of, Enum members. # # For example: # ``` # require "athena" # # enum Color # Red # Blue # Green # Black # end # # class ExampleController < ATH::Controller # @[ARTA::Get( # "/color/{color}", # requirements: {"color" => ART::Requirement::Enum(Color).new}, # )] # def get_color(color : Color) : Color # color # end # # @[ARTA::Get( # "/rgb-color/{color}", # requirements: {"color" => ART::Requirement::Enum(Color).new(:red, :green, :blue)}, # )] # def get_rgb_color(color : Color) : Color # color # end # end # # ATH.run # # # GET /color/red # => "red" # # GET /color/pink # => 404 # # # # GET /rgb-color/red # => "red" # # GET /rgb-color/green # => "green" # # GET /rgb-color/blue # => "blue" # # GET /rgb-color/black # => 404 # ``` # # NOTE: This type _ONLY_ supports the string representation of enum members. struct Athena::Routing::Requirement::Enum(EnumType) # Returns the set of allowed enum members, or `nil` if all members are allowed. getter members : Set(EnumType)? = nil def self.new(*cases : EnumType) new cases.to_set end def initialize(@members : Set(EnumType)? = nil) {% unless EnumType <= ::Enum raise "'#{EnumType}' is not an Enum type." end %} end # :nodoc: def to_s(io : IO) : Nil (@members || EnumType.names).join io, '|' do |member, join_io| join_io << Regex.escape member.to_s.underscore end end end ================================================ FILE: src/components/routing/src/requirement/requirement.cr ================================================ # Includes types related to [route requirements][Athena::Routing::Route--parameter-validation]. # # The namespace also exposes various regex constants representing common universal requirements to make using them in routes easier. # # ``` # class ExampleController < ATH::Controller # @[ARTA::Get( # "/user/{id}", # requirements: {"id" => ART::Requirement::DIGITS}, # )] # def get_user(id : Int64) : Int64 # id # end # # @[ARTA::Get( # "/article/{slug}", # requirements: {"slug" => ART::Requirement::ASCII_SLUG}, # )] # def get_article(slug : String) : String # slug # end # end # ``` module Athena::Routing::Requirement # Sourced from https://github.com/symfony/symfony/blob/c70be0957a11fd8b7aa687d6173e76724068daa4/src/Symfony/Component/Routing/Requirement/Requirement.php ASCII_SLUG = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/ CATCH_ALL = /.+/ # Matches a date string in the format of `YYYY-MM-DD`. DATE_YMD = /[0-9]{4}-(?:0[1-9]|1[012])-(?:0[1-9]|[12][0-9]|(?`, instead of providing it as a dedicated argument. # For example, `/blog/{page<\\d+>}` (note we need to escape the `\` within a string literal). # # ``` # routes = ART::RouteCollection.new # routes.add "blog_list", ART::Route.new "/blog/{page}", requirements: {"page" => /\d+/} # routes.add "blog_show", ART::Route.new "/blog/{slug}" # # matcher.match "/blog/foo" # => ART::Parameters{"_route" => "blog_show", "slug" => "foo"} # matcher.match "/blog/10" # => ART::Parameters{"_route" => "blog_list", "page" => "10"} # ``` # # TIP: Checkout `ART::Requirement` for a set of common, helpful requirement regexes. # # ### Optional Parameters # # By default, all parameters are required, meaning given the path `/blog/{page}`, `/blog/10` would match but `/blog` would _NOT_ match. # Parameters can be made optional by providing a default value for the parameter, for example: # # ``` # ART::Route.new "/blog/{page}", {"page" => 1}, {"page" => /\d+/} # # # ... # # matcher.match "/blog" # => ART::Parameters{"_route" => "blog_list", "page" => "1"} # ``` # # CAUTION: More than one parameter may have a default value, but everything after an optional parameter must also be optional. # For example within `/{page}/blog`, `page` will always be required and `/blog` will _NOT_ match. # # `defaults` may also be inlined within the `path` by putting the value after a `?`. # This is also compatible with `requirements`, allowing both to be defined within a path. # For example `/blog/{page<\\d+>?1}`. # # TIP: The default value for a parameter may also be `nil`, with the inline syntax being adding a `?` with no following value, e.g. `{page?}`. # Be sure to update any type restrictions to be nilable as well. # # ### Priority Parameter # # When determining which route should match, the first matching route will win. # For example, if two routes were added with variable parameters in the same location, the first one that was added would match regardless of what their requirements are. # In most cases this will not be a problem, but in some cases you may need to ensure a particular route is checked first. # # ### Special Parameters # # The routing component comes with a few standardized parameters that have special meanings. # These parameters could be leveraged within the underlying implementation, but are not directly used within the routing component other than for matching. # # * `_format` - Could be used to set the underlying format of the request, as well as determining the [content-type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) of the response. # * `_fragment` - Represents the fragment identifier when generating a URL. E.g. `/article/10#summary` with the fragment being `summary`. # * `_locale` - Could be used to set the underlying locale of the `ART::Request` based on which route is matched. # * `_query` - Used to explicitly add [query parameters](/Routing/Generator/Interface/#Athena::Routing::Generator::Interface--query-parameters) to the generated URL. # # ``` # ART::Route.new( # "/articles/{_locale}/search.{_format}", # { # "_locale" => "en", # "_format" => "html", # }, # { # "_locale" => /en|fr/, # "_format" => /html|xml/, # } # ) # ``` # # This route supports `en` and `fr` locales in either `html` or `xml` formats with a default of `en` and `html`. # # TIP: The trailing `.` is optional if the parameter to the right has a default. # E.g. `/articles/en/search` would match with a format of `html` but `/articles/en/search.xml` would be required for matching non-default formats. # # ### Extra Parameters # # The defaults defined within a route do not all need to be present as route parameters. # This could be useful to provide extra context to the controller that should handle the request. # # ``` # ART::Route.new "/blog/{page}", {"page" => 1, "title" => "Hello world!"} # ``` # # ### Slash Characters in Route Parameters # # By default, route parameters may include any value except a `/`, since that's the character used to separate the different portions of the URL. # Route parameter matching logic may be made more permissive by using a more liberal regex, such as `.+`, for example: # # ``` # ART::Route.new "/share/{token}", requirements: {"token" => /.+/} # ``` # # Special parameters should _NOT_ be made more permissive. # For example, if the pattern is `/share/{token}.{_format}` and `{token}` allows any character, the `/share/foo/bar.json` URL will consider `foo/bar.json` as the token and the format will be empty. # This can be solved by replacing the `.+` requirement with `[^.]+` to allow any character except dots. # # Related to this, allowing multiple parameters to accept `/` may also lead to unexpected results. # # ## Sub-Domain Routing # # The `host` property can be used to require the HTTP [host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) header to match this value in order for the route to match. # # ``` # mobile_homepage = ART::Route.new "/", host: "m.example.com" # homepage = ART::Route.new "/" # ``` # # In this example, both routes match the same path, but one requires a specific hostname. # The `host` parameter can also be used as route parameters, including `defaults` and `requirements` support: # # ``` # mobile_homepage = ART::Route.new( # "/", # {"subdomain" => "m"}, # {"subdomain" => /m|mobile/}, # "{subdomain}.example.com" # ) # homepage = ART::Route.new "/" # ``` # # TIP: Inline defaults and requirements also works for `host` values, `"{subdomain?m}.example.com"`. class Athena::Routing::Route # Represents the callback proc used to dynamically determine if a route should be matched. # See [Routing Expressions][Athena::Routing::Route--expressions] for more information. alias Condition = Proc(ART::RequestContext, ART::Request, Bool) # Returns the URL that this route will handle. # See [Routing Parameters][Athena::Routing::Route--parameters] for more information. getter path : String # Returns the default values of a route's parameters if they were not provided in the request. # See [Optional Parameters][Athena::Routing::Route--optional-parameters] for more information. getter defaults : ART::Parameters = ART::Parameters.new # Returns a hash representing the requirements the route's parameters must match in order for this route to match. # See [Parameter Validation][Athena::Routing::Route--parameter-validation] for more information. getter requirements : Hash(String, Regex) = Hash(String, Regex).new # Returns the hostname that the HTTP [host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) header must match in order for this route to match. # See [Sub-Domain Routing][Athena::Routing::Route--sub-domain-routing] for more information. getter host : String? # Returns the set of valid HTTP methods that this route supports. # See `ART::Route` for more information. getter methods : Set(String)? # Returns the optional `ART::Route::Condition` callback used to determine if this route should match. # See [Routing Expressions][Athena::Routing::Route--expressions] for more information. property condition : Condition? = nil # TODO: Don't think we actually know what this is: # Returns the set of valid URI schemes that this route supports. # See `ART::Route` for more information. getter schemes : Set(String)? = nil @compiled_route : ART::CompiledRoute? = nil def initialize( @path : String, defaults : Hash(String, _) | ART::Parameters = Hash(String, String?).new, requirements : Hash(String, Regex | String) = Hash(String, Regex | String).new, host : String | Regex | Nil = nil, methods : String | Enumerable(String) | Nil = nil, schemes : String | Enumerable(String) | Nil = nil, @condition : ART::Route::Condition? = nil, ) self.path = @path self.add_defaults defaults self.add_requirements requirements self.host = host unless host.nil? self.methods = methods unless methods.nil? self.schemes = schemes unless schemes.nil? end # :nodoc: def_equals @path, @defaults, @requirements, @host, @methods, @schemes # :nodoc: def_clone # Sets the optional `ART::Route::Condition` callback used to determine if this route should match. # # ``` # route = ART::Route.new "/foo" # route.condition do |context, request| # request.headers["user-agent"].includes? "Firefox" # end # ``` # # See [Routing Expressions][Athena::Routing::Route--expressions] for more information. def condition(&@condition : ART::RequestContext, ART::Request -> Bool) : self self end # Sets the hostname that the HTTP [host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) header must match in order for this route to match to the provided *pattern*. # See [Sub-Domain Routing][Athena::Routing::Route--sub-domain-routing] for more information. def host=(pattern : String | Regex) : self @host = self.extract_inline_defaults_and_requirements pattern @compiled_route = nil self end # Sets the path required for this route to match to the provided *pattern*. def path=(pattern : String) : self pattern = self.extract_inline_defaults_and_requirements pattern @path = "/#{pattern.strip.lstrip '/'}" @compiled_route = nil self end # Sets the set of valid URI *scheme(s)* that this route supports. # See `ART::Route` for more information. def schemes=(schemes : String | Enumerable(String)) : self schemes = schemes.is_a?(String) ? {schemes} : schemes schemes_set = (@schemes ||= Set(String).new) schemes_set.clear schemes.each { |s| schemes_set << s.downcase } @compiled_route = nil self end # Returns `true` if this route allows the provided *scheme*, otherwise `false`. def has_scheme?(scheme : String) : Bool !!@schemes.try &.includes? scheme.downcase end # Sets the set of valid HTTP *method(s)* that this route supports. # See `ART::Route` for more information. def methods=(methods : String | Enumerable(String)) : self methods = methods.is_a?(String) ? {methods} : methods methods_set = (@methods ||= Set(String).new) methods_set.clear methods.each { |m| methods_set << m.upcase } @compiled_route = nil self end # Compiles and returns an `ART::CompiledRoute` representing this route. # The route is only compiled once and future calls to this method will return the same compiled route, # assuming no changes were made to this route in between. def compile : CompiledRoute @compiled_route ||= ART::RouteCompiler.compile self end # Returns `true` if this route has a default with the provided *key*, otherwise `false`. def has_default?(key : String) : Bool !!@defaults.try &.has_key?(key) end # Returns the default with the provided *key* as a `String`, if any. def default(key : String) : String? @defaults[key]? end # Returns the default with the provided *key* casted to the provided *type*, if any. def default(key : String, type : T.class) : T? forall T @defaults.get?(key, T) end # Sets the default values of a route's parameters if they were not provided in the request to the provided *defaults*. # See [Optional Parameters][Athena::Routing::Route--optional-parameters] for more information. def defaults=(defaults : Hash(String, _)) : self @defaults = ART::Parameters.new self.add_defaults defaults end # :ditto: def defaults=(defaults : ART::Parameters) : self @defaults = ART::Parameters.new self.add_defaults defaults end # Adds the provided *defaults*, overriding previously set values. def add_defaults(defaults : Hash(String, _)) : self if defaults.has_key?("_locale") && self.localized? defaults.delete "_locale" end defaults.each do |key, value| @defaults[key] = value end @compiled_route = nil self end # :ditto: def add_defaults(defaults : ART::Parameters) : self if defaults.has_key?("_locale") && self.localized? defaults.delete "_locale" end defaults.each do |key, value| @defaults[key] = value end @compiled_route = nil self end # Sets the default with the provided *key* to the provided *value*. def set_default(key : String, value) : self if "_locale" == key && self.localized? return self end @defaults[key] = value @compiled_route = nil self end # Returns `true` if this route has a requirement with the provided *key*, otherwise `false`. def has_requirement?(key : String) : Bool !!@requirements.try &.has_key?(key) end # Returns the requirement with the provided *key*, if any. def requirement(key : String) : Regex? @requirements[key]? end # Sets the hash representing the requirements the route's parameters must match in order for this route to match to the provided *requirements*. # See [Parameter Validation][Athena::Routing::Route--parameter-validation] for more information. def requirements=(requirements : Hash(String, Regex | String)) : self @requirements.clear self.add_requirements requirements end # Adds the provided *requirements*, overriding previously set values. def add_requirements(requirements : Hash(String, Regex | String)) : self if requirements.has_key?("_locale") && self.localized? requirements.delete "_locale" end requirements.each do |key, regex| @requirements[key] = self.sanitize_requirement key, regex end @compiled_route = nil self end # Sets the requirement with the provided *key* to the provided *value*. def set_requirement(key : String, requirement : Regex | String) : self if "_locale" == key && self.localized? return self end @requirements[key] = self.sanitize_requirement key, requirement @compiled_route = nil self end private def extract_inline_defaults_and_requirements(pattern : Regex) : String self.extract_inline_defaults_and_requirements pattern.source end private def extract_inline_defaults_and_requirements(pattern : String) : String return pattern if !pattern.includes?('?') && !pattern.includes?('<') pattern.gsub /\{(!?)(\w++)(<.*?>)?(\?[^\}]*+)?\}/ do |_, match| if requirement = match[3]?.presence self.set_requirement match[2], requirement[1...-1] end if match[4]?.presence self.set_default match[2], "?" != match[4] ? match[4][1..] : nil end "{#{match[1]}#{match[2]}}" end end private def sanitize_requirement(key : String, pattern : Regex) : Regex self.sanitize_requirement key, pattern.source end private def sanitize_requirement(key : String, pattern : String) : Regex unless pattern.empty? if p = pattern.lchop? '^' pattern = p elsif p = pattern.lchop? "\\A" pattern = p end end if p = pattern.rchop? '$' pattern = p elsif p = pattern.rchop? "\\z" pattern = p end pattern = "\\\\" if pattern == "\\" raise ArgumentError.new "Routing requirement for '#{key}' cannot be empty." if pattern.empty? Regex.new pattern end private def localized? : Bool return false unless locale = @defaults["_locale"]? @defaults.has_key?("_canonical_route") && self.requirement("_locale").try &.source == Regex.escape(locale) end end ================================================ FILE: src/components/routing/src/route_collection.cr ================================================ # Represents a collection of `ART::Route`s. # Provides a way to traverse, edit, remove, and access the stored routes. # # Each route has an associated name that should be unique. # Adding another route with the same name will override the previous one. # # ## Route Priority # # When determining which route should match, the first matching route will win. # For example, if two routes were added with variable parameters in the same location, the first one that was added would match regardless of what their requirements are. # In most cases this will not be a problem, but in some cases you may need to ensure a particular route is checked first. # # The `priority` argument within `#add` can be used to control this order. class Athena::Routing::RouteCollection include Enumerable({String, Athena::Routing::Route}) include Iterable({String, Athena::Routing::Route}) @routes = Hash(String, ART::Route).new protected getter priorities = Hash(String, Int32).new @sorted : Bool = false # :nodoc: def_clone # TODO: Support route aliases? # Returns the `ART::Action` with the provided *name*. # # Raises a `ART::Exception::RouteNotFound` if a route with the provided *name* does not exist. def [](name : String) : ART::Route self.routes.fetch(name) { raise ART::Exception::RouteNotFound.new "No route with the name '#{name}' exists." } end # Returns the `ART::Action` with the provided *name*, or `nil` if it does not exist. def []?(name : String) : ART::Route? self.routes[name]? end # Adds all the routes from the provided *collection* to this collection. def add(collection : self) : Nil @sorted = false # Remove the routes first so they are added to the end of the routes hash. collection.each do |name, route| self.delete name @routes[name] = route if priority = collection.priorities[name]? @priorities[name] = priority end end end # Adds the provided *route* with the provided *name* to this collection, optionally with the provided *priority*. def add(name : String, route : ART::Route, priority : Int32 = 0) : Nil self.delete name @routes[name] = route @priorities[name] = priority unless priority.zero? end def add_defaults(defaults : Hash(String, _)) : Nil return if defaults.empty? @routes.each_value do |route| route.add_defaults defaults end end # Adds a path *prefix* to all routes stored in this collection. # Optionally allows merging in additional *defaults* or *requirements*. def add_prefix(prefix : String, defaults : Hash(String, _) = Hash(String, String?).new, requirements : Hash(String, String | Regex) = Hash(String, String | Regex).new) : Nil prefix = prefix.strip.rstrip '/' return if prefix.empty? @routes.each_value do |route| route.path = "/#{prefix}#{route.path}" route.add_defaults defaults route.add_requirements requirements end end # Adds the provided *prefix* to the name of all routes stored within this collection. def add_name_prefix(prefix : String) : Nil prefixed_routes = Hash(String, ART::Route).new prefixed_priorities = Hash(String, Int32).new @routes.each do |name, route| prefixed_routes["#{prefix}#{name}"] = route if canonical_route = route.default "_canonical_route" route.set_default "_canonical_route", "#{prefix}#{canonical_route}" end if priority = @priorities[name]? prefixed_priorities["#{prefix}#{name}"] = priority end end # TODO: Support aliases? @routes = prefixed_routes @priorities = prefixed_priorities end # Merges the provided *requirements* into all routes stored within this collection. def add_requirements(requirements : Hash(String, Regex | String)) : Nil return if requirements.empty? @routes.each_value do |route| route.add_requirements requirements end end # Sets the host property of all routes stored in this collection. # Optionally allows merging in additional *defaults* or *requirements*. def set_host(host : String, defaults : Hash(String, _) = Hash(String, String?).new, requirements : Hash(String, String | Regex) = Hash(String, String | Regex).new) : Nil @routes.each_value do |route| route.host = host route.add_defaults defaults route.add_requirements requirements end end # Sets the scheme(s) of all routes stored within this collection. def schemes=(schemes : String | Enumerable(String)) : Nil @routes.each_value do |route| route.schemes = schemes end end # Sets the method(s) of all routes stored within this collection. def methods=(methods : String | Enumerable(String)) : Nil @routes.each_value do |route| route.methods = methods end end # Removes the routes with the provide *names*. def remove(*names : String) : Nil names.each { |n| self.remove n } end # Removes the route with the provide *name*. def remove(name : String) : Nil self.delete name end # Yields the name and `ART::Route` object for each registered route. def each(&) : Nil self.routes.each do |k, v| yield({k, v}) end end # Returns an `Iterator` for each registered route. def each self.routes.each end # Returns the routes stored within this collection. def routes : Hash(String, ART::Route) if !@priorities.empty? && !@sorted insert_order = @routes.keys @routes .to_a .sort! do |(n1, r1), (n2, r2)| priority = (@priorities[n2]? || 0) <=> (@priorities[n1]? || 0) next priority unless priority.zero? insert_order.index!(n1) <=> insert_order.index!(n2) end .tap { @routes.clear } .each { |name, route| @routes[name] = route } @sorted = true end @routes end # Returns the number of routes stored within this collection. def size : Int self.routes.size end private def delete(name : String) : Nil @routes.delete name @priorities.delete name end end ================================================ FILE: src/components/routing/src/route_compiler.cr ================================================ # :nodoc: module Athena::Routing::RouteCompiler private PATH_REGEX = /{(!)?(\w+)}/ private SEPARATORS = "/,;.:-_~+*=@|" private MAX_LENGTH = 32 private record CompiledPattern, static_prefix : String, regex : Regex, tokens : Array(ART::CompiledRoute::Token), variables : Set(String) def self.compile(route : Route) : CompiledRoute host_variables = Set(String).new variables = Set(String).new host_regex = nil host_tokens = Array(ART::CompiledRoute::Token).new if host = route.host.presence pattern = self.compile_pattern route, host, true host_variables = pattern.variables variables = host_variables.dup host_tokens = pattern.tokens host_regex = pattern.regex end if (locale = route.default("_locale", String)) && !route.default("_canonical_route").nil? && route.requirement("_locale").try &.source == Regex.escape(locale) requirements = route.requirements requirements.delete "_locale" # TODO: Pretty sure this deletes via reference route.requirements = requirements route.path = route.path.sub "{_locale}", locale end path = route.path pattern = self.compile_pattern route, path, false path_variables = pattern.variables raise ART::Exception::InvalidArgument.new "Route pattern '#{route.path}' cannot contain '_fragment' as a path parameter." if path_variables.includes? "_fragment" variables.concat path_variables CompiledRoute.new( pattern.static_prefix, pattern.regex, pattern.tokens, path_variables, host_regex, host_tokens, host_variables, variables ) end # ameba:disable Metrics/CyclomaticComplexity private def self.compile_pattern(route : Route, pattern : String, is_host : Bool) pos = 0 variables = Set(String).new tokens = Array(ART::CompiledRoute::Token).new default_separator = is_host ? "." : "/" # Matches and iterates over all variables within `{}`. # match[0] => var name with {} # match[1] => (optional) `!` symbol # match[2] => var name without {} pattern.scan(PATH_REGEX) do |match| is_important = !match[1]?.nil? var_name = match[2] # Static text before the match preceding_text = pattern[pos, match.begin - pos] pos = match.begin + match[0].size preceding_char = preceding_text.empty? ? "" : preceding_text[-1].to_s is_separator = !preceding_char.empty? && SEPARATORS.includes?(preceding_char) raise ART::Exception::InvalidArgument.new "Variable name '#{var_name}' cannot start with a digit in route pattern '#{pattern}'." if var_name.starts_with? /\d/ raise ART::Exception::InvalidArgument.new "Route pattern '#{pattern}' cannot reference variable name '#{var_name}' more than once." unless variables.add? var_name raise ART::Exception::InvalidArgument.new "Variable name '#{var_name}' cannot be longer than #{MAX_LENGTH} characters in route pattern '#{pattern}'." if var_name.size > MAX_LENGTH if is_separator && preceding_text != preceding_char tokens << ART::CompiledRoute::Token.new :text, preceding_text[0...-preceding_char.size] elsif !is_separator && !preceding_text.empty? tokens << ART::CompiledRoute::Token.new :text, preceding_text end if regex = route.requirement var_name regex = self.transform_capturing_groups_to_non_capturings regex.source else following_pattern = pattern[pos..] next_separator = self.find_next_separator following_pattern regex = /[^#{Regex.escape default_separator}#{default_separator != next_separator && "" != next_separator ? Regex.escape(next_separator) : ""}]+/ if (!next_separator.empty? && !following_pattern.matches?(/^\{\w+\}/)) || following_pattern.empty? regex = /#{regex.source}+/ end end tokens << if is_important ART::CompiledRoute::Token.new :variable, is_separator ? preceding_char : "", regex, var_name, true else ART::CompiledRoute::Token.new :variable, is_separator ? preceding_char : "", regex, var_name end end if pos < pattern.size tokens << ART::CompiledRoute::Token.new :text, pattern[pos..] end first_optional_index = Int32::MAX unless is_host idx = tokens.size - 1 while idx >= 0 token = tokens[idx] break if !token.type.variable? || token.important? || !route.has_default?(token.var_name.not_nil!) first_optional_index = idx idx -= 1 end end route_pattern = "" tokens.each_with_index do |_, i| route_pattern += self.compute_regex tokens, i, first_optional_index end route_regex = Regex.new "^#{route_pattern}$", is_host ? Regex::CompileOptions::IGNORE_CASE : Regex::CompileOptions::None # Crystal has UTF-8 regex mode enabled by default, so no need to add it. CompiledPattern.new( self.determine_static_prefix(route, tokens), route_regex, tokens.reverse!, variables ) end private def self.determine_static_prefix(route : Route, tokens : Array(ART::CompiledRoute::Token)) : String first_token = tokens.first unless first_token.type.text? return (route.has_default?(first_token.var_name.not_nil!) || "/" == first_token.prefix) ? "" : first_token.prefix end prefix = first_token.prefix if (second_token = tokens[1]?) && ("/" != second_token.prefix) && !route.has_default?(second_token.var_name.not_nil!) prefix += second_token.prefix end prefix end private def self.find_next_separator(pattern : String) : String return "" if pattern.empty? return "" if (pattern = pattern.gsub(/\{\w+\}/, "")).empty? pattern = pattern[0].to_s SEPARATORS.includes?(pattern) ? pattern : "" end private def self.compute_regex(tokens : Array(ART::CompiledRoute::Token), idx : Int, first_optional_index : Int) : String token = tokens[idx] case token.type in .text? then Regex.escape token.prefix in .variable? if idx.zero? && 0 == first_optional_index "#{Regex.escape token.prefix}(?P<#{token.var_name}>#{token.regex.not_nil!.source})?" else regex = "#{Regex.escape token.prefix}(?P<#{token.var_name}>#{token.regex.not_nil!.source})" if idx >= first_optional_index regex = "(?:#{regex}" num_tokens = tokens.size if idx == num_tokens - 1 regex += ")?" * (num_tokens - first_optional_index - (first_optional_index.zero? ? 1 : 0)) end end regex end end end private def self.transform_capturing_groups_to_non_capturings(source : String) : Regex idx = 0 while idx < source.size if '\\' == source[idx] idx += 2 next end if '(' != source[idx] || source[idx + 2]?.nil? idx += 1 next end if '*' == source[(idx += 1)] || '?' == source[idx] idx += 2 next end source = source.insert idx, "?:" idx += 1 end Regex.new source end end ================================================ FILE: src/components/routing/src/route_provider.cr ================================================ require "./static_prefix_collection" # Stores the compiled route data on the class level for performance reasons. # # This type is default location, but can be extended to support multiple routers using different route collections without affecting one another. # # ``` # class MyCustomProvider < ART::RouteProvider # end # # # ... # # # Compile the provided routes into MyCustomProvider, instead of the default provider. # ART.compile routes, route_provider: MyCustomProvider # ``` class Athena::Routing::RouteProvider private alias Condition = Athena::Routing::Route::Condition # We store this as a tuple in order to get splatting/unpacking features. # defaults, variables, methods, schemas, trailing slash?, trailing var?, conditions # # :nodoc: alias DynamicRouteData = Tuple(ART::Parameters, Set(String)?, Set(String)?, Set(String)?, Bool, Bool, Int32?) # We store this as a tuple in order to get splatting/unpacking features. # defaults, host, methods, schemas, trailing slash?, trailing var?, conditions # # :nodoc: alias StaticRouteData = Tuple(ART::Parameters, String | Regex | Nil, Set(String)?, Set(String)?, Bool, Bool, Int32?) # We store this as a tuple in order to get splatting/unpacking features. # variables, defaults, requirements, tokens, host tokens, schemes # # :nodoc: alias RouteGenerationData = Tuple(Set(String), ART::Parameters, Hash(String, Regex), Array(ART::CompiledRoute::Token), Array(ART::CompiledRoute::Token), Set(String)?) private record PreCompiledStaticRoute, route : ART::Route, has_trailing_slash : Bool private record PreCompiledDynamicRegex, host_regex : Regex?, regex : Regex, static_prefix : String private record PreCompiledDynamicRoute, pattern : String, routes : ART::RouteCollection private class State property vars : Set(String) = Set(String).new property host_vars : Set(String) = Set(String).new property mark : Int32 = 0 property mark_tail : Int32 = 0 getter routes : Hash(String, Array(DynamicRouteData)) property regex : String = "" def initialize(@routes : Hash(String, Array(DynamicRouteData))); end def vars(subject : String, regex : Regex) : String subject.gsub(regex) do |_, match| next "?:" if "_route" == match[1] @vars << match[1].to_s "" end end end protected class_getter? match_host : Bool = false protected class_getter static_routes : Hash(String, Array(StaticRouteData)) = Hash(String, Array(StaticRouteData)).new protected class_getter route_regexes : Hash(Int32, Regex) = Hash(Int32, Regex).new protected class_getter dynamic_routes : Hash(String, Array(DynamicRouteData)) = Hash(String, Array(DynamicRouteData)).new protected class_getter conditions : Hash(Int32, Condition) = Hash(Int32, Condition).new protected class_getter route_generation_data : Hash(String, RouteGenerationData) = Hash(String, RouteGenerationData).new protected class_getter? compiled : Bool = false @@routes : ART::RouteCollection? = nil def self.compile(routes : ART::RouteCollection) : Nil return if @@compiled @@routes = routes self.compile end # :nodoc: def self.inspect(io : IO) : Nil io << "Match Host: " @@match_host.inspect io io << "\n\nStatic Routes: " @@static_routes.inspect io io << "\n\nRegexes: " @@route_regexes.inspect io io << "\n\nDynamic Routes: " @@dynamic_routes.inspect io io << "\n\nConditions: " @@conditions.inspect io io << "\n\nRoute Generation Data: " @@route_generation_data.inspect io io << "\n\n" end protected def self.reset : Nil @@match_host = false @@static_routes.clear @@dynamic_routes.clear @@route_regexes.clear @@conditions.clear @@route_generation_data.clear @@compiled = false @@routes = nil end private def self.compile : Nil match_host = false routes = ART::RouteProvider::StaticPrefixCollection.new self.routes.each do |name, route| if host = route.host match_host = true host = %(/#{host.reverse.tr "}.{", "(/)"}) end routes.add_route (host || "/(.*)"), ART::RouteProvider::StaticPrefixCollection::StaticTreeNamedRoute.new name, route end if match_host @@match_host = true routes = routes.populate_collection ART::RouteCollection.new else @@match_host = false routes = self.routes end static_routes, dynamic_routes = self.group_static_routes routes conditions = Array(Condition).new self.compile_static_routes static_routes, conditions chunk_limit = dynamic_routes.size loop do self.compile_dynamic_routes dynamic_routes, match_host, chunk_limit, conditions break rescue e : ArgumentError if 1 < chunk_limit && e.message.try(&.starts_with?("regular expression is too large")) chunk_limit = 1 + (chunk_limit >> 1) next end raise e end self.routes.each do |name, route| compiled_route = route.compile @@route_generation_data[name] = { compiled_route.variables, route.defaults, route.requirements, compiled_route.tokens, compiled_route.host_tokens, route.schemes, } end @@compiled = true @@routes = nil end # ameba:disable Metrics/CyclomaticComplexity private def self.compile_dynamic_routes(collection : ART::RouteCollection, match_host : Bool, chunk_limit : Int, conditions : Array(Condition)) : Nil dr = Hash(String, Array(DynamicRouteData)).new if collection.empty? return @@dynamic_routes = dr end state = State.new dr chunk_size = 0 routes = nil collections = Array(ART::RouteCollection).new collection.each do |name, route| if chunk_limit < (chunk_size += 1) || routes.nil? chunk_size = 1 routes = ART::RouteCollection.new collections << routes end routes.not_nil!.add name, route end collections.each do |sub_collection| previous_regex = false per_host_routes = Array(Tuple(Regex?, ART::RouteCollection)).new host_routes = nil sub_collection.each do |name, route| regex = route.compile.host_regex if previous_regex != regex host_routes = ART::RouteCollection.new per_host_routes << {regex, host_routes} previous_regex = regex end host_routes.not_nil!.add name, route end previous_regex = false final_regex = "^(?" starting_mark = state.mark state.mark += final_regex.size + 1 # Add 1 to account for the eventual `/`. state.regex = final_regex per_host_routes.each do |host_regex, sub_routes| if match_host if host_regex host_regex.source.match(/^\^(.*)\$$/).try do |match| state.vars = Set(String).new host_regex = state.vars match[1], /\?P<([^>]++)>/ host_regex = Regex.new "(?i:#{host_regex})\\." state.host_vars = state.vars end else host_regex = /(?:(?:[^.\/]*+\.)++)/ state.host_vars = Set(String).new end pattern = %<#{previous_regex ? ")" : ""}|#{host_regex.source}(?> state.mark += pattern.size state.regex += pattern previous_regex = true end tree = ART::RouteProvider::StaticPrefixCollection.new sub_routes.each do |name, route| matched_regex = route.compile.regex.source.match!(/\^(.*)\$$/) state.vars = Set(String).new pattern = state.vars matched_regex[1], /\?P<([^>]++)>/ if has_trailing_slash = "/" != pattern && pattern.ends_with? '/' pattern = pattern.rchop '/' end has_trailing_var = route.path.matches? /\{\w+\}\/?$/ tree.add_route pattern, ART::RouteProvider::StaticPrefixCollection::StaticPrefixTreeRoute.new name, pattern, state.vars, route, has_trailing_slash, has_trailing_var end self.compile_static_prefix_collection tree, state, 0, conditions end if match_host state.regex += ")" end state.regex += ")/?$" state.mark_tail = 0 @@route_regexes[starting_mark] = ART.create_regex state.regex end @@dynamic_routes = state.routes end private def self.compile_static_prefix_collection(tree : ART::RouteProvider::StaticPrefixCollection, state : State, prefix_length : Int32, conditions) : Nil previous_regex = nil tree.items.each do |item| case item in ART::RouteProvider::StaticPrefixCollection previous_regex = nil prefix = item.prefix[prefix_length..] pattern = "|#{prefix}(?" state.mark += pattern.size state.regex += pattern self.compile_static_prefix_collection item, state, prefix_length + prefix.size, conditions state.regex += ")" state.mark_tail += 1 next in ART::RouteProvider::StaticPrefixCollection::StaticPrefixTreeRoute compiled_route = item.route.compile vars = state.host_vars + item.variables if compiled_route.regex == previous_regex state.routes[state.mark.to_s] << self.compile_dynamic_route item.route, item.name, vars, item.has_trailing_slash, item.has_trailing_var, conditions next end state.mark += 3 + state.mark_tail + item.pattern.size - prefix_length state.mark_tail = 2 + state.mark.digits.size state.regex += "|#{item.pattern[prefix_length..]}(*:#{state.mark})" previous_regex = compiled_route.regex state.routes[state.mark.to_s] = [self.compile_dynamic_route item.route, item.name, vars, item.has_trailing_slash, item.has_trailing_var, conditions] of DynamicRouteData in ART::RouteProvider::StaticPrefixCollection::StaticTreeNamedRoute raise "BUG: StaticTreeNamedRoute" in ART::RouteProvider::StaticPrefixCollection::StaticTreeName raise "BUG: StaticTreeName" end end end private alias StaticRoutes = Hash(String, Hash(String, PreCompiledStaticRoute)) private def self.compile_static_routes(static_routes : StaticRoutes, conditions : Array(Condition)) : Nil return if static_routes.empty? sr = Hash(String, Array(StaticRouteData)).new static_routes.each do |url, routes| sr[url] = Array(StaticRouteData).new routes.size routes.each do |name, pre_compiled_route| route = pre_compiled_route.route host = if route.compile.host_variables.empty? route.host elsif regex = route.compile.host_regex regex end sr[url] << self.compile_static_route( route, name, host, pre_compiled_route.has_trailing_slash, false, conditions ) end end @@static_routes = sr end private def self.compile_dynamic_route(route : ART::Route, name : String, vars : Set(String)?, has_trailing_slash : Bool, has_trailing_var : Bool, conditions : Array(Condition)) : DynamicRouteData defaults = route.defaults.dup if canonical_route = defaults["_canonical_route"]? name = canonical_route defaults.delete "_canonical_route" end if condition = route.condition @@conditions[condition_key = 1 * @@conditions.size] = condition end { ART::Parameters.new({"_route" => name}).merge!(defaults), vars, route.methods, route.schemes, has_trailing_slash, has_trailing_var, condition_key, } end private def self.compile_static_route(route : ART::Route, name : String, host : String | Regex | Nil, has_trailing_slash : Bool, has_trailing_var : Bool, conditions : Array(Condition)) : StaticRouteData defaults = route.defaults.dup if canonical_route = defaults["_canonical_route"]? name = canonical_route defaults.delete "_canonical_route" end if condition = route.condition @@conditions[condition_key = 1 * @@conditions.size] = condition end { ART::Parameters.new({"_route" => name}).merge!(defaults), host, route.methods, route.schemes, has_trailing_slash, has_trailing_var, condition_key, } end # ameba:disable Metrics/CyclomaticComplexity private def self.group_static_routes(routes : ART::RouteCollection) : Tuple(StaticRoutes, ART::RouteCollection) static_routes = Hash(String, Hash(String, PreCompiledStaticRoute)).new { |hash, key| hash[key] = Hash(String, PreCompiledStaticRoute).new } dynamic_regex = Array(PreCompiledDynamicRegex).new dynamic_routes = ART::RouteCollection.new routes.each do |name, route| compiled_route = route.compile static_prefix = compiled_route.static_prefix.rstrip '/' host_regex = compiled_route.host_regex regex = compiled_route.regex has_trailing_slash = "/" != route.path if has_trailing_slash pos = regex.source.index!('$') has_trailing_slash = '/' == regex.source[pos - 1] regex = Regex.new regex.source.sub (1 + pos - (has_trailing_slash ? 1 : 0))..-((has_trailing_slash ? 1 : 0)), "/?$" end if compiled_route.path_variables.empty? host = compiled_route.host_variables.empty? ? "" : route.host url = route.path if has_trailing_slash url = url.rstrip '/' end should_next = dynamic_regex.each do |dr| host_regex_matches = host ? dr.host_regex.try &.matches?(host) : false if (dr.static_prefix.empty? || url.starts_with?(dr.static_prefix)) && (dr.regex.matches?(url) || dr.regex.matches?("#{url}/")) && (host.presence.nil? || host_regex.nil? || host_regex_matches) dynamic_regex << PreCompiledDynamicRegex.new host_regex, regex, static_prefix dynamic_routes.add name, route break true end end next if should_next static_routes[url][name] = PreCompiledStaticRoute.new route, has_trailing_slash else dynamic_regex << PreCompiledDynamicRegex.new host_regex, regex, static_prefix dynamic_routes.add name, route end end {static_routes, dynamic_routes} end private def self.routes : ART::RouteCollection @@routes || raise "Routes have not been compiled. Did you forget to call `ART.compile` for #{self.class}?" end private def initialize; end end ================================================ FILE: src/components/routing/src/router.cr ================================================ require "./router_interface" require "./matcher/request_matcher_interface" class Athena::Routing::Router include Athena::Routing::RouterInterface include Athena::Routing::Matcher::RequestMatcherInterface # :inherit: getter route_collection : ART::RouteCollection # :inherit: getter context : ART::RequestContext # TODO: Should the matcher/generator types be customizable? getter matcher : ART::Matcher::URLMatcherInterface do ART::Matcher::URLMatcher.new(@context, @route_provider) end getter generator : ART::Generator::Interface do generator = ART::Generator::URLGenerator.new @context, @default_locale, @route_provider generator.strict_requirements = @strict_requirements generator end def initialize( @route_collection : ART::RouteCollection, @default_locale : String? = nil, @strict_requirements : Bool? = true, context : ART::RequestContext? = nil, @route_provider : ART::RouteProvider.class = ART::RouteProvider, ) @context = context || ART::RequestContext.new end # :inherit: def generate(route : String, reference_type : ART::Generator::ReferenceType = :absolute_path, **params) : String self.generate route, params.to_h.transform_keys(&.to_s), reference_type end # :inherit: def generate(route : String, params : Hash = Hash(String, String?).new, reference_type : ART::Generator::ReferenceType = :absolute_path) : String self.generator.generate route, params, reference_type end # :inherit: def match(path : String) : ART::Parameters self.matcher.match path end # :inherit: def match(request : ART::Request) : ART::Parameters matcher = self.matcher unless matcher.is_a? ART::Matcher::RequestMatcherInterface return matcher.match request.path end matcher.match request end # :inherit: def match?(path : String) : ART::Parameters? self.matcher.match? path end # :inherit: def match?(request : ART::Request) : ART::Parameters? matcher = self.matcher unless matcher.is_a? ART::Matcher::RequestMatcherInterface return matcher.match? request.path end matcher.match? request end # :inherit: def context=(@context : ART::RequestContext) if matcher = @matcher matcher.context = context end if generator = @generator generator.context = context end end end ================================================ FILE: src/components/routing/src/router_interface.cr ================================================ require "./matcher/url_matcher_interface" require "./generator/interface" module Athena::Routing::RouterInterface include Athena::Routing::Matcher::URLMatcherInterface include Athena::Routing::Generator::Interface abstract def route_collection : ART::RouteCollection end ================================================ FILE: src/components/routing/src/routing_handler.cr ================================================ require "log" # Provides basic routing functionality to an [HTTP::Server](https://crystal-lang.org/api/HTTP/Server.html). # # This type works as both a [HTTP::Handler](https://crystal-lang.org/api/HTTP/Handler.html) and # an `ART::RouteCollection` that accepts a block that will handle that particular route. # # ``` # handler = ART::RoutingHandler.new # # # The `methods` property can be used to limit the route to a particular HTTP method. # handler.add "new_article", ART::Route.new("/article", methods: "post") do |ctx| # pp ctx.request.body.try &.gets_to_end # end # # # The match parameters from the route are passed to the callback as an `ART::Parameters`. # handler.add "article", ART::Route.new("/article/{id<\\d+>}", methods: "get") do |ctx, params| # pp params # => ART::Parameters{"_route" => "article", "id" => "10"} # end # # # Call the `#compile` method when providing the handler to the handler array. # server = HTTP::Server.new([ # handler.compile, # ]) # # address = server.bind_tcp 8080 # puts "Listening on http://#{address}" # server.listen # ``` # # NOTE: This handler should be the last one, as it is terminal. # # ## Bubbling Exceptions # # By default, requests that result in an exception, either from `Athena::Routing` or the callback block itself, # are gracefully handled by returning a proper error response to the client via [HTTP::Server::Response#respond_with_status](https://crystal-lang.org/api/HTTP/Server/Response.html#respond_with_status%28status%3AHTTP%3A%3AStatus%2Cmessage%3AString%3F%3Dnil%29%3ANil-instance-method). # # You can set `bubble_exceptions: true` when instantiating the routing handler to have full control over the returned response. # This would allow you to define your own [HTTP::Handler](https://crystal-lang.org/api/HTTP/Handler.html) that can rescue the exceptions and apply your custom logic for how to handle the error. # # ``` # class ErrorHandler # include HTTP::Handler # # def call(context) # call_next context # rescue ex # # Do something based on the ex, such as rendering the appropriate template, etc. # end # end # # handler = ART::RoutingHandler.new bubble_exceptions: true # # # Add the routes... # # # Have the `ErrorHandler` run _before_ the routing handler. # server = HTTP::Server.new([ # ErrorHandler.new, # handler.compile, # ]) # # address = server.bind_tcp 8080 # puts "Listening on http://#{address}" # server.listen # ``` class Athena::Routing::RoutingHandler include ::HTTP::Handler private LOG = Log.for "athena.routing" # :nodoc: class RouteProvider < ::Athena::Routing::RouteProvider end @handlers : Hash(String, Proc(::HTTP::Server::Context, ART::Parameters, Nil)) = {} of String => ::HTTP::Server::Context, ART::Parameters -> Nil # :nodoc: forward_missing_to @collection @collection : ART::RouteCollection def initialize( matcher : ART::Matcher::URLMatcherInterface? = nil, @collection : ART::RouteCollection = ART::RouteCollection.new, @bubble_exceptions : Bool = false, ) @matcher = matcher || ART::Matcher::URLMatcher.new ART::RequestContext.new, RouteProvider end # :inherit: def call(context) request : ART::Request {% if @top_level.has_constant?("Athena") && Athena.has_constant?("HTTP") && Athena::HTTP.has_constant?("Request") %} request = AHTTP::Request.new context.request {% else %} request = context.request {% end %} @matcher.context.apply request begin parameters = if @matcher.is_a? ART::Matcher::RequestMatcherInterface @matcher.match request else @matcher.match request.path end rescue ex : ART::Exception::ResourceNotFound raise ex if @bubble_exceptions return context.response.respond_with_status(:not_found) rescue ex : ART::Exception::MethodNotAllowed raise ex if @bubble_exceptions return context.response.respond_with_status(:method_not_allowed) end begin @handlers[parameters["_route"]].call context, parameters rescue ex : ::Exception raise ex if @bubble_exceptions LOG.error(exception: ex) { "Unhandled exception" } context.response.respond_with_status(:internal_server_error) end end # :nodoc: def add(collection : ART::RouteCollection) : NoReturn raise ArgumentError.new "Cannot add an existing collection to a routing handler." end # Adds the provided *route* with the provided *name* to this collection, optionally with the provided *priority*. # The passed *block* will be called when a request matching this route is encountered. def add(name : String, route : ART::Route, priority : Int32 = 0, &block : ::HTTP::Server::Context, ART::Parameters -> Nil) : Nil @handlers[name] = block @collection.add name, route, priority end # Helper method that calls `ART.compile` with the internal `ART::RouteCollection`, # and returns `self` to make setting up the routes easier. # # ``` # handler = ART::RoutingHandler.new # # # Register routes # # server = HTTP::Server.new([ # handler.compile, # ]) # ``` def compile : self ART.compile @collection, route_provider: RouteProvider self end end ================================================ FILE: src/components/routing/src/static_prefix_collection.cr ================================================ class Athena::Routing::RouteProvider; end # :nodoc: class Athena::Routing::RouteProvider::StaticPrefixCollection # :nodoc: # # name, regex pattern, variables, route, trailing slash?, trailing var? record StaticPrefixTreeRoute, name : String, pattern : String, variables : Set(String), route : ART::Route, has_trailing_slash : Bool, has_trailing_var : Bool # :nodoc: record StaticTreeNamedRoute, name : String, route : ART::Route record StaticTreeName, name : String private alias RouteInfo = Array(StaticTreeNamedRoute | StaticPrefixTreeRoute | StaticTreeName | self) protected def self.handle_error?(message : String) : Bool message.starts_with?("lookbehind assertion is not fixed length") || message.starts_with?("length of lookbehind assertion is not limited") end getter prefix : String getter items : RouteInfo = RouteInfo.new protected setter items : RouteInfo protected getter static_prefixes = Array(String).new protected getter prefixes = Array(String).new def initialize(@prefix : String = "/"); end # ameba:disable Metrics/CyclomaticComplexity def add_route(prefix : String, route : StaticTreeNamedRoute | StaticPrefixTreeRoute | StaticTreeName | self) : Nil prefix, static_prefix = self.common_prefix prefix, prefix idx = @items.size - 1 while 0 <= idx item = @items[idx] common_prefix, common_static_prefix = self.common_prefix prefix, @prefixes[idx] if @prefix == common_prefix if @prefix != static_prefix && @prefix != @static_prefixes[idx] idx -= 1 next end break if @prefix == static_prefix && @prefix == @static_prefixes[idx] break if @prefixes[idx] != @static_prefixes[idx] && @prefix == @static_prefixes[idx] break if prefix != static_prefix && @prefix == static_prefix idx -= 1 next end if item.is_a? self && @prefixes[idx] == common_prefix item.add_route prefix, route else child = self.class.new common_prefix common_child_prefix, common_child_static_prefix = child.common_prefix @prefixes[idx], @prefixes[idx] child.prefixes << common_child_prefix child.static_prefixes << common_child_static_prefix common_child_prefix, common_child_static_prefix = child.common_prefix prefix, prefix child.prefixes << common_child_prefix child.static_prefixes << common_child_static_prefix child.items << @items[idx] child.items << route @static_prefixes[idx] = common_static_prefix @prefixes[idx] = common_prefix @items[idx] = child end return end @static_prefixes << static_prefix @prefixes << prefix @items << route end def populate_collection(routes : ART::RouteCollection) : ART::RouteCollection @items.each do |item| case item in ART::RouteProvider::StaticPrefixCollection then item.populate_collection routes in StaticTreeNamedRoute then routes.add item.name, item.route in StaticPrefixTreeRoute, StaticTreeName # Skip end end routes end # ameba:disable Metrics/CyclomaticComplexity protected def common_prefix(prefix : String, other_prefix : String) : Tuple(String, String) base_length = @prefix.size end_size = Math.min(prefix.size, other_prefix.size) static_length = nil idx = base_length begin while idx < end_size && prefix[idx] == other_prefix[idx] if '(' == prefix[idx] static_length = static_length || idx jdx = 1 + idx n = 1 should_break = while jdx < end_size && 0 < n break true if prefix[jdx] != other_prefix[jdx] if '(' == prefix[jdx] n += 1 elsif ')' == prefix[jdx] n -= 1 elsif '\\' == prefix[jdx] && ((jdx += 1) == end_size || prefix[jdx] != other_prefix[jdx]) jdx -= 1 break false end jdx += 1 end break if should_break break if 0 < n break if ('?' == (prefix[jdx]? || "") || '?' == (other_prefix[jdx]? || "")) && ((prefix[jdx]? || "") != (other_prefix[jdx]? || "")) sub_pattern = prefix[idx, jdx - idx] break if prefix != other_prefix && !sub_pattern.matches?(/^\(\[[^\]]++\]\+\+\)$/) && !"".matches?(/(? ### Removed - Remove `ASR::PropertyMetadata#class` method and generic variable ([#672]) (George Dietrich) [0.4.3]: https://github.com/athena-framework/serializer/releases/tag/v0.4.3 [#646]: https://github.com/athena-framework/athena/pull/646 [#672]: https://github.com/athena-framework/athena/pull/672 ## [0.4.2] - 2025-08-12 ### Fixed - Fix nightly type incompatibility with `ASR::Any` ([#562]) (George Dietrich) [0.4.2]: https://github.com/athena-framework/serializer/releases/tag/v0.4.2 [#562]: https://github.com/athena-framework/athena/pull/562 ## [0.4.1] - 2025-02-08 ### Fixed - Fix serialization of value when its type is different type than the ivar ([#514]) (George Dietrich) [0.4.1]: https://github.com/athena-framework/serializer/releases/tag/v0.4.1 [#514]: https://github.com/athena-framework/athena/pull/514 ## [0.4.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) - Update minimum `crystal` version to `~> 1.13.0` ([#428]) (George Dietrich) [0.4.0]: https://github.com/athena-framework/serializer/releases/tag/v0.4.0 [#428]: https://github.com/athena-framework/athena/pull/428 ## [0.3.6] - 2024-04-27 ### Fixed - Fix misnamed modules being defined in incorrect namespace ([#402]) (George Dietrich) [0.3.6]: https://github.com/athena-framework/serializer/releases/tag/v0.3.6 [#402]: https://github.com/athena-framework/athena/pull/402 ## [0.3.5] - 2024-04-09 ### Changed - Change `Config` dependency to `DependencyInjection` for the custom annotation feature ([#392]) (George Dietrich) - Integrate website into monorepo ([#365]) (George Dietrich) [0.3.5]: https://github.com/athena-framework/serializer/releases/tag/v0.3.5 [#392]: https://github.com/athena-framework/athena/pull/392 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.3.4] - 2023-10-09 _Administrative release, no functional changes_ [0.3.4]: https://github.com/athena-framework/serializer/releases/tag/v0.3.4 ## [0.3.3] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) [0.3.3]: https://github.com/athena-framework/serializer/releases/tag/v0.3.3 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.3.2] - 2023-01-07 ### Fixed - Fix deserializing `JSON::Any` and `YAML::Any` ([#215]) (George Dietrich) [0.3.2]: https://github.com/athena-framework/serializer/releases/tag/v0.3.2 [#215]: https://github.com/athena-framework/athena/pull/215 ## [0.3.1] - 2022-09-05 ### Changed - **Breaking:** ensure parameter names defined on interfaces match the implementation ([#188]) (George Dietrich) [0.3.1]: https://github.com/athena-framework/serializer/releases/tag/v0.3.1 [#188]: https://github.com/athena-framework/athena/pull/188 ## [0.3.0] - 2022-05-14 _First release a part of the monorepo._ ### Added - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Changed - **Breaking:** change serialization of [Enums](https://crystal-lang.org/api/Enum.html) to underscored strings by default ([#173]) (George Dietrich) - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Fixed - Fix compiler error when trying to deserialize a `Hash` ([#165]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/serializer/releases/tag/v0.3.0 [#165]: https://github.com/athena-framework/athena/pull/165 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 [#173]: https://github.com/athena-framework/athena/pull/173 ## [0.2.10] - 2021-11-12 ### Fixed - Fix issue with empty YAML input ([#22]) (George Dietrich) [0.2.10]: https://github.com/athena-framework/serializer/releases/tag/v0.2.10 [#22]: https://github.com/athena-framework/serializer/pull/22 ## [0.2.9] - 2021-10-30 ### Added - Add `VERSION` constant to `Athena::Serializer` namespace ([#20]) (George Dietrich) ### Fixed - Fix broken type link ([#19]) (George Dietrich) [0.2.9]: https://github.com/athena-framework/serializer/releases/tag/v0.2.9 [#19]: https://github.com/athena-framework/serializer/pull/19 [#20]: https://github.com/athena-framework/serializer/pull/20 ## [0.2.8] - 2021-05-17 ### Fixed - Fixes incorrect `nil` check in macro logic ([#17]) (George Dietrich) [0.2.8]: https://github.com/athena-framework/serializer/releases/tag/v0.2.8 [#17]: https://github.com/athena-framework/serializer/pull/17 ## [0.2.7] - 2021-04-09 ### Added - Add some more specialized exception types ([#16]) (George Dietrich) [0.2.7]: https://github.com/athena-framework/serializer/releases/tag/v0.2.7 [#16]: https://github.com/athena-framework/serializer/pull/16 ## [0.2.6] - 2021-03-16 ### Added - Expose a setter for `ASR::Context#version=` ([#15]) (George Dietrich) ### Changed - Change `athena-framework/config` version constraint to `>= 2.0.0` ([#15]) (George Dietrich) [0.2.6]: https://github.com/athena-framework/serializer/releases/tag/v0.2.6 [#15]: https://github.com/athena-framework/serializer/pull/15 ## [0.2.5] - 2021-01-29 ### Changed - Migrate documentation to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#14]) (George Dietrich) [0.2.5]: https://github.com/athena-framework/serializer/releases/tag/v0.2.5 [#14]: https://github.com/athena-framework/serializer/pull/14 ## [0.2.4] - 2021-01-29 ### Changed - Bump min `athena-framework/config` version to `~> 2.0.0` ([#13]) (George Dietrich) [0.2.4]: https://github.com/athena-framework/serializer/releases/tag/v0.2.4 [#13]: https://github.com/athena-framework/serializer/pull/13 ## [0.2.3] - 2021-01-20 ### Fixed - Fix since/until and group annotations not working for virtual properties ([#12]) (George Dietrich) [0.2.3]: https://github.com/athena-framework/serializer/releases/tag/v0.2.3 [#12]: https://github.com/athena-framework/serializer/pull/12 ## [0.2.2] - 2020-12-03 ### Changed - Update `crystal` version to allow version greater than `1.0.0` ([#11]) (George Dietrich) [0.2.2]: https://github.com/athena-framework/serializer/releases/tag/v0.2.2 [#11]: https://github.com/athena-framework/serializer/pull/11 ## [0.2.1] - 2020-11-08 ### Added - Add deserialization support to `ASRA::Name` ([#9]) (Joakim Repomaa) [0.2.1]: https://github.com/athena-framework/serializer/releases/tag/v0.2.1 [#9]: https://github.com/athena-framework/serializer/pull/9 ## [0.2.0] - 2020-07-08 ### Added - Add dependency on `athena-framework/config` ([#8]) (George Dietrich) - Add ability to use custom annotations within [exclusion strategies](https://athenaframework.org/Serializer/ExclusionStrategies/ExclusionStrategyInterface/#Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface--annotation-configurations) ([#8]) (George Dietrich) - Add [ASR::Context#direction](https://athenaframework.org/Serializer/Context/#Athena::Serializer::Context#direction) to represent which direction the context object represents ([#8]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/serializer/releases/tag/v0.2.0 [#8]: https://github.com/athena-framework/serializer/pull/8 ## [0.1.3] - 2020-07-08 ### Fixed - Fix overflow error when deserializing `Int64` values ([#7]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/serializer/releases/tag/v0.1.3 [#7]: https://github.com/athena-framework/serializer/pull/7 ## [0.1.2] - 2020-07-05 ### Added - Add improved documentation to various types ([#6]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/serializer/releases/tag/v0.1.2 [#6]: https://github.com/athena-framework/serializer/pull/6 ## [0.1.1] - 2020-06-27 ### Added - Add [naming strategies](https://athenaframework.org/Serializer/Annotations/Name/#Athena::Serializer::Annotations::Name--naming-strategies) to `ASRA::Name` ([#5]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/serializer/releases/tag/v0.1.1 [#5]: https://github.com/athena-framework/serializer/pull/5 ## [0.1.0] - 2020-06-23 _Initial release._ [0.1.0]: https://github.com/athena-framework/serializer/releases/tag/v0.1.0 ================================================ FILE: src/components/serializer/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/serializer/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2020 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/serializer/README.md ================================================ # Serializer [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/serializer.svg)](https://github.com/athena-framework/serializer/releases) Flexible object (de)serialization library ## Getting Started Checkout the [Documentation](https://athenaframework.org/Serializer). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/serializer/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.4.0 ### Normalization of Exception types The namespace exception types live in has changed from `ASR::Exceptions` to `ASR::Exception`. Any usages of `serializer` exception types will need to be updated. Some additional types have also been removed/renamed: * `ASR::Exceptions::SerializerException` has been removed in favor of using `ASR::Exception` directly If using a `rescue` statement with a parent exception type, either from the `serializer` component or Crystal stdlib, double check it to ensure it'll still rescue what you are expecting it will. ================================================ FILE: src/components/serializer/docs/README.md ================================================ The `Athena::Serializer` component provides enhanced (de)serialization features, with most leveraging [annotations](https://crystal-lang.org/reference/syntax_and_semantics/annotations/index.html). ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-serializer: github: athena-framework/serializer version: ~> 0.4.0 ``` ## Usage The `Athena::Serializer` component focuses around [ASR::Serializer](/Serializer/Serializer/) implementations, with the default being [ASR::Serializer] as the main entrypoint into (de)serializing objects. Usage wise, the component functions much like the `*::Serializable` modules in the stdlib, such as [JSON::Serializable](https://crystal-lang.org/api/JSON/Serializable.html). The [ASR::Serializable](/Serializer/Serializable/) module can be included into a type to make it (de)serializable. From here various [annotations](/Serializer/Annotations/) may be used to control how the object is (de)serialized. ```crystal # ExclusionPolicy specifies that all properties should not be (de)serialized # unless exposed via the `ASRA::Expose` annotation. @[ASRA::ExclusionPolicy(:all)] @[ASRA::AccessorOrder(:alphabetical)] class Example include ASR::Serializable # Groups can be used to create different "views" of a type. @[ASRA::Expose] @[ASRA::Groups("details")] property name : String # The `ASRA::Name` controls the name that this property # should be deserialized from or be serialized to. # It can also be used to set the default serialized naming strategy on the type. @[ASRA::Expose] @[ASRA::Name(deserialize: "a_prop", serialize: "a_prop")] property some_prop : String # Define a custom accessor used to get the value for serialization. @[ASRA::Expose] @[ASRA::Groups("default", "details")] @[ASRA::Accessor(getter: get_title)] property title : String # ReadOnly properties cannot be set on deserialization @[ASRA::Expose] @[ASRA::ReadOnly] property created_at : Time = Time.utc # Allows the property to be set via deserialization, # but not exposed when serialized. @[ASRA::IgnoreOnSerialize] property password : String? # Because of the `:all` exclusion policy, and not having the `ASRA::Expose` annotation, # these properties are not exposed. getter first_name : String? getter last_name : String? # Runs directly after `self` is deserialized @[ASRA::PostDeserialize] def split_name : Nil @first_name, @last_name = @name.split(' ') end # Allows using the return value of a method as a key/value in the serialized output. @[ASRA::VirtualProperty] def get_val : String "VAL" end private def get_title : String @title.downcase end end obj = ASR.serializer.deserialize Example, %({"name":"FIRST LAST","a_prop":"STR","title":"TITLE","password":"monkey123","created_at":"2020-10-10T12:34:56Z"}), :json obj # => # ASR.serializer.serialize obj, :json # => {"a_prop":"STR","created_at":"2020-07-05T23:06:58.94Z","get_val":"VAL","name":"FIRST LAST","title":"title"} ASR.serializer.serialize obj, :json, ASR::SerializationContext.new.groups = ["details"] # => {"name":"FIRST LAST","title":"title"} ``` ## Learn More * Customize how objects are [constructed](/Serializer/ObjectConstructorInterface/) * Make use of inheritance with [ASR::Model](/Serializer/Model/)s * Conditionally determine which (if any) properties should be [excluded](/Serializer/ExclusionStrategies/ExclusionStrategyInterface/) ================================================ FILE: src/components/serializer/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Serializer site_url: https://athenaframework.org/Serializer/ repo_url: https://github.com/athena-framework/serializer nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-dependency_injection/src/athena-dependency_injection.cr - ./lib/athena-serializer/src/athena-serializer.cr source_locations: lib/athena-serializer: https://github.com/athena-framework/serializer/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/serializer/shard.yml ================================================ name: athena-serializer version: 0.4.3 crystal: ~> 1.19 license: MIT repository: https://github.com/athena-framework/serializer documentation: https://athenaframework.org/Serializer description: | Object (de)serialization library. authors: - George Dietrich dependencies: athena-dependency_injection: github: athena-framework/dependency-injection version: '>= 0.4.0' ================================================ FILE: src/components/serializer/spec/athena-serializer_spec.cr ================================================ require "./spec_helper" describe ASR::Serializable do describe "#serialization_properties" do describe ASRA::Accessor do it "should use the value of the method" do properties = GetterAccessor.new.serialization_properties properties.size.should eq 1 p = properties[0] p.name.should eq "foo" p.external_name.should eq "foo" p.value.should eq "FOO" p.skip_when_empty?.should be_false p.type.should eq String end it "handles when getter value has a diff type than ivar" do properties = GetterAccessorDiffType.new.serialization_properties properties.size.should eq 1 p = properties[0] p.name.should eq "value" p.external_name.should eq "value" p.value.should eq "100" p.value.should be_a String p.skip_when_empty?.should be_false p.type.should eq Int32 end end describe ASRA::AccessorOrder do describe :default do it "should used the order in which the properties were defined" do properties = Default.new.serialization_properties properties.size.should eq 6 properties.map(&.name).should eq %w(a z two one a_a get_val) properties.map(&.external_name).should eq %w(a z two one a_a get_val) end end describe :alphabetical do it "should order the properties alphabetically by their name" do properties = Abc.new.serialization_properties properties.size.should eq 6 properties.map(&.name).should eq %w(a a_a get_val one zzz z) properties.map(&.external_name).should eq %w(a a_a get_val one two z) end end describe :custom do it "should use the order defined by the user" do properties = Custom.new.serialization_properties properties.size.should eq 6 properties.map(&.name).should eq %w(two z get_val a one a_a) properties.map(&.external_name).should eq %w(two z get_val a one a_a) end end end describe ASRA::Skip do it "should not include skipped properties" do properties = Skip.new.serialization_properties properties.size.should eq 1 p = properties[0] p.name.should eq "one" p.external_name.should eq "one" p.value.should eq "one" p.skip_when_empty?.should be_false p.type.should eq String end end describe ASRA::ExclusionPolicy do describe :all do describe ASRA::Expose do it "should only return properties that are exposed" do properties = Expose.new.serialization_properties properties.size.should eq 1 p = properties[0] p.name.should eq "name" p.external_name.should eq "name" p.value.should eq "Jim" p.skip_when_empty?.should be_false p.type.should eq String end end end describe :none do describe ASRA::Exclude do it "should only return properties that are not excluded" do properties = Exclude.new.serialization_properties properties.size.should eq 1 p = properties[0] p.name.should eq "name" p.external_name.should eq "name" p.value.should eq "Jim" p.skip_when_empty?.should be_false p.type.should eq String end end end end describe ASRA::Name do describe :serialize do it "should use the value in the annotation or property name if it wasnt defined" do properties = SerializedName.new.serialization_properties properties.size.should eq 3 p = properties[0] p.name.should eq "my_home_address" p.external_name.should eq "myAddress" p.value.should eq "123 Fake Street" p.skip_when_empty?.should be_false p.type.should eq String p = properties[1] p.name.should eq "value" p.external_name.should eq "a_value" p.value.should eq "str" p.skip_when_empty?.should be_false p.type.should eq String p = properties[2] p.name.should eq "myZipCode" p.external_name.should eq "myZipCode" p.value.should eq 90210 p.skip_when_empty?.should be_false p.type.should eq Int32 end end describe :deserialize do it "should use the value in the annotation or property name if it wasnt defined" do properties = DeserializedName.deserialization_properties properties.size.should eq 2 p = properties[0] p.name.should eq "custom_name" p.external_name.should eq "des" p.skip_when_empty?.should be_false p.type.should eq Int32? p = properties[1] p.name.should eq "default_name" p.external_name.should eq "default_name" p.skip_when_empty?.should be_false p.type.should eq Bool? end end describe :key do it "should use the value in the annotation or property name if it wasnt defined" do both_properties = [ SerializedNameKey.new.serialization_properties, SerializedNameKey.deserialization_properties, ] both_properties.each do |properties| properties.size.should eq 3 p = properties[0] p.name.should eq "my_home_address" p.external_name.should eq "myAddress" p.skip_when_empty?.should be_false p.type.should eq String p = properties[1] p.name.should eq "value" p.external_name.should eq "some_key" p.skip_when_empty?.should be_false p.type.should eq String p = properties[2] p.name.should eq "myZipCode" p.external_name.should eq "myZipCode" p.skip_when_empty?.should be_false p.type.should eq Int32 end end end describe :serialization_strategy do it :camelcase do properties = SerializedNameCamelcaseSerializationStrategy.new.serialization_properties properties.size.should eq 3 p = properties[0] p.name.should eq "my_home_address" p.external_name.should eq "myAdd_ress" p = properties[1] p.name.should eq "two_wOrds" p.external_name.should eq "twoWOrds" p = properties[2] p.name.should eq "myZipCode" p.external_name.should eq "myZipCode" end it :underscore do properties = SerializedNameUnderscoreSerializationStrategy.new.serialization_properties properties.size.should eq 3 p = properties[0] p.name.should eq "my_home_address" p.external_name.should eq "myAdd_ress" p = properties[1] p.name.should eq "two_wOrds" p.external_name.should eq "two_w_ords" p = properties[2] p.name.should eq "myZipCode" p.external_name.should eq "my_zip_code" end it :identical do properties = SerializedNameIdenticalSerializationStrategy.new.serialization_properties properties.size.should eq 3 p = properties[0] p.name.should eq "my_home_address" p.external_name.should eq "myAdd_ress" p = properties[1] p.name.should eq "two_wOrds" p.external_name.should eq "two_wOrds" p = properties[2] p.name.should eq "myZipCode" p.external_name.should eq "myZipCode" end end describe :deserialization_strategy do it :camelcase do properties = DeserializedNameCamelcaseDeserializationStrategy.deserialization_properties properties.size.should eq 3 p = properties[0] p.name.should eq "my_home_address" p.external_name.should eq "myAdd_ress" p = properties[1] p.name.should eq "two_wOrds" p.external_name.should eq "twoWOrds" p = properties[2] p.name.should eq "myZipCode" p.external_name.should eq "myZipCode" end it :underscore do properties = DeserializedNameUnderscoreDeserializationStrategy.deserialization_properties properties.size.should eq 3 p = properties[0] p.name.should eq "my_home_address" p.external_name.should eq "myAdd_ress" p = properties[1] p.name.should eq "two_wOrds" p.external_name.should eq "two_w_ords" p = properties[2] p.name.should eq "myZipCode" p.external_name.should eq "my_zip_code" end it :identical do properties = DeserializedNameIdenticalDeserializationStrategy.deserialization_properties properties.size.should eq 3 p = properties[0] p.name.should eq "my_home_address" p.external_name.should eq "myAdd_ress" p = properties[1] p.name.should eq "two_wOrds" p.external_name.should eq "two_wOrds" p = properties[2] p.name.should eq "myZipCode" p.external_name.should eq "myZipCode" end end describe :strategy do it :camelcase do both_properties = [ SerializedNameCamelcaseStrategy.new.serialization_properties, SerializedNameCamelcaseStrategy.deserialization_properties, ] both_properties.each do |properties| properties.size.should eq 3 p = properties[0] p.name.should eq "my_home_address" p.external_name.should eq "myAdd_ress" p = properties[1] p.name.should eq "two_wOrds" p.external_name.should eq "twoWOrds" p = properties[2] p.name.should eq "myZipCode" p.external_name.should eq "myZipCode" end end it :underscore do both_properties = [ SerializedNameUnderscoreStrategy.new.serialization_properties, SerializedNameUnderscoreStrategy.deserialization_properties, ] both_properties.each do |properties| properties.size.should eq 3 p = properties[0] p.name.should eq "my_home_address" p.external_name.should eq "myAdd_ress" p = properties[1] p.name.should eq "two_wOrds" p.external_name.should eq "two_w_ords" p = properties[2] p.name.should eq "myZipCode" p.external_name.should eq "my_zip_code" end end it :identical do both_properties = [ SerializedNameIdenticalStrategy.new.serialization_properties, SerializedNameIdenticalStrategy.deserialization_properties, ] both_properties.each do |properties| properties.size.should eq 3 p = properties[0] p.name.should eq "my_home_address" p.external_name.should eq "myAdd_ress" p = properties[1] p.name.should eq "two_wOrds" p.external_name.should eq "two_wOrds" p = properties[2] p.name.should eq "myZipCode" p.external_name.should eq "myZipCode" end end end end describe ASRA::SkipWhenEmpty do it "should use the value of the method" do properties = SkipWhenEmpty.new.serialization_properties properties.size.should eq 1 p = properties[0] p.name.should eq "value" p.external_name.should eq "value" p.value.should eq "value" p.skip_when_empty?.should be_true p.type.should eq String end end describe ASRA::VirtualProperty do it "should only return properties that are not excluded" do properties = VirtualProperty.new.serialization_properties properties.size.should eq 3 p = properties[0] p.name.should eq "foo" p.groups.should eq Set{"default"} p.since_version.should be_nil p.until_version.should be_nil p.external_name.should eq "foo" p.value.should eq "foo" p.skip_when_empty?.should be_false p.type.should eq String p = properties[1] p.name.should eq "get_val" p.groups.should eq Set{"default"} p.since_version.should be_nil p.until_version.should be_nil p.external_name.should eq "get_val" p.value.should eq "VAL" p.skip_when_empty?.should be_false p.type.should eq String p = properties[2] p.name.should eq "group_version" p.groups.should eq Set{"group1"} p.since_version.should eq SemanticVersion.parse "1.3.2" p.until_version.should eq SemanticVersion.parse "1.2.3" p.external_name.should eq "group_version" p.value.should eq "group_version" p.skip_when_empty?.should be_false p.type.should eq String end end describe ASRA::IgnoreOnSerialize do it "should not include ignored properties" do properties = IgnoreOnSerialize.new.serialization_properties properties.size.should eq 1 p = properties[0] p.name.should eq "name" p.external_name.should eq "name" p.value.should eq "Fred" p.skip_when_empty?.should be_false p.type.should eq String end end end end ================================================ FILE: src/components/serializer/spec/compiler_spec.cr ================================================ require "./spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "athena-serializer") end describe Athena::Serializer, tags: "compiled" do describe "compiler errors" do describe ASRA::Name do it "errors if an invalid strategy is used for deserialization" do assert_compile_time_error "Invalid ASRA::Name strategy: ':invalid'.", <<-'CR' @[ASRA::Name(strategy: :invalid)] class Foo include ASR::Serializable def initialize; end property name : String = "foo" end Foo.deserialization_properties CR end end describe "read-only properties" do it "errors if a read-only property is not nilable and has no default value" do assert_compile_time_error "is read-only but is not nilable nor has a default value", <<-'CR' class Foo include ASR::Serializable @[ASRA::ReadOnly] property name : String end Foo.deserialization_properties CR end end end end ================================================ FILE: src/components/serializer/spec/exclusion_strategies/custom_strategy_spec.cr ================================================ require "../spec_helper" ADI.configuration_annotation IsActiveProperty, active : Bool = true private struct ActivePropertyExclusionStrategy include Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface # :inherit: def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool return false if context.direction.deserialization? ann_configs = metadata.annotation_configurations ann_configs.has?(IsActiveProperty) && !ann_configs[IsActiveProperty].active end end # Mainly testing `Athena::DependencyInjection` integration in regards to custom annotations accessible via the property metadata. describe ActivePropertyExclusionStrategy do describe "#skip_property?" do describe :deserialization do it "it should not skip" do ActivePropertyExclusionStrategy.new.skip_property?(create_metadata, ASR::DeserializationContext.new).should be_false end end describe :serialization do describe "without the annotation" do it "should not skip" do ActivePropertyExclusionStrategy.new.skip_property?(create_metadata, ASR::SerializationContext.new).should be_false end end describe "with the annotation" do it true do ann_config = ADI::AnnotationConfigurations.new({IsActiveProperty => [IsActivePropertyConfiguration.new] of ADI::AnnotationConfigurations::ConfigurationBase}) ActivePropertyExclusionStrategy.new.skip_property?(create_metadata(annotation_configurations: ann_config), ASR::SerializationContext.new).should be_false end it false do ann_config = ADI::AnnotationConfigurations.new({IsActiveProperty => [IsActivePropertyConfiguration.new(false)] of ADI::AnnotationConfigurations::ConfigurationBase}) ActivePropertyExclusionStrategy.new.skip_property?(create_metadata(annotation_configurations: ann_config), ASR::SerializationContext.new).should be_true end end end end end ================================================ FILE: src/components/serializer/spec/exclusion_strategies/group_spec.cr ================================================ require "../spec_helper" describe ASR::ExclusionStrategies::Groups do describe "#skip_property?" do describe "that is in the default group" do it "should not skip" do assert_groups(groups: ["default"]).should be_false end end describe "that includes at least one group" do it "should not skip" do assert_groups(groups: ["one", "two"], metadata_groups: ["two", "three"]).should be_false end end describe "that does not include any group" do it "should skip" do assert_groups(groups: ["one", "two"], metadata_groups: ["three", "four"]).should be_true end end it "splat argument" do ASR::ExclusionStrategies::Groups.new("one", "two", "three").skip_property?(create_metadata(groups: ["default"]), ASR::SerializationContext.new).should be_true ASR::ExclusionStrategies::Groups.new("one", "default", "three").skip_property?(create_metadata(groups: ["default"]), ASR::SerializationContext.new).should be_false end end end ================================================ FILE: src/components/serializer/spec/exclusion_strategies/version_spec.cr ================================================ require "../spec_helper" describe ASR::ExclusionStrategies::Version do describe "#skip_property?" do describe :since_version do describe "that isnt set" do it "should not skip" do assert_version.should be_false end end describe "that is less than the version" do it "should not skip" do assert_version(since_version: "0.31.0").should be_false end end describe "that is equal than the version" do it "should not skip" do assert_version(since_version: "1.0.0").should be_false end end describe "that is larger than the version" do it "should skip" do assert_version(since_version: "1.5.0").should be_true end end end describe :until_version do describe "that isnt set" do it "should not skip" do assert_version.should be_false end end describe "that is less than the version" do it "should skip" do assert_version(until_version: "0.31.0").should be_true end end describe "that is equal than the version" do it "should skip" do assert_version(until_version: "1.0.0").should be_true end end describe "that is larger than the version" do it "should not skip" do assert_version(until_version: "1.5.0").should be_false end end end end end ================================================ FILE: src/components/serializer/spec/models/accessor.cr ================================================ class GetterAccessor include ASR::Serializable def initialize; end @[ASRA::Accessor(getter: get_foo)] @foo : String = "foo" private def get_foo : String @foo.upcase end end class GetterAccessorDiffType include ASR::Serializable def initialize; end @[ASRA::Accessor(getter: get_value)] @value : Int32 = 10 private def get_value : String (@value * 10).to_s end end class SetterAccessor include ASR::Serializable @[ASRA::Accessor(setter: set_foo)] getter foo : String private def set_foo(foo : String) : String foo.should eq "foo" @foo = "FOO" end end ================================================ FILE: src/components/serializer/spec/models/accessor_order.cr ================================================ class Default include ASR::Serializable def initialize; end property a : String = "A" property z : String = "Z" property two : String = "two" property one : String = "one" property a_a : Int32 = 123 @[ASRA::VirtualProperty] def get_val : String "VAL" end end @[ASRA::AccessorOrder(:alphabetical)] class Abc include ASR::Serializable def initialize; end property a : String = "A" property z : String = "Z" property one : String = "one" property a_a : Int32 = 123 @[ASRA::Name(serialize: "two")] property zzz : String = "two" @[ASRA::VirtualProperty] def get_val : String "VAL" end end @[ASRA::AccessorOrder(:custom, order: ["two", "z", "get_val", "a", "one", "a_a"])] class Custom include ASR::Serializable def initialize; end property a : String = "A" property z : String = "Z" property two : String = "two" property one : String = "one" property a_a : Int32 = 123 @[ASRA::VirtualProperty] def get_val : String "VAL" end end ================================================ FILE: src/components/serializer/spec/models/basic.cr ================================================ require "../spec_helper" class TestObject include ASR::Serializable def initialize; end getter foo : Symbol = :foo getter bar : Float32 = 12.1_f32 getter nest : NestedType = NestedType.new end ================================================ FILE: src/components/serializer/spec/models/discriminator.cr ================================================ @[ASRA::Discriminator(key: "type", map: {"point" => Point, "circle" => Circle})] abstract class Shape include ASR::Serializable property type : String end class Point < Shape property x : Int32 property y : Int32 end class Circle < Shape property x : Int32 property y : Int32 property radius : Int32 end ================================================ FILE: src/components/serializer/spec/models/emit_null.cr ================================================ class EmitNil include ASR::Serializable def initialize; end property name : String? property age : Int32 = 1 end ================================================ FILE: src/components/serializer/spec/models/empty.cr ================================================ require "../spec_helper" class EmptyObject include ASR::Serializable def initialize; end end ================================================ FILE: src/components/serializer/spec/models/exclude.cr ================================================ @[ASRA::ExclusionPolicy(:none)] class Exclude include ASR::Serializable def initialize; end property name : String = "Jim" @[ASRA::Exclude] property password : String? = "monkey" end ================================================ FILE: src/components/serializer/spec/models/expose.cr ================================================ @[ASRA::ExclusionPolicy(:all)] class Expose include ASR::Serializable def initialize; end @[ASRA::Expose] property name : String = "Jim" property password : String? = "monkey" end ================================================ FILE: src/components/serializer/spec/models/groups.cr ================================================ class Group include ASR::Serializable def initialize; end @[ASRA::Groups("list", "details")] property id : Int64 = 1 @[ASRA::Groups("list")] property comment_summaries : Array(String) = ["Sentence 1.", "Sentence 2."] @[ASRA::Groups("details")] property comments : Array(String) = ["Sentence 1. Another sentence.", "Sentence 2. Some other stuff."] property created_at : Time = Time.utc(2019, 1, 1) end ================================================ FILE: src/components/serializer/spec/models/ignore_on_deserialize.cr ================================================ class IgnoreOnDeserialize include ASR::Serializable property name : String = "Fred" @[ASRA::IgnoreOnDeserialize] property password : String = "monkey" end ================================================ FILE: src/components/serializer/spec/models/ignore_on_serialize.cr ================================================ class IgnoreOnSerialize include ASR::Serializable def initialize; end property name : String = "Fred" @[ASRA::IgnoreOnSerialize] property password : String = "monkey" end ================================================ FILE: src/components/serializer/spec/models/name.cr ================================================ class SerializedName include ASR::Serializable def initialize; end @[ASRA::Name(serialize: "myAddress")] property my_home_address : String = "123 Fake Street" @[ASRA::Name(deserialize: "some_key", serialize: "a_value")] property value : String = "str" # ameba:disable Naming/VariableNames property myZipCode : Int32 = 90210 end class SerializedNameKey include ASR::Serializable def initialize; end @[ASRA::Name(key: "myAddress")] property my_home_address : String = "123 Fake Street" @[ASRA::Name(key: "some_key")] property value : String = "str" # ameba:disable Naming/VariableNames property myZipCode : Int32 = 90210 end @[ASRA::Name(serialization_strategy: :camelcase)] class SerializedNameCamelcaseSerializationStrategy include ASR::Serializable def initialize; end # Is overridable @[ASRA::Name(serialize: "myAdd_ress")] property my_home_address : String = "123 Fake Street" # ameba:disable Naming/VariableNames property two_wOrds : String = "two words" # ameba:disable Naming/VariableNames property myZipCode : Int32 = 90210 end @[ASRA::Name(serialization_strategy: :underscore)] class SerializedNameUnderscoreSerializationStrategy include ASR::Serializable def initialize; end # Is overridable @[ASRA::Name(serialize: "myAdd_ress")] property my_home_address : String = "123 Fake Street" # ameba:disable Naming/VariableNames property two_wOrds : String = "two words" # ameba:disable Naming/VariableNames property myZipCode : Int32 = 90210 end @[ASRA::Name(serialization_strategy: :identical)] class SerializedNameIdenticalSerializationStrategy include ASR::Serializable def initialize; end # Is overridable @[ASRA::Name(serialize: "myAdd_ress")] property my_home_address : String = "123 Fake Street" # ameba:disable Naming/VariableNames property two_wOrds : String = "two words" # ameba:disable Naming/VariableNames property myZipCode : Int32 = 90210 end @[ASRA::Name(deserialization_strategy: :camelcase)] class DeserializedNameCamelcaseDeserializationStrategy include ASR::Serializable def initialize; end # Is overridable @[ASRA::Name(deserialize: "myAdd_ress")] property my_home_address : String = "123 Fake Street" # ameba:disable Naming/VariableNames property two_wOrds : String = "two words" # ameba:disable Naming/VariableNames property myZipCode : Int32 = 90210 end @[ASRA::Name(deserialization_strategy: :underscore)] class DeserializedNameUnderscoreDeserializationStrategy include ASR::Serializable def initialize; end # Is overridable @[ASRA::Name(deserialize: "myAdd_ress")] property my_home_address : String = "123 Fake Street" # ameba:disable Naming/VariableNames property two_wOrds : String = "two words" # ameba:disable Naming/VariableNames property myZipCode : Int32 = 90210 end @[ASRA::Name(deserialization_strategy: :identical)] class DeserializedNameIdenticalDeserializationStrategy include ASR::Serializable def initialize; end # Is overridable @[ASRA::Name(deserialize: "myAdd_ress")] property my_home_address : String = "123 Fake Street" # ameba:disable Naming/VariableNames property two_wOrds : String = "two words" # ameba:disable Naming/VariableNames property myZipCode : Int32 = 90210 end @[ASRA::Name(strategy: :camelcase)] class SerializedNameCamelcaseStrategy include ASR::Serializable def initialize; end # Is overridable @[ASRA::Name(key: "myAdd_ress")] property my_home_address : String = "123 Fake Street" # ameba:disable Naming/VariableNames property two_wOrds : String = "two words" # ameba:disable Naming/VariableNames property myZipCode : Int32 = 90210 end @[ASRA::Name(strategy: :underscore)] class SerializedNameUnderscoreStrategy include ASR::Serializable def initialize; end # Is overridable @[ASRA::Name(key: "myAdd_ress")] property my_home_address : String = "123 Fake Street" # ameba:disable Naming/VariableNames property two_wOrds : String = "two words" # ameba:disable Naming/VariableNames property myZipCode : Int32 = 90210 end @[ASRA::Name(strategy: :identical)] class SerializedNameIdenticalStrategy include ASR::Serializable def initialize; end # Is overridable @[ASRA::Name(key: "myAdd_ress")] property my_home_address : String = "123 Fake Street" # ameba:disable Naming/VariableNames property two_wOrds : String = "two words" # ameba:disable Naming/VariableNames property myZipCode : Int32 = 90210 end class DeserializedName include ASR::Serializable def initialize; end @[ASRA::Name(deserialize: "des")] property custom_name : Int32? property default_name : Bool? end class AliasName include ASR::Serializable def initialize; end @[ASRA::Name(aliases: ["val", "value", "some_value"])] property some_value : String? end class KeyName include ASR::Serializable def initialize; end @[ASRA::Name(key: "firstName")] property first_name : String? end ================================================ FILE: src/components/serializer/spec/models/nested.cr ================================================ require "../spec_helper" class NestedType include ASR::Serializable def initialize; end getter? active : Bool = true end ================================================ FILE: src/components/serializer/spec/models/post_deserialize.cr ================================================ @[ASRA::ExclusionPolicy(:all)] class PostDeserialize include ASR::Serializable def initialize; end getter first_name : String? getter last_name : String? @[ASRA::Expose] getter name : String = "First Last" @[ASRA::PostDeserialize] def split_name : Nil @first_name, @last_name = @name.split(' ') end end ================================================ FILE: src/components/serializer/spec/models/post_serialize.cr ================================================ class PostSerialize include ASR::Serializable def initialize; end getter name : String? getter age : Int32? @[ASRA::PreSerialize] def set_name : Nil @name = "NAME" end @[ASRA::PreSerialize] def set_age : Nil @age = 123 end @[ASRA::PostSerialize] def reset : Nil @age = nil @name = nil end end ================================================ FILE: src/components/serializer/spec/models/pre_serialize.cr ================================================ class PreSerialize include ASR::Serializable def initialize; end getter name : String? getter age : Int32? @[ASRA::PreSerialize] def set_name : Nil @name = "NAME" end @[ASRA::PreSerialize] def set_age : Nil @age = 123 end end ================================================ FILE: src/components/serializer/spec/models/read_only.cr ================================================ # class ReadOnly # include ASR::Serializable # property name : String # @[ASRA::ReadOnly] # property password : String? # end ================================================ FILE: src/components/serializer/spec/models/skip.cr ================================================ class Skip include ASR::Serializable def initialize; end property one : String = "one" @[ASRA::Skip] property two : String = "two" end ================================================ FILE: src/components/serializer/spec/models/skip_when_empty.cr ================================================ class SkipWhenEmpty include ASR::Serializable def initialize; end @[ASRA::SkipWhenEmpty] property value : String = "value" end ================================================ FILE: src/components/serializer/spec/models/virtual_property.cr ================================================ class VirtualProperty include ASR::Serializable def initialize; end property foo : String = "foo" @[ASRA::VirtualProperty] def get_val : String "VAL" end @[ASRA::VirtualProperty] @[ASRA::Groups("group1")] @[ASRA::Since("1.3.2")] @[ASRA::Until("1.2.3")] def group_version : String "group_version" end end ================================================ FILE: src/components/serializer/spec/navigators/deserialization_navigator_spec.cr ================================================ require "../spec_helper" describe ASR::Navigators::DeserializationNavigator do describe "#accept" do describe ASRA::PostDeserialize do it "should run post deserialize methods" do data = JSON.parse %({"name": "First Last"}) visitor = create_deserialization_visitor do |properties| properties.size.should eq 1 p = properties[0] p.name.should eq "name" p.external_name.should eq "name" p.skip_when_empty?.should be_false p.groups.should eq ["default"] of String p.type.should eq String? p.class.should eq PostDeserialize obj = PostDeserialize.new obj.first_name.should be_nil obj.last_name.should be_nil obj end obj = ASR::Navigators::DeserializationNavigator.new(visitor, ASR::DeserializationContext.new, ASR::InstantiateObjectConstructor.new).accept(PostDeserialize, data).as(PostDeserialize) obj.first_name.should eq "First" obj.last_name.should eq "Last" end end end end ================================================ FILE: src/components/serializer/spec/navigators/serialization_navigator_spec.cr ================================================ require "../spec_helper" describe ASR::Navigators::SerializationNavigator do describe "#accept" do describe ASRA::PreSerialize do it "should run pre serialize methods" do obj = PreSerialize.new obj.name.should be_nil obj.age.should be_nil visitor = create_serialization_visitor do |properties| properties.size.should eq 2 p = properties[0] p.name.should eq "name" p.external_name.should eq "name" p.value.should eq "NAME" p.skip_when_empty?.should be_false p.groups.should eq Set{"default"} p.type.should eq String? p = properties[1] p.name.should eq "age" p.external_name.should eq "age" p.value.should eq 123 p.skip_when_empty?.should be_false p.groups.should eq Set{"default"} p.type.should eq Int32? end ASR::Navigators::SerializationNavigator.new(visitor, ASR::SerializationContext.new).accept obj obj.name.should eq "NAME" obj.age.should eq 123 end end describe ASRA::PostSerialize do it "should run pre serialize methods" do obj = PostSerialize.new obj.name.should be_nil obj.age.should be_nil visitor = create_serialization_visitor do |properties| properties.size.should eq 2 p = properties[0] p.name.should eq "name" p.external_name.should eq "name" p.value.should eq "NAME" p.skip_when_empty?.should be_false p.groups.should eq Set{"default"} p.type.should eq String? p = properties[1] p.name.should eq "age" p.external_name.should eq "age" p.value.should eq 123 p.skip_when_empty?.should be_false p.groups.should eq Set{"default"} p.type.should eq Int32? end ASR::Navigators::SerializationNavigator.new(visitor, ASR::SerializationContext.new).accept obj obj.name.should be_nil obj.age.should be_nil end end describe ASRA::SkipWhenEmpty do it "should not serialize empty properties" do obj = SkipWhenEmpty.new obj.value = "" visitor = create_serialization_visitor do |properties| properties.should be_empty end ASR::Navigators::SerializationNavigator.new(visitor, ASR::SerializationContext.new).accept obj end it "should serialize non-empty properties" do obj = SkipWhenEmpty.new visitor = create_serialization_visitor do |properties| properties.size.should eq 1 p = properties[0] p.name.should eq "value" p.external_name.should eq "value" p.value.should eq "value" p.skip_when_empty?.should be_true p.groups.should eq Set{"default"} p.type.should eq String end ASR::Navigators::SerializationNavigator.new(visitor, ASR::SerializationContext.new).accept obj end end describe :emit_nil do describe "with the default value" do it "should not include nil values" do obj = EmitNil.new visitor = create_serialization_visitor do |properties| properties.size.should eq 1 p = properties[0] p.name.should eq "age" p.external_name.should eq "age" p.value.should eq 1 p.skip_when_empty?.should be_false p.groups.should eq Set{"default"} p.type.should eq Int32 end ASR::Navigators::SerializationNavigator.new(visitor, ASR::SerializationContext.new).accept obj end end describe "when enabled" do it "should include nil values" do obj = EmitNil.new ctx = ASR::SerializationContext.new ctx.emit_nil = true visitor = create_serialization_visitor do |properties| properties.size.should eq 2 p = properties[0] p.name.should eq "name" p.external_name.should eq "name" p.value.should be_nil p.skip_when_empty?.should be_false p.groups.should eq Set{"default"} p.type.should eq String? p = properties[1] p.name.should eq "age" p.external_name.should eq "age" p.value.should eq 1 p.skip_when_empty?.should be_false p.groups.should eq Set{"default"} p.type.should eq Int32 end ASR::Navigators::SerializationNavigator.new(visitor, ctx).accept obj end end end describe ASRA::Groups do describe "without any groups in the context" do it "should include all properties" do obj = Group.new visitor = create_serialization_visitor do |properties| properties.size.should eq 4 p = properties[0] p.name.should eq "id" p.external_name.should eq "id" p.value.should eq 1 p.skip_when_empty?.should be_false p.groups.should eq Set{"list", "details"} p.type.should eq Int64 p = properties[1] p.name.should eq "comment_summaries" p.external_name.should eq "comment_summaries" p.value.should eq ["Sentence 1.", "Sentence 2."] p.skip_when_empty?.should be_false p.groups.should eq Set{"list"} p.type.should eq Array(String) p = properties[2] p.name.should eq "comments" p.external_name.should eq "comments" p.value.should eq ["Sentence 1. Another sentence.", "Sentence 2. Some other stuff."] p.skip_when_empty?.should be_false p.groups.should eq Set{"details"} p.type.should eq Array(String) p = properties[3] p.name.should eq "created_at" p.external_name.should eq "created_at" p.value.should eq Time.utc(2019, 1, 1) p.skip_when_empty?.should be_false p.groups.should eq Set{"default"} p.type.should eq Time end ASR::Navigators::SerializationNavigator.new(visitor, ASR::SerializationContext.new).accept obj end end describe "with a group specified" do it "should exclude properties not in the given groups" do obj = Group.new ctx = ASR::SerializationContext.new.groups = ["list"] # Manually call init here to set the exclusion strategies, # normally this gets handled in the serializer instance ctx.init visitor = create_serialization_visitor do |properties| properties.size.should eq 2 p = properties[0] p.name.should eq "id" p.external_name.should eq "id" p.value.should eq 1 p.skip_when_empty?.should be_false p.groups.should eq Set{"list", "details"} p.type.should eq Int64 p = properties[1] p.name.should eq "comment_summaries" p.external_name.should eq "comment_summaries" p.value.should eq ["Sentence 1.", "Sentence 2."] p.skip_when_empty?.should be_false p.groups.should eq Set{"list"} p.type.should eq Array(String) end ASR::Navigators::SerializationNavigator.new(visitor, ctx).accept obj end end describe "that is in the default group" do it "should include properties without groups explicitly defined" do obj = Group.new ctx = ASR::SerializationContext.new.groups = ["list", "default"] # Manually call init here to set the exclusion strategies, # normally this gets handled in the serializer instance ctx.init visitor = create_serialization_visitor do |properties| properties.size.should eq 3 p = properties[0] p.name.should eq "id" p.external_name.should eq "id" p.value.should eq 1 p.skip_when_empty?.should be_false p.groups.should eq Set{"list", "details"} p.type.should eq Int64 p = properties[1] p.name.should eq "comment_summaries" p.external_name.should eq "comment_summaries" p.value.should eq ["Sentence 1.", "Sentence 2."] p.skip_when_empty?.should be_false p.groups.should eq Set{"list"} p.type.should eq Array(String) p = properties[2] p.name.should eq "created_at" p.external_name.should eq "created_at" p.value.should eq Time.utc(2019, 1, 1) p.skip_when_empty?.should be_false p.groups.should eq Set{"default"} p.type.should eq Time end ASR::Navigators::SerializationNavigator.new(visitor, ctx).accept obj end end end describe "primitive type" do it "should write the value" do io = IO::Memory.new ASR::Navigators::SerializationNavigator.new(TestSerializationVisitor.new(io, NamedTuple.new), ASR::SerializationContext.new).accept "FOO" io.rewind.gets_to_end.should eq "FOO" end end end end ================================================ FILE: src/components/serializer/spec/serialization_context_spec.cr ================================================ require "./spec_helper" struct False include ASR::ExclusionStrategies::ExclusionStrategyInterface # :inherit: def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool false end end describe ASR::SerializationContext do describe "#init" do it "that wasn't already inited" do context = ASR::SerializationContext.new context.groups = {"group1"} context.version = "1.0.0" context.exclusion_strategy.should be_nil context.init context.exclusion_strategy.should be_a ASR::ExclusionStrategies::Disjunct context.exclusion_strategy.try &.as(ASR::ExclusionStrategies::Disjunct).members.size.should eq 2 end it "that was already inited" do context = ASR::SerializationContext.new context.init expect_raises ASR::Exception::Logic, "This context was already initialized, and cannot be re-used." do context.init end end end describe "#add_exclusion_strategy" do describe "with no previous strategy" do it "should set it directly" do context = ASR::SerializationContext.new context.exclusion_strategy.should be_nil context.add_exclusion_strategy False.new context.exclusion_strategy.should be_a False end end describe "with a strategy already set" do it "should use a Disjunct strategy" do context = ASR::SerializationContext.new context.exclusion_strategy.should be_nil context.add_exclusion_strategy False.new context.add_exclusion_strategy False.new context.exclusion_strategy.should be_a ASR::ExclusionStrategies::Disjunct context.exclusion_strategy.try &.as(ASR::ExclusionStrategies::Disjunct).members.size.should eq 2 end end describe "with a multiple strategies already set" do it "should push the member to the Disjunct strategy" do context = ASR::SerializationContext.new context.exclusion_strategy.should be_nil context.add_exclusion_strategy False.new context.add_exclusion_strategy False.new context.add_exclusion_strategy False.new context.exclusion_strategy.should be_a ASR::ExclusionStrategies::Disjunct context.exclusion_strategy.try &.as(ASR::ExclusionStrategies::Disjunct).members.size.should eq 3 end end end describe "#groups=" do it "sets the groups" do context = ASR::SerializationContext.new.groups = ["one", "two"] context.groups.should eq Set{"one", "two"} end it "raises if the groups are empty" do expect_raises ArgumentError, "Groups cannot be empty" do ASR::SerializationContext.new.groups = [] of String end end end describe "#version=" do it "sets the version as a `SemanticVersion`" do context = ASR::SerializationContext.new.version = "1.1.1" context.version.should eq SemanticVersion.new 1, 1, 1 end end end ================================================ FILE: src/components/serializer/spec/serializer_spec.cr ================================================ require "./spec_helper" class Unserializable getter id : Int64? end class IsSerializable include ASR::Serializable getter id : Int64 end class NotNilableModel include ASR::Serializable getter not_nilable : String getter not_nilable_not_serializable : Unserializable end class NilableModel include ASR::Serializable getter nilable : String? getter nilable_not_serializable : Unserializable? end class NilableArrayModel include ASR::Serializable getter nilable_array : Array(Unserializable)? getter default_array : Array(Unserializable)? = [] of Unserializable getter nilable_nilable_array : Array(Unserializable?)? end class TestingModel include ASR::Serializable getter id : Int64 @array : Array(IsSerializable) property obj : IsSerializable def get_array @array end end module ReverseConverter def self.deserialize(navigator : ASR::Navigators::DeserializationNavigatorInterface, metadata : ASR::PropertyMetadataBase, data : ASR::Any) : String data.as_s.reverse end end class ReverseConverterModel include ASR::Serializable @[ASRA::Accessor(converter: ReverseConverter)] getter str : String end class SingleNilablePropertyModel include ASR::Serializable property my_prop : String? end abstract struct BaseModel include ASR::Model end record ModelOne < BaseModel, id : Int32, name : String do include ASR::Serializable end record ModelTwo < BaseModel, id : Int32, name : String do include ASR::Serializable end record Unionable, type : BaseModel.class struct JSONAnyThing include ASR::Serializable getter json : Hash(String, JSON::Any) end struct YAMLAnyThing include ASR::Serializable getter yaml : Hash(String, YAML::Any) end describe ASR::Serializer do describe "#deserialize" do describe ASR::Serializable do describe NotNilableModel do it "missing" do ex = expect_raises ASR::Exception::MissingRequiredProperty, "Missing required property: 'not_nilable'." do ASR.serializer.deserialize NotNilableModel, %({}), :json end ex.property_name.should eq "not_nilable" ex.property_type.should eq "String" end it nil do ex = expect_raises ASR::Exception::NilRequiredProperty, "Required property 'not_nilable_not_serializable' cannot be nil." do ASR.serializer.deserialize NotNilableModel, %({"not_nilable":"FOO","not_nilable_not_serializable":null}), :json end ex.property_name.should eq "not_nilable_not_serializable" ex.property_type.should eq "Unserializable" end end describe ASRA::Accessor do it :setter do ASR.serializer.deserialize(SetterAccessor, %({"foo":"foo"}), :json).foo.should eq "FOO" end end describe ASRA::Discriminator do it "happy path" do ASR.serializer.deserialize(Shape, %({"x":1,"y":2,"type":"point"}), :json).should be_a Point end it "missing discriminator" do ex = expect_raises ASR::Exception::PropertyException, "Missing discriminator field 'type'." do ASR.serializer.deserialize Shape, %({"x":1,"y":2}), :json end ex.property_name.should eq "type" end it "unknown discriminator value" do ex = expect_raises(ASR::Exception::PropertyException, "Unknown 'type' discriminator value: 'triangle'.") do ASR.serializer.deserialize Shape, %({"x":1,"y":2,"type":"triangle"}), :json end ex.property_name.should eq "type" end end describe NilableModel do it "should be set to `nil`" do obj = ASR.serializer.deserialize NilableModel, %({"nilable":"FOO","nilable_not_serializable":{"id":10}}), :json obj.nilable.should eq "FOO" obj.nilable_not_serializable.should be_nil end it "should still return an instance if the input is empty" do ASR.serializer.deserialize(SingleNilablePropertyModel, "{}", :json).my_prop.should be_nil ASR.serializer.deserialize(SingleNilablePropertyModel, "", :yaml).my_prop.should be_nil end end describe NilableArrayModel do it "should be set to `nil` or default if not provided" do obj = ASR.serializer.deserialize NilableArrayModel, %({}), :json obj.nilable_array.should be_nil obj.default_array.should eq [] of Unserializable obj.nilable_nilable_array.should be_nil end it "should default to an empty array if provided or `nil` if possible" do obj = ASR.serializer.deserialize NilableArrayModel, %({"nilable_array":[{"id":1}],"default_array":[{"id":1}],"nilable_nilable_array":[{"id":1}]}), :json obj.nilable_array.should eq [] of Unserializable obj.default_array.should eq [] of Unserializable obj.nilable_nilable_array.should eq [nil] end end describe TestingModel do it "should deserialize correctly" do obj = ASR.serializer.deserialize TestingModel, %({"id":1,"array":[{"id":2},{"id":3}],"obj":{"id":4}}), :json obj.id.should eq 1 array = obj.get_array array.size.should eq 2 array[0].id.should eq 2 array[1].id.should eq 3 obj.obj.id.should eq 4 end end describe ReverseConverterModel do it "should use the converter when deserializing" do ASR.serializer.deserialize(ReverseConverterModel, %({"str":"jim"}), :json).str.should eq "mij" end end end describe "primitive" do it nil do expect_raises ASR::Exception::DeserializationException, "Could not parse String from ''." do ASR.serializer.deserialize String, "null", :json end end it Int32 do value = ASR.serializer.deserialize Int32, "17", :json value.should eq 17 value.should be_a Int32 end end describe Unionable do it "it works with a class union" do model = ASR.serializer.deserialize Unionable.new(ModelOne).type, %({"id":1,"name":"Fred"}), :json model.should be_a ModelOne model.id.should eq 1 model.name.should eq "Fred" end end describe ASR::Any do it "works with base JSON type" do model = ASR.serializer.deserialize JSONAnyThing, %({"json":{"foo":"bar"}}), :json model.json.should be_a Hash(String, JSON::Any) model.json["foo"].as_s.should eq "bar" end it "works with base YAML type" do model = ASR.serializer.deserialize YAMLAnyThing, %({"yaml":{"biz":"baz"}}), :yaml model.yaml.should be_a Hash(String, YAML::Any) model.yaml["biz"].as_s.should eq "baz" end end end end ================================================ FILE: src/components/serializer/spec/spec_helper.cr ================================================ require "spec" require "../src/athena-serializer" require "athena-spec" require "./models/*" enum TestEnum Zero One Two Three end def get_test_property_metadata : Array(ASR::PropertyMetadataBase) [ASR::PropertyMetadata(String, String).new( name: "name", external_name: "external_name", annotation_configurations: ADI::AnnotationConfigurations.new, value: "YES", skip_when_empty: false, groups: ["default"], since_version: nil, until_version: nil, )] of ASR::PropertyMetadataBase end private struct TestSerializationNavigator include Athena::Serializer::Navigators::SerializationNavigatorInterface def initialize(@visitor : ASR::Visitors::SerializationVisitorInterface, @context : ASR::SerializationContext); end def accept(data : ASR::Serializable) : Nil @visitor.visit data.serialization_properties end def accept(data : _) : Nil @visitor.visit data end end # Asserts the output of the given *visitor_type*. def assert_serialized_output(visitor_type : ASR::Visitors::SerializationVisitorInterface.class, expected : String, **named_args, & : ASR::Visitors::SerializationVisitorInterface -> Nil) io = IO::Memory.new visitor = visitor_type.new io, named_args navigator = TestSerializationNavigator.new(visitor, ASR::SerializationContext.new) visitor.navigator = navigator visitor.prepare yield visitor visitor.finish io.rewind.gets_to_end.should eq expected end # Test implementation of `ASR::Visitors::SerializationVisitorInterface` that writes the data to the `io`. class TestSerializationVisitor include Athena::Serializer::Visitors::SerializationVisitorInterface def initialize(@io : IO, named_args : NamedTuple); end def assert_properties(handler : Proc(Array(ASR::PropertyMetadataBase), Nil)) : Nil @assert_properties = handler end def prepare : Nil end def finish : Nil end def visit(data : Array(ASR::PropertyMetadataBase)) : Nil @assert_properties.try &.call data end def visit(data : _) : Nil @io << data end end def create_serialization_visitor(&block : Array(ASR::PropertyMetadataBase) -> Nil) visitor = TestSerializationVisitor.new IO::Memory.new, NamedTuple.new visitor.assert_properties block visitor end # Test implementation of `ASR::Visitors::DeserializationVisitorInterface` that writes the data to the `io`. class TestDeserializationVisitor include Athena::Serializer::Visitors::DeserializationVisitorInterface def initialize(@io : IO); end def assert_properties(handler : Proc(Array(ASR::PropertyMetadataBase), ASR::Serializable)) : Nil @assert_properties = handler end def prepare(data : IO | String) : ASR::Any ASR::Any.new "" end def finish : Nil end def visit(type : _, properties : Array(ASR::PropertyMetadataBase), data : _) @assert_properties.not_nil!.call properties end def visit(type : _, data : _) : Nil @io << data end end def create_deserialization_visitor(&block : Array(ASR::PropertyMetadataBase) -> ASR::Serializable) visitor = TestDeserializationVisitor.new IO::Memory.new visitor.assert_properties block visitor end def assert_deserialized_output(visitor_type : ASR::Visitors::DeserializationVisitorInterface.class, type : _, data : _, expected : _) context = ASR::DeserializationContext.new context.init visitor = visitor_type.new navigator = ASR::Navigators::DeserializationNavigator.new visitor, context, ASR::InstantiateObjectConstructor.new visitor.navigator = navigator result = navigator.accept type, visitor.prepare data result.should eq expected end def create_metadata( *, name : String = "name", external_name : String = "external_name", value : I = "value", skip_when_empty : Bool = false, groups : Array(String) = ["default"], since_version : String? = nil, until_version : String? = nil, annotation_configurations : ADI::AnnotationConfigurations = ADI::AnnotationConfigurations.new, ) : ASR::PropertyMetadata forall I context = ASR::PropertyMetadata(I, I).new name, external_name, annotation_configurations, value, skip_when_empty, groups context.since_version = SemanticVersion.parse since_version if since_version context.until_version = SemanticVersion.parse until_version if until_version context end def assert_version(*, since_version : String? = nil, until_version : String? = nil) : Bool ASR::ExclusionStrategies::Version.new(SemanticVersion.parse "1.0.0").skip_property?(create_metadata(since_version: since_version, until_version: until_version), ASR::SerializationContext.new) end def assert_groups(*, groups : Array(String), metadata_groups : Array(String) = ["default"]) : Bool ASR::ExclusionStrategies::Groups.new(groups).skip_property?(create_metadata(groups: metadata_groups), ASR::SerializationContext.new) end ================================================ FILE: src/components/serializer/spec/visitors/json_deserialization_visitor_spec.cr ================================================ require "../spec_helper" describe ASR::Visitors::JSONDeserializationVisitor do describe "#visit" do describe "primitive types" do it String do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, String, %("Foo"), "Foo" assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, String?, %("Foo"), "Foo" end it Int32 do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Int32, "17", 17 assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Int32?, "17", 17 end it Int64 do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Int64, "17", 17_i64 assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Int64?, "17", 17_i64 assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Int64?, "1033488268764", 1_033_488_268_764 end it Float32 do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Float32, "17.145", 17.145_f32 assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Float32?, "17.145", 17.145_f32 end it Float64 do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Float64, "17.145", 17.145 assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Float64?, "17.145", 17.145 end it String | Int32 do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, String | Int32, "100000", 100_000 assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, String | Int32, %("Bar"), "Bar" expect_raises(ASR::Exception::DeserializationException, "Couldn't parse (Int32 | String) from 'false'") do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, String | Int32, "false", false end end it Array do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Array(Int32), "[1,2,3]", [1, 2, 3] assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Array(Int32?), "[1,2,null]", [1, 2, nil] assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Array(Int32)?, "[1,2,3]", [1, 2, 3] end it Set do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Set(Int32), "[1,2,3]", Set{1, 2, 3} assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Set(Int32?), "[1,2,null]", Set{1, 2, nil} assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Set(Int32)?, "[1,2,3]", Set{1, 2, 3} end it Tuple do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Tuple(Int32, Int32, Int32), "[1,2,3]", {1, 2, 3} assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Tuple(Int32, Int32, Int32)?, "[1,2,3]", {1, 2, 3} end it NamedTuple do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, NamedTuple(numbers: Array(Int32), data: Hash(String, String | Int32)), %({"numbers":[1,2,3],"data":{"name":"Jim","age":19}}), {numbers: [1, 2, 3], data: {"name" => "Jim", "age" => 19}} assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, NamedTuple(numbers: Array(Int32), data: Hash(String, String | Int32))?, %({"numbers":[1,2,3],"data":{"name":"Jim","age":19}}), {numbers: [1, 2, 3], data: {"name" => "Jim", "age" => 19}} end it TestEnum do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, TestEnum, "0", TestEnum::Zero assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, TestEnum, %("Three"), TestEnum::Three assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, TestEnum?, "1", TestEnum::One expect_raises(ASR::Exception::DeserializationException, "Couldn't parse (TestEnum | Nil) from 'asdf'") do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, TestEnum?, %("asdf"), nil end end it Time do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Time, %("2020-04-07T12:34:56Z"), Time.utc 2020, 4, 7, 12, 34, 56 assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Time?, %("2020-04-07T12:34:56Z"), Time.utc 2020, 4, 7, 12, 34, 56 end it Hash do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Hash(String, String), %({"foo": "bar"}), {"foo" => "bar"} assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Hash(String, String)?, %({"foo": "bar"}), {"foo" => "bar"} assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Hash(String, String?)?, %({"foo": "bar"}), {"foo" => "bar"} assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Hash(String, String?), %({"foo": "bar"}), {"foo" => "bar"} end it JSON::Any do assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Hash(String, JSON::Any), %({"foo":"bar"}), {"foo" => "bar"} assert_deserialized_output ASR::Visitors::JSONDeserializationVisitor, Hash(String, JSON::Any)?, %({"foo":"bar"}), {"foo" => "bar"} end end end end ================================================ FILE: src/components/serializer/spec/visitors/json_serialization_visitor_spec.cr ================================================ require "../spec_helper" describe ASR::Visitors::JSONSerializationVisitor do describe "#visit" do describe "primitive types" do it "with indent" do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, %({\n "key": "value"\n}), indent: 4) do |visitor| visitor.visit({"key" => "value"}) end end it String do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, %("Foo")) do |visitor| visitor.visit "Foo" end end it Symbol do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, %("Bar")) do |visitor| visitor.visit :Bar end end it Int do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, "14") do |visitor| visitor.visit 14 end end it Float do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, "15.5") do |visitor| visitor.visit 15.5 end end it Bool do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, "false") do |visitor| visitor.visit false end end it Nil do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, "null") do |visitor| visitor.visit nil end end it UUID do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, %("f89dc089-2c6c-411a-af20-ea98f90376ef")) do |visitor| visitor.visit UUID.new("f89dc089-2c6c-411a-af20-ea98f90376ef") end end describe Enumerable do it Array do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, "[1,2,3]") do |visitor| visitor.visit [1, 2, 3] end end it Set do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, "[1,2,3]") do |visitor| visitor.visit Set{1, 2, 3} end end it Deque do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, "[1,2,3]") do |visitor| visitor.visit Deque{1, 2, 3} end end it Tuple do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, "[1,2,3]") do |visitor| visitor.visit({1, 2, 3}) end end end it Time do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, %("2020-01-18T10:20:30Z")) do |visitor| visitor.visit Time.utc 2020, 1, 18, 10, 20, 30 end end it Hash do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, %({"key":"value","values":[1,"foo",false]})) do |visitor| visitor.visit({"key" => "value", "values" => [1, "foo", false]}) end end it NamedTuple do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, %({"space key":123.12})) do |visitor| visitor.visit({"space key": 123.12}) end end it Enum do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, %("two")) do |visitor| visitor.visit TestEnum::Two end end it YAML::Any do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, "2020") do |visitor| visitor.visit YAML.parse("2020") end end it JSON::Any do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, "2020") do |visitor| visitor.visit JSON.parse("2020") end end end describe ASR::Serializable do it "empty object" do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, "{}") do |visitor| visitor.visit EmptyObject.new end end it "valid object" do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, %({"foo":"foo","bar":12.1,"nest":{"active":true}})) do |visitor| visitor.visit TestObject.new end end it Array(ASR::PropertyMetadataBase) do assert_serialized_output(ASR::Visitors::JSONSerializationVisitor, %({"external_name":"YES"})) do |visitor| visitor.visit get_test_property_metadata end end end end end ================================================ FILE: src/components/serializer/spec/visitors/yaml_deserialization_visitor_spec.cr ================================================ require "../spec_helper" describe ASR::Visitors::YAMLDeserializationVisitor do describe "#visit" do describe "primitive types" do it String do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, String, %("Foo"), "Foo" assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, String?, %("Foo"), "Foo" end it Int32 do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Int32, "17", 17 assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Int32?, "17", 17 end it Int64 do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Int64, "17", 17_i64 assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Int64?, "17", 17_i64 assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Int64?, "1033488268764", 1_033_488_268_764 end it Float32 do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Float32, "17.145", 17.145_f32 assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Float32?, "17.145", 17.145_f32 end it Float64 do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Float64, "17.145", 17.145 assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Float64?, "17.145", 17.145 end it String | Int32 do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, String | Int32, "100000", 100_000 assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, String | Int32, %("Bar"), "Bar" expect_raises(ASR::Exception::DeserializationException, "Couldn't parse (Int32 | String) from 'false'") do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, String | Int32, "false", false end end it Array do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Array(Int32), "---\n- 1\n- 2\n- 3", [1, 2, 3] assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Array(Int32?), "[1,2,~]", [1, 2, nil] assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Array(Int32)?, "[1,2,3]", [1, 2, 3] end it Set do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Set(Int32), "---\n- 1\n- 2\n- 3", Set{1, 2, 3} assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Set(Int32?), "[1,2,null]", Set{1, 2, nil} assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Set(Int32)?, "[1,2,3]", Set{1, 2, 3} end it Tuple do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Tuple(Int32, Int32, Int32), "[1,2,3]", {1, 2, 3} assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Tuple(Int32, Int32, Int32)?, "[1,2,3]", {1, 2, 3} end it NamedTuple do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, NamedTuple(numbers: Array(Int32), data: Hash(String, String | Int32)), %(---\nnumbers:\n - 1\n - 2\n - 3\ndata:\n name: Jim\n age: 19), {numbers: [1, 2, 3], data: {"name" => "Jim", "age" => 19}} assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, NamedTuple(numbers: Array(Int32), data: Hash(String, String | Int32))?, %(---\nnumbers:\n - 1\n - 2\n - 3\ndata:\n name: Jim\n age: 19), {numbers: [1, 2, 3], data: {"name" => "Jim", "age" => 19}} end it TestEnum do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, TestEnum, "0", TestEnum::Zero assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, TestEnum, %("Three"), TestEnum::Three assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, TestEnum?, "1", TestEnum::One expect_raises(ASR::Exception::DeserializationException, "Couldn't parse (TestEnum | Nil) from 'asdf'") do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, TestEnum?, %("asdf"), nil end end it Time do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Time, %("2020-04-07T12:34:56Z"), Time.utc 2020, 4, 7, 12, 34, 56 assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Time?, %("2020-04-07T12:34:56Z"), Time.utc 2020, 4, 7, 12, 34, 56 end it Hash do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Hash(String, String), %(---\nfoo: bar), {"foo" => "bar"} assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Hash(String, String)?, %(---\nfoo: bar), {"foo" => "bar"} assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Hash(String, String?)?, %(---\nfoo: bar), {"foo" => "bar"} assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Hash(String, String?), %(---\nfoo: bar), {"foo" => "bar"} end it YAML::Any do assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Hash(String, YAML::Any), %(---\nfoo: bar), {"foo" => "bar"} assert_deserialized_output ASR::Visitors::YAMLDeserializationVisitor, Hash(String, YAML::Any)?, %(---\nfoo: bar), {"foo" => "bar"} end end end end ================================================ FILE: src/components/serializer/spec/visitors/yaml_serialization_visitor_spec.cr ================================================ require "../spec_helper" # Used to conditionally add the document end marker after some scalar strings based on the libyaml version. private def build_expected_yaml_string(expected : String) : String expected += "...\n" if YAML.libyaml_version < SemanticVersion.new(0, 2, 1) expected end describe ASR::Visitors::YAMLSerializationVisitor do describe "#visit" do describe "primitive types" do it String do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, build_expected_yaml_string %(--- Foo\n)) do |visitor| visitor.visit "Foo" end end it Symbol do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, build_expected_yaml_string %(--- Bar\n)) do |visitor| visitor.visit :Bar end end it Int do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, build_expected_yaml_string "--- 14\n") do |visitor| visitor.visit 14 end end it Float do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, build_expected_yaml_string "--- 15.5\n") do |visitor| visitor.visit 15.5 end end it Bool do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, build_expected_yaml_string "--- false\n") do |visitor| visitor.visit false end end it Nil do str = "---" str += " " if YAML.libyaml_version < SemanticVersion.new(0, 2, 5) str += '\n' assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, build_expected_yaml_string str) do |visitor| visitor.visit nil end end it UUID do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, build_expected_yaml_string "--- f89dc089-2c6c-411a-af20-ea98f90376ef\n") do |visitor| visitor.visit UUID.new("f89dc089-2c6c-411a-af20-ea98f90376ef") end end describe Enumerable do it Array do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, "---\n- 1\n- 2\n- 3\n") do |visitor| visitor.visit [1, 2, 3] end end it Set do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, "---\n- 1\n- 2\n- 3\n") do |visitor| visitor.visit Set{1, 2, 3} end end it Deque do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, "---\n- 1\n- 2\n- 3\n") do |visitor| visitor.visit Deque{1, 2, 3} end end it Tuple do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, "---\n- 1\n- 2\n- 3\n") do |visitor| visitor.visit({1, 2, 3}) end end end it Time do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, build_expected_yaml_string %(--- 2020-01-18T10:20:30Z\n)) do |visitor| visitor.visit Time.utc 2020, 1, 18, 10, 20, 30 end end it Hash do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, %(---\nkey: value\nvalues:\n- 1\n- foo\n- false\n)) do |visitor| visitor.visit({"key" => "value", "values" => [1, "foo", false]}) end end it NamedTuple do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, %(---\nspace key: 123.12\n)) do |visitor| visitor.visit({"space key": 123.12}) end end it Enum do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, build_expected_yaml_string "--- two\n") do |visitor| visitor.visit TestEnum::Two end end it YAML::Any do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, build_expected_yaml_string "--- 2020\n") do |visitor| visitor.visit YAML.parse("2020") end end it JSON::Any do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, build_expected_yaml_string "--- 2020\n") do |visitor| visitor.visit JSON.parse("2020") end end end describe ASR::Serializable do it "empty object" do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, "--- {}\n") do |visitor| visitor.visit EmptyObject.new end end it "valid object" do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, %(---\nfoo: foo\nbar: 12.1\nnest:\n active: true\n)) do |visitor| visitor.visit TestObject.new end end it Array(ASR::PropertyMetadataBase) do assert_serialized_output(ASR::Visitors::YAMLSerializationVisitor, %(---\nexternal_name: YES\n)) do |visitor| visitor.visit get_test_property_metadata end end end end end ================================================ FILE: src/components/serializer/src/annotations.cr ================================================ # `Athena::Serializer` uses annotations to control how an object gets serialized and deserialized. # This module includes all the default serialization and deserialization annotations. The `ASRA` alias can be used as a shorthand when applying the annotations. module Athena::Serializer::Annotations # Allows using methods/modules to control how a property is retrieved/set. # # ## Fields # * `getter` - A method name whose return value will be used as the serialized value. # * `setter` - A method name that accepts the deserialized value. Can be used to apply additional logic before setting the properties value. # * `converter` - A module that defines a `.deserialize` method. Can be used to share common deserialization between types. # * `path : Tuple` - A set of keys used to navigate to a value during deserialization. The value of the last key will be used as the property's value. # # ## Example # # ### Getter/Setter # # ``` # class AccessorExample # include ASR::Serializable # # def initialize; end # # @[ASRA::Accessor(getter: get_foo, setter: set_foo)] # property foo : String = "foo" # # private def set_foo(foo : String) : String # @foo = foo.upcase # end # # private def get_foo : String # @foo.upcase # end # end # # ASR.serializer.serialize AccessorExample.new, :json # => {"foo":"FOO"} # ASR.serializer.deserialize AccessorExample, %({"foo":"bar"}), :json # => # # ``` # # ### Converter # # ``` # module ReverseConverter # def self.deserialize(navigator : ASR::Navigators::DeserializationNavigatorInterface, metadata : ASR::PropertyMetadataBase, data : ASR::Any) : String # data.as_s.reverse # end # end # # class ConverterExample # include ASR::Serializable # # @[ASRA::Accessor(converter: ReverseConverter)] # getter str : String # end # # ASR.serializer.deserialize ConverterExample, %({"str":"jim"}), :json # => # # ``` # # ### Path # # ``` # class Example # include ASR::Serializable # # getter id : Int64 # # @[ASRA::Accessor(path: {"stats", "HP"})] # getter hp : Int32 # # @[ASRA::Accessor(path: {"stats", "Attack"})] # getter attack : Int32 # # @[ASRA::Accessor(path: {"downs", -1, "last_down"})] # getter last_down : Time # end # # DATA = <<-JSON # { # "id": 1, # "stats": { # "HP": 45, # "Attack": 49 # }, # "downs": [ # { # "id": 1, # "last_down": "2020-05-019T05:23:17Z" # }, # { # "id": 2, # "last_down": "2020-04-07T12:34:56Z" # } # ] # # } # JSON # # ASR.serializer.deserialize Example, DATA, :json # # # # ``` annotation Accessor; end # Can be applied to a type to control the order of properties when serialized. Valid values: `:alphabetical`, and `:custom`. # # By default properties are ordered in the order in which they are defined. # # ## Fields # * `order` - Used to specify the order of the properties when using `:custom` ordering. # # ## Example # # ``` # class Default # include ASR::Serializable # # def initialize; end # # property a : String = "A" # property z : String = "Z" # property two : String = "two" # property one : String = "one" # property a_a : Int32 = 123 # # @[ASRA::VirtualProperty] # def get_val : String # "VAL" # end # end # # ASR.serializer.serialize Default.new, :json # => {"a":"A","z":"Z","two":"two","one":"one","a_a":123,"get_val":"VAL"} # # @[ASRA::AccessorOrder(:alphabetical)] # class Abc # include ASR::Serializable # # def initialize; end # # property a : String = "A" # property z : String = "Z" # property two : String = "two" # property one : String = "one" # property a_a : Int32 = 123 # # @[ASRA::VirtualProperty] # def get_val : String # "VAL" # end # end # # ASR.serializer.serialize Abc.new, :json # => {"a":"A","a_a":123,"get_val":"VAL","one":"one","two":"two","z":"Z"} # # @[ASRA::AccessorOrder(:custom, order: ["two", "z", "get_val", "a", "one", "a_a"])] # class Custom # include ASR::Serializable # # def initialize; end # # property a : String = "A" # property z : String = "Z" # property two : String = "two" # property one : String = "one" # property a_a : Int32 = 123 # # @[ASRA::VirtualProperty] # def get_val : String # "VAL" # end # end # # ASR.serializer.serialize Custom.new, :json # => {"two":"two","z":"Z","get_val":"VAL","a":"A","one":"one","a_a":123} # ``` annotation AccessorOrder; end # Allows deserializing an object based on the value of a specific field. # # ## Fields # * `key : String` - The field that should be read from the data to determine the correct type. # * `map : Hash | NamedTuple` - Maps the possible `key` values to their corresponding types. # # ## Example # # ``` # @[ASRA::Discriminator(key: "type", map: {point: Point, circle: Circle})] # abstract class Shape # include ASR::Serializable # # property type : String # end # # class Point < Shape # property x : Int32 # property y : Int32 # end # # class Circle < Shape # property x : Int32 # property y : Int32 # property radius : Int32 # end # # ASR.serializer.deserialize Shape, %({"type":"point","x":10,"y":20}), :json # => # # ASR.serializer.deserialize Shape, %({"type":"circle","x":30,"y":40,"radius":12}), :json # => # # ``` annotation Discriminator; end # Indicates that a property should not be serialized/deserialized when used with `:none` `ASRA::ExclusionPolicy`. # # Also see, `ASRA::IgnoreOnDeserialize` and `ASRA::IgnoreOnSerialize`. # # ## Example # # ``` # @[ASRA::ExclusionPolicy(:none)] # class Example # include ASR::Serializable # # def initialize; end # # property name : String = "Jim" # # @[ASRA::Exclude] # property password : String = "monkey" # end # # ASR.serializer.serialize Example.new, :json # => {"name":"Jim"} # ASR.serializer.deserialize Example, %({"name":"Jim","password":"password1!"}), :json # => # # ``` # # !!!warning # On deserialization, the excluded properties must be nilable, or have a default value. annotation Exclude; end # Defines the default exclusion policy to use on a class. Valid values: `:none`, and `:all`. # # Used with `ASRA::Expose` and `ASRA::Exclude`. annotation ExclusionPolicy; end # Indicates that a property should be serialized/deserialized when used with `:all` `ASRA::ExclusionPolicy`. # # Also see, `ASRA::IgnoreOnDeserialize` and `ASRA::IgnoreOnSerialize`. # # ## Example # # ``` # @[ASRA::ExclusionPolicy(:all)] # class Example # include ASR::Serializable # # def initialize; end # # @[ASRA::Expose] # property name : String = "Jim" # # property password : String = "monkey" # end # # ASR.serializer.serialize Example.new, :json # => {"name":"Jim"} # ASR.serializer.deserialize Example, %({"name":"Jim","password":"password1!"}), :json # => # # ``` # # !!!warning # On deserialization, the excluded properties must be nilable, or have a default value. annotation Expose; end # Defines the group(s) a property belongs to. Properties are automatically added to the `default` group # if no groups are explicitly defined. # # See `ASR::ExclusionStrategies::Groups`. annotation Groups; end # Indicates that a property should not be set on deserialization, but should be serialized. # # ## Example # # ``` # class Example # include ASR::Serializable # # property name : String # # @[ASRA::IgnoreOnDeserialize] # property password : String? # end # # obj = ASR.serializer.deserialize Example, %({"name":"Jim","password":"monkey123"}), :json # # obj.password # => nil # obj.name # => Jim # # obj.password = "foobar" # # ASR.serializer.serialize obj, :json # => {"name":"Jim","password":"foobar"} # ``` annotation IgnoreOnDeserialize; end # Indicates that a property should be set on deserialization, but should not be serialized. # # ## Example # # ``` # class Example # include ASR::Serializable # # property name : String # # @[ASRA::IgnoreOnSerialize] # property password : String? # end # # obj = ASR.serializer.deserialize Example, %({"name":"Jim","password":"monkey123"}), :json # # obj.password # => monkey123 # obj.name # => Jim # # obj.password = "foobar" # # ASR.serializer.serialize obj, :json # => {"name":"Jim"} # ``` annotation IgnoreOnSerialize; end # Defines the `key` to use during (de)serialization. If not provided, the name of the property is used. # Also allows defining aliases that can be used for that property when deserializing. # # ## Fields # # * `serialize : String` - The key to use for this property during serialization. # * `deserialize : String` - The key to use for this property during deserialization. # * `key` : String - The key to use for this property during (de)serialization. # * `aliases : Array(String)` - A set of keys to use for this property during deserialization; is equivalent to multiple `deserialize` keys. # * `serialization_strategy : Symbol` - Defines the default serialization naming strategy for this type. Can be overridden using the `serialize` or `key` field. # * `deserialization_strategy : Symbol` - Defines the default deserialization naming strategy for this type. Can be overridden using the `deserialize` or `key` field. # * `strategy : Symbol` - Defines the default (de)serialization naming strategy for this type. Can be overridden using the `serialize`, `deserialize` or `key` fields. # # ## Example # # ``` # class Example # include ASR::Serializable # # def initialize; end # # @[ASRA::Name(serialize: "myAddress")] # property my_home_address : String = "123 Fake Street" # # @[ASRA::Name(deserialize: "some_key", serialize: "a_value")] # property both_names : String = "str" # # @[ASRA::Name(key: "same")] # property same_in_both_directions : String = "same for both" # # @[ASRA::Name(aliases: ["val", "value", "some_value"])] # property some_value : String = "some_val" # end # # ASR.serializer.serialize Example.new, :json # => {"myAddress":"123 Fake Street","a_value":"str","same":"same for both","some_value":"some_val"} # # obj = ASR.serializer.deserialize Example, %({"my_home_address":"555 Mason Ave","some_key":"deserialized from diff key","same":"same again","value":"some_other_val"}), :json # # obj.my_home_address # => "555 Mason Ave" # obj.both_names # => "deserialized from diff key" # obj.same_in_both_directions # => "same again" # obj.some_value # => "some_other_val" # ``` # # ### Naming Strategies # # By default the keys in the serialized data match exactly to the name of the property. # Naming strategies allow changing this behavior for all properties within the type. # The serialized name can still be overridden on a per-property basis via # using the `ASRA::Name` annotation with the `serialize`, `deserialize` or `key` field. # The strategy will be applied on serialization, deserialization or both, depending # on whether `serialization_strategy`, `deserialization_strategy` or `strategy` is used. # # The available naming strategies include: # * `:camelcase` # * `:underscore` # * `:identical` # # ``` # @[ASRA::Name(strategy: :camelcase)] # class User # include ASR::Serializable # # def initialize; end # # property id : Int32 = 1 # property first_name : String = "Jon" # property last_name : String = "Snow" # end # # ASR.serializer.serialize User.new, :json # => {"id":1,"firstName":"Jon","lastName":"Snow"} # ``` annotation Name; end # Defines a callback method(s) that are ran directly after the object has been deserialized. # # ## Example # # ``` # record Example, name : String, first_name : String?, last_name : String? do # include ASR::Serializable # # @[ASRA::PostDeserialize] # private def split_name : Nil # @first_name, @last_name = @name.split(' ') # end # end # # obj = ASR.serializer.deserialize Example, %({"name":"Jon Snow"}), :json # # obj.name # => Jon Snow # obj.first_name # => Jon # obj.last_name # => Snow # ``` annotation PostDeserialize; end # Defines a callback method that is executed directly after the object has been serialized. # # ## Example # # ``` # @[ASRA::ExclusionPolicy(:all)] # class Example # include ASR::Serializable # # def initialize; end # # @[ASRA::Expose] # @name : String? # # property first_name : String = "Jon" # property last_name : String = "Snow" # # @[ASRA::PreSerialize] # private def pre_serialize : Nil # @name = "#{first_name} #{last_name}" # end # # @[ASRA::PostSerialize] # private def post_serialize : Nil # @name = nil # end # end # # ASR.serializer.serialize Example.new, :json # => {"name":"Jon Snow"} # ``` annotation PostSerialize; end # Defines a callback method that is executed directly before the object has been serialized. # # ## Example # # ``` # @[ASRA::ExclusionPolicy(:all)] # class Example # include ASR::Serializable # # def initialize; end # # @[ASRA::Expose] # @name : String? # # property first_name : String = "Jon" # property last_name : String = "Snow" # # @[ASRA::PreSerialize] # private def pre_serialize : Nil # @name = "#{first_name} #{last_name}" # end # # @[ASRA::PostSerialize] # private def post_serialize : Nil # @name = nil # end # end # # ASR.serializer.serialize Example.new, :json # => {"name":"Jon Snow"} # ``` annotation PreSerialize; end # Indicates that a property is read-only and cannot be set during deserialization. # # ## Example # # ``` # class Example # include ASR::Serializable # # property name : String # # @[ASRA::ReadOnly] # property password : String? # end # # obj = ASR.serializer.deserialize Example, %({"name":"Fred","password":"password1"}), :json # # obj.name # => "Fred" # obj.password # => nil # ``` # # !!!warning # The property must be nilable, or have a default value. annotation ReadOnly; end # Represents the first version a property was available. # # See `ASR::ExclusionStrategies::Version`. # # !!!note # Value must be a `SemanticVersion` version. annotation Since; end # Indicates that a property should not be serialized or deserialized. # # ## Example # # ``` # class Example # include ASR::Serializable # # def initialize; end # # property name : String = "Jim" # # @[ASRA::Skip] # property password : String = "monkey" # end # # ASR.serializer.deserialize Example, %({"name":"Fred","password":"foobar"}), :json # => # # ASR.serializer.serialize Example.new, :json # => {"name":"Fred"} # ``` annotation Skip; end # Indicates that a property should not be serialized when it is empty. # # ## Example # # ``` # class Example # include ASR::Serializable # # def initialize; end # # property id : Int64 = 1 # # @[ASRA::SkipWhenEmpty] # property value : String = "value" # # @[ASRA::SkipWhenEmpty] # property values : Array(String) = %w(one two three) # end # # obj = Example.new # # ASR.serializer.serialize obj, :json # => {"id":1,"value":"value","values":["one","two","three"]} # # obj.value = "" # obj.values = [] of String # # ASR.serializer.serialize obj, :json # => {"id":1} # ``` # # !!!tip: # Can be used on any type that defines an `#empty?` method. annotation SkipWhenEmpty; end # Represents the last version a property was available. # # See `ASR::ExclusionStrategies::Version`. # # !!!note # Value must be a `SemanticVersion` version. annotation Until; end # Can be applied to a method to make it act like a property. # # ## Example # # ``` # class Example # include ASR::Serializable # # def initialize; end # # property foo : String = "foo" # # @[ASRA::VirtualProperty] # @[ASRA::Name(serialize: "testing")] # def some_method : Bool # false # end # # @[ASRA::VirtualProperty] # def get_val : String # "VAL" # end # end # # ASR.serializer.serialize Example.new, :json # => {"foo":"foo","testing":false,"get_val":"VAL"} # ``` # # !!!warning # The return type restriction _MUST_ be defined. annotation VirtualProperty; end end ================================================ FILE: src/components/serializer/src/any.cr ================================================ # Defines an abstraction that format specific types, such as `JSON::Any`, or `YAML::Any` must implement. module Athena::Serializer::Any abstract def as_bool : Bool abstract def as_i : Int32 abstract def as_i? : Int32? abstract def as_f : Float64 abstract def as_f? : Float64? abstract def as_f32 : Float32 abstract def as_f32? : Float32? abstract def as_i64 : Int64 abstract def as_i64? : Int64? abstract def as_s : String abstract def as_s? : String? abstract def as_a abstract def as_a? # ameba:disable Naming/PredicateName abstract def is_nil? : Bool abstract def dig(index_or_key : String | Int, *subkeys : Int | String) abstract def raw end # :nodoc: struct JSON::Any include Athena::Serializer::Any # ameba:disable Naming/PredicateName def is_nil? : Bool @raw.nil? end end # :nodoc: struct YAML::Any include Athena::Serializer::Any # ameba:disable Naming/PredicateName def is_nil? : Bool @raw.nil? end end ================================================ FILE: src/components/serializer/src/athena-serializer.cr ================================================ require "semantic_version" require "uuid" require "json" require "yaml" require "athena-dependency_injection" require "./annotations" require "./any" require "./context" require "./serializable" require "./serializer_interface" require "./serializer" require "./property_metadata" require "./deserialization_context" require "./serialization_context" require "./construction/*" require "./exception/*" require "./exclusion_strategies/*" require "./navigators/*" require "./visitors/*" # Convenience alias to make referencing `Athena::Serializer` types easier. alias ASR = Athena::Serializer # Convenience alias to make referencing `Athena::Serializer::Annotations` types easier. alias ASRA = Athena::Serializer::Annotations # :nodoc: module JSON; end # :nodoc: module YAML; end # Provides enhanced (de)serialization features. module Athena::Serializer VERSION = "0.4.3" # Returns an `ASR::SerializerInterface` instance for ad-hoc (de)serialization. # # The serializer is cached and only instantiated once. class_getter serializer : ASR::SerializerInterface { ASR::Serializer.new } # The built-in supported formats. enum Format JSON YAML # Returns the `ASR::Visitors::SerializationVisitorInterface` related to `self`. def serialization_visitor case self in .json? then ASR::Visitors::JSONSerializationVisitor in .yaml? then ASR::Visitors::YAMLSerializationVisitor end end # Returns the `ASR::Visitors::DeserializationVisitorInterface` related to `self`. def deserialization_visitor case self in .json? then ASR::Visitors::JSONDeserializationVisitor in .yaml? then ASR::Visitors::YAMLDeserializationVisitor end end end # Contains all custom exceptions defined within `Athena::Serializer`. # Also acts as a marker that can be used to rescue all serializer related exceptions. module Exception; end # Exclusion Strategies allow controlling which properties should be (de)serialized. # # `Athena::Serializer` includes two common strategies: `ASR::ExclusionStrategies::Groups`, and `ASR::ExclusionStrategies::Version`. # # Custom strategies can be implemented by via `ExclusionStrategies::ExclusionStrategyInterface`. # # !!!todo # Once feasible, support compile time exclusion strategies. module ExclusionStrategies; end # Used to denote a type that is (de)serializable. # # This module can be used to make the compiler happy in some situations, it doesn't do anything on its own. # You most likely want to use `ASR::Serializable` instead. # # ``` # require "athena-serializer" # # abstract struct BaseModel # # `ASR::Model` is needed here to ensure typings are correct for the deserialization process. # # Child types should still include `ASR::Serializable`. # include ASR::Model # end # # record ModelOne < BaseModel, id : Int32, name : String do # include ASR::Serializable # end # # record ModelTwo < BaseModel, id : Int32, name : String do # include ASR::Serializable # end # # record Unionable, type : BaseModel.class # ``` module Model; end end ================================================ FILE: src/components/serializer/src/construction/instantiate_object_constructor.cr ================================================ require "./object_constructor_interface" # Default `ASR::ObjectConstructorInterface` implementation. # # Directly instantiates the object via a custom initializer added by `ASR::Serializable`. struct Athena::Serializer::InstantiateObjectConstructor include Athena::Serializer::ObjectConstructorInterface # :inherit: def construct(navigator : ASR::Navigators::DeserializationNavigatorInterface, properties : Array(PropertyMetadataBase), data : ASR::Any, type) type.new navigator, properties, data end end ================================================ FILE: src/components/serializer/src/construction/object_constructor_interface.cr ================================================ # Determines how a new object is constructed during deserialization. # # By default it is directly instantiated via `.new` as part of `ASR::InstantiateObjectConstructor`. # # However custom constructors can be defined. A use case could be retrieving the object from the database as part of a `PUT` request in order # to apply the deserialized data onto it. This would allow it to retain the PK, any timestamps, or `ASRA::ReadOnly` values. module Athena::Serializer::ObjectConstructorInterface # Creates an instance of *type* and applies the provided *properties* onto it, with the provided *data*. abstract def construct(navigator : ASR::Navigators::DeserializationNavigatorInterface, properties : Array(PropertyMetadataBase), data : ASR::Any, type) end ================================================ FILE: src/components/serializer/src/context.cr ================================================ # Stores runtime data about the current action. # # Such as what serialization groups/version to use when serializing. # # !!!warning # Cannot be used for more than one action. abstract class Athena::Serializer::Context # The possible (de)serialization actions. enum Direction Deserialization Serialization end # The `ASR::ExclusionStrategies::ExclusionStrategyInterface` being used. getter exclusion_strategy : ASR::ExclusionStrategies::ExclusionStrategyInterface? @initialized : Bool = false # Returns the serialization groups, if any, currently set on `self`. getter groups : Set(String)? = nil # Returns the version, if any, currently set on `self`. property version : SemanticVersion? = nil # Returns which (de)serialization action `self` represents. abstract def direction : ASR::Context::Direction # Adds *strategy* to `self`. # # * `exclusion_strategy` is set to *strategy* if there previously was no strategy. # * `exclusion_strategy` is set to `ASR::ExclusionStrategies::Disjunct` if there was a `exclusion_strategy` already set. # * *strategy* is added to the `ASR::ExclusionStrategies::Disjunct` if there are multiple strategies. def add_exclusion_strategy(strategy : ASR::ExclusionStrategies::ExclusionStrategyInterface) : self current_strategy = @exclusion_strategy case current_strategy when Nil then @exclusion_strategy = strategy when ASR::ExclusionStrategies::Disjunct then current_strategy.members << strategy else @exclusion_strategy = ASR::ExclusionStrategies::Disjunct.new [current_strategy, strategy] end self end # :nodoc: def init : Nil raise ASR::Exception::Logic.new "This context was already initialized, and cannot be re-used." if @initialized if v = @version add_exclusion_strategy ASR::ExclusionStrategies::Version.new v end if g = @groups add_exclusion_strategy ASR::ExclusionStrategies::Groups.new g end @initialized = true end # Sets the group(s) to compare against properties' `ASRA::Groups` annotations. # # Adds a `ASR::ExclusionStrategies::Groups` automatically if set. def groups=(groups : Enumerable(String)) : self raise ArgumentError.new "Groups cannot be empty" if groups.empty? @groups = groups.to_set self end # Sets the *version* to compare against properties' `ASRA::Since` and `ASRA::Until` annotations. # # Adds an `ASR::ExclusionStrategies::Version` automatically if set. def version=(version : String) : self @version = SemanticVersion.parse version self end end ================================================ FILE: src/components/serializer/src/deserialization_context.cr ================================================ # The `ASR::Context` specific to deserialization. class Athena::Serializer::DeserializationContext < Athena::Serializer::Context def direction : ASR::Context::Direction ASR::Context::Direction::Deserialization end end ================================================ FILE: src/components/serializer/src/exception/deserialization_exception.cr ================================================ # Represents an error that occurred during deserialization. class Athena::Serializer::Exception::DeserializationException < RuntimeError include Athena::Serializer::Exception end ================================================ FILE: src/components/serializer/src/exception/logic.cr ================================================ class Athena::Serializer::Exception::Logic < ::Exception include Athena::Serializer::Exception end ================================================ FILE: src/components/serializer/src/exception/missing_required_property.cr ================================================ require "./property_exception" # Represents an error due to a missing required property that was not included in the input data. # # Exposes the missing property's name and type. class Athena::Serializer::Exception::MissingRequiredProperty < Athena::Serializer::Exception::PropertyException getter property_type : String def initialize(property_name : String, @property_type : String) super "Missing required property: '#{property_name}'.", property_name end end ================================================ FILE: src/components/serializer/src/exception/nil_required_property.cr ================================================ require "./property_exception" # Represents an error due to a required property that was `nil`. # # Exposes the property's name and type. class Athena::Serializer::Exception::NilRequiredProperty < Athena::Serializer::Exception::PropertyException getter property_type : String def initialize(property_name : String, @property_type : String) super "Required property '#{property_name}' cannot be nil.", property_name end end ================================================ FILE: src/components/serializer/src/exception/property_exception.cr ================================================ # Represents an error due to an invalid property. # # Exposes the property's name. class Athena::Serializer::Exception::PropertyException < RuntimeError include Athena::Serializer::Exception getter property_name : String def initialize(message : String, @property_name : String) super message end end ================================================ FILE: src/components/serializer/src/exception/serialization_exception.cr ================================================ # Represents an error that occurred during serialization. class Athena::Serializer::Exception::SerializationException < RuntimeError include Athena::Serializer::Exception end ================================================ FILE: src/components/serializer/src/exclusion_strategies/disjunct.cr ================================================ require "./exclusion_strategy_interface" # Wraps an `Array(ASR::ExclusionStrategies::ExclusionStrategyInterface)`, excluding a property if any member skips it. # # Used internally to allow multiple exclusion strategies to be used within a single instance variable for `ASR::Context#add_exclusion_strategy`. struct Athena::Serializer::ExclusionStrategies::Disjunct include Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface # The wrapped exclusion strategies. getter members : Array(ASR::ExclusionStrategies::ExclusionStrategyInterface) def initialize(@members : Array(ASR::ExclusionStrategies::ExclusionStrategyInterface)); end # :inherit: def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool @members.any?(&.skip_property?(metadata, context)) end end ================================================ FILE: src/components/serializer/src/exclusion_strategies/exclusion_strategy_interface.cr ================================================ # Represents a specific exclusion strategy. # # Custom logic can be implemented by defining a type with this interface. # It can then be used via `ASR::Context#add_exclusion_strategy`. # # ## Example # # ``` # struct OddNumberExclusionStrategy # include Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface # # # :inherit: # # # # Skips serializing odd numbered values # def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool # # Don't skip if the value is nil # return false unless value = (metadata.value) # # # Only skip on serialization, if the value is an number, and if it's odd. # context.is_a?(ASR::SerializationContext) && value.is_a?(Number) && value.odd? # end # end # # serialization_context = ASR::SerializationContext.new # serialization_context.add_exclusion_strategy OddNumberExclusionStrategy.new # # deserialization_context = ASR::DeserializationContext.new # deserialization_context.add_exclusion_strategy OddNumberExclusionStrategy.new # # record Values, one : Int32 = 1, two : Int32 = 2, three : Int32 = 3 do # include ASR::Serializable # end # # ASR.serializer.serialize Values.new, :json, serialization_context # => {"two":2} # ASR.serializer.deserialize Values, %({"one":4,"two":5,"three":6}), :json, deserialization_context # => Values(@one=4, @three=6, @two=5) # ``` # # ### Annotation Configurations # # Custom annotations can be defined using `ADI.configuration_annotation`. # These annotations will be exposed at runtime as part of the properties' metadata within exclusion strategies via `ASR::PropertyMetadata#annotation_configurations`. # The main purpose of this is to allow for more advanced annotation based exclusion strategies. # # ``` # # Define an annotation called `IsActiveProperty` that accepts an optional `active` field. # ADI.configuration_annotation IsActiveProperty, active : Bool = true # # # Define an exclusion strategy that should skip "inactive" properties. # struct ActivePropertyExclusionStrategy # include Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface # # # :inherit: # def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool # # Don't skip on deserialization. # return false if context.direction.deserialization? # # ann_configs = metadata.annotation_configurations # # # Skip if the property has the annotation and it's "inactive". # ann_configs.has?(IsActiveProperty) && !ann_configs[IsActiveProperty].active # end # end # # record Example, id : Int32, first_name : String, last_name : String, zip_code : Int32 do # include ASR::Serializable # # @[IsActiveProperty] # @first_name : String # # @[IsActiveProperty(active: false)] # @last_name : String # # # Can also be defined as a positional argument. # @[IsActiveProperty(false)] # @zip_code : Int32 # end # # serialization_context = ASR::SerializationContext.new # serialization_context.add_exclusion_strategy ActivePropertyExclusionStrategy.new # # ASR.serializer.serialize Example.new(1, "Jon", "Snow", 90210), :json, serialization_context # => {"id":1,"first_name":"Jon"} # ``` module Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface # Returns `true` if a property should _NOT_ be (de)serialized. abstract def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool end ================================================ FILE: src/components/serializer/src/exclusion_strategies/groups.cr ================================================ require "./exclusion_strategy_interface" # Allows creating different views of your objects by limiting which properties get serialized, based on the group(s) each property is a part of. # # It is enabled by default when using `ASR::Context#groups=`. # # ``` # class Example # include ASR::Serializable # # def initialize; end # # @[ASRA::Groups("list", "details")] # property id : Int64 = 1 # # @[ASRA::Groups("list", "details")] # property title : String = "TITLE" # # @[ASRA::Groups("list")] # property comment_summaries : Array(String) = ["Sentence 1.", "Sentence 2."] # # @[ASRA::Groups("details")] # property comments : Array(String) = ["Sentence 1. Another sentence.", "Sentence 2. Some other stuff."] # # # Properties not explicitly given a group are added to the `"default"` group. # property created_at : Time = Time.utc(2019, 1, 1) # property updated_at : Time? # end # # obj = Example.new # # ASR.serializer.serialize obj, :json, ASR::SerializationContext.new.groups = ["list"] # => {"id":1,"title":"TITLE","comment_summaries":["Sentence 1.","Sentence 2."]} # ASR.serializer.serialize obj, :json, ASR::SerializationContext.new.groups = ["details"] # => {"id":1,"title":"TITLE","comments":["Sentence 1. Another sentence.","Sentence 2. Some other stuff."]} # ASR.serializer.serialize obj, :json, ASR::SerializationContext.new.groups = ["list", "default"] # => {"id":1,"title":"TITLE","comment_summaries":["Sentence 1.","Sentence 2."],"created_at":"2019-01-01T00:00:00Z"} # ``` struct Athena::Serializer::ExclusionStrategies::Groups include Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface @groups : Set(String) def initialize(groups : Enumerable(String)) @groups = groups.to_set end def self.new(*groups : String) new groups end # :inherit: def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool (metadata.groups & @groups).empty? end end ================================================ FILE: src/components/serializer/src/exclusion_strategies/version.cr ================================================ require "./exclusion_strategy_interface" # Serialize properties based on a `SemanticVersion` string. # # It is enabled by default when using `ASR::Context#version=`. # # ``` # class Example # include ASR::Serializable # # def initialize; end # # @[ASRA::Until("1.0.0")] # property name : String = "Legacy Name" # # @[ASRA::Since("1.1.0")] # property name2 : String = "New Name" # end # # obj = Example.new # # ASR.serializer.serialize obj, :json, ASR::SerializationContext.new.version = "0.30.0" # => {"name":"Legacy Name"} # ASR.serializer.serialize obj, :json, ASR::SerializationContext.new.version = "1.2.0" # => {"name2":"New Name"} # ``` struct Athena::Serializer::ExclusionStrategies::Version include Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface getter version : SemanticVersion def initialize(@version : SemanticVersion); end # :inherit: def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool # Skip if *version* is not at least *since_version*. return true if (since_version = metadata.since_version) && @version < since_version # Skip if *version* is greater than or equal to than *until_version*. return true if (until_version = metadata.until_version) && @version >= until_version false end end ================================================ FILE: src/components/serializer/src/navigators/deserialization_navigator.cr ================================================ module Athena::Serializer::Navigators::DeserializationNavigatorInterface abstract def accept(type : T.class, data : ASR::Any) forall T end struct Athena::Serializer::Navigators::DeserializationNavigator include Athena::Serializer::Navigators::DeserializationNavigatorInterface def initialize( @visitor : ASR::Visitors::DeserializationVisitorInterface, @context : ASR::DeserializationContext, @object_constructor : ASR::ObjectConstructorInterface, ); end def accept(type : T.class, data : ASR::Any) forall T {% unless T.instance <= ASR::Model %} {% if T.class.has_method? :deserialize %} @visitor.visit type, data {% end %} {% else %} {% if ann = T.instance.annotation(ASRA::Discriminator) %} if key = data[{{ann[:key]}}]? type = case key {% for k, t in ann[:map] %} when {{k.id.stringify}} then {{t}} {% end %} else raise ASR::Exception::PropertyException.new "Unknown '#{{{ann[:key]}}}' discriminator value: '#{key}'.", {{ann[:key].id.stringify}} end else raise ASR::Exception::PropertyException.new "Missing discriminator field '#{{{ann[:key]}}}'.", {{ann[:key].id.stringify}} end {% end %} properties = type.deserialization_properties # Apply exclusion strategies if one is defined if strategy = @context.exclusion_strategy properties.reject! { |property| strategy.skip_property? property, @context } end object = @object_constructor.construct self, properties, data, type object.run_postdeserialize object {% end %} end end ================================================ FILE: src/components/serializer/src/navigators/navigator_factory.cr ================================================ module Athena::Serializer::Navigators::NavigatorFactoryInterface abstract def get_serialization_navigator(visitor : ASR::Visitors::SerializationVisitorInterface, context : ASR::SerializationContext) : ASR::Navigators::SerializationNavigatorInterface abstract def get_deserialization_navigator(visitor : ASR::Visitors::DeserializationVisitorInterface, context : ASR::DeserializationContext) : ASR::Navigators::DeserializationNavigatorInterface end struct Athena::Serializer::Navigators::NavigatorFactory include Athena::Serializer::Navigators::NavigatorFactoryInterface def initialize(@object_constructor : ASR::ObjectConstructorInterface = ASR::InstantiateObjectConstructor.new); end def get_serialization_navigator(visitor : ASR::Visitors::SerializationVisitorInterface, context : ASR::SerializationContext) : ASR::Navigators::SerializationNavigatorInterface ASR::Navigators::SerializationNavigator.new visitor, context end def get_deserialization_navigator(visitor : ASR::Visitors::DeserializationVisitorInterface, context : ASR::DeserializationContext) : ASR::Navigators::DeserializationNavigatorInterface ASR::Navigators::DeserializationNavigator.new visitor, context, @object_constructor end end ================================================ FILE: src/components/serializer/src/navigators/serialization_navigator.cr ================================================ module Athena::Serializer::Navigators::SerializationNavigatorInterface abstract def accept(data : ASR::Model) : Nil abstract def accept(data : _) : Nil end struct Athena::Serializer::Navigators::SerializationNavigator include Athena::Serializer::Navigators::SerializationNavigatorInterface def initialize(@visitor : ASR::Visitors::SerializationVisitorInterface, @context : ASR::SerializationContext); end def accept(data : ASR::Model) : Nil data.run_preserialize properties = data.serialization_properties # Apply exclusion strategies if one is defined if strategy = @context.exclusion_strategy properties.reject! { |property| strategy.skip_property? property, @context } end # Reject properties that should be skipped when empty # or properties that should be skipped when nil properties.reject! do |property| val = property.value skip_when_empty = property.skip_when_empty? && val.responds_to? :empty? && val.empty? skip_nil = !@context.emit_nil? && val.nil? skip_when_empty || skip_nil end # Process properties @visitor.visit properties data.run_postserialize end def accept(data : _) : Nil @visitor.visit data end end ================================================ FILE: src/components/serializer/src/property_metadata.cr ================================================ # Parent type of a property metadata just used for typing. # # See `ASR::PropertyMetadata`. module Athena::Serializer::PropertyMetadataBase; end # Stores metadata related to a specific property. # # This includes its name (internal and external), value, versions/groups, and any aliases. struct Athena::Serializer::PropertyMetadata(IvarType, ValueType) include Athena::Serializer::PropertyMetadataBase # The name of the property. getter name : String # The name that should be used for serialization/deserialization. getter external_name : String # The value of the property (when serializing). getter value : ValueType # The type of the property. getter type : IvarType.class = IvarType # Represents the first version this property is available. # # See `ASR::ExclusionStrategies::Version`. property since_version : SemanticVersion? # Represents the last version this property was available. # # See `ASR::ExclusionStrategies::Version`. property until_version : SemanticVersion? # The serialization groups this property belongs to. # # See `ASR::ExclusionStrategies::Groups`. getter groups : Set(String) = Set{"default"} # Deserialize this property from the property's name or any name in *aliases*. # # See `ASRA::Name`. getter aliases : Array(String) # If this property should not be serialized if it is empty. # # See `ASRA::SkipWhenEmpty`. getter? skip_when_empty : Bool # Returns annotations configurations registered via `ADI..configuration_annotation` and applied to this property. # # These configurations could then be accessed within an `ASR::ExclusionStrategies::ExclusionStrategyInterface`. getter annotation_configurations : ADI::AnnotationConfigurations def initialize( @name : String, @external_name : String, @annotation_configurations : ADI::AnnotationConfigurations, @value : ValueType = nil, @skip_when_empty : Bool = false, groups : Enumerable(String) = ["default"], @aliases : Array(String) = [] of String, @since_version : SemanticVersion? = nil, @until_version : SemanticVersion? = nil, @type : IvarType.class = IvarType, ) @groups = groups.to_set end end ================================================ FILE: src/components/serializer/src/serializable.cr ================================================ # Adds the necessary methods to a `struct`/`class` to allow for (de)serialization of that type. # # ``` # require "athena-serializer" # # record Example, id : Int32, name : String do # include ASR::Serializable # end # # obj = ASR.serializer.deserialize Example, %({"id":1,"name":"George"}), :json # obj # => Example(@id=1, @name="George") # ASR.serializer.serialize obj, :yaml # => # # --- # # id: 1 # # name: George # ``` module Athena::Serializer::Serializable # :nodoc: abstract def serialization_properties : Array(ASR::PropertyMetadataBase) # :nodoc: abstract def run_preserialize : Nil # :nodoc: abstract def run_postserialize : Nil # :nodoc: abstract def run_postdeserialize : Nil macro included {% verbatim do %} include ASR::Model # :nodoc: def run_preserialize : Nil {% for method in @type.methods.select &.annotation(ASRA::PreSerialize) %} {{method.name}} {% end %} end # :nodoc: def run_postserialize : Nil {% for method in @type.methods.select &.annotation(ASRA::PostSerialize) %} {{method.name}} {% end %} end # :nodoc: def run_postdeserialize : Nil {% for method in @type.methods.select &.annotation(ASRA::PostDeserialize) %} {{method.name}} {% end %} end # :nodoc: def serialization_properties : Array(ASR::PropertyMetadataBase) {% begin %} # Construct the array of metadata from the properties on `self`. # Takes into consideration some annotations to control how/when a property should be serialized {% instance_vars = @type.instance_vars .reject(&.annotation(ASRA::Skip)) .reject(&.annotation(ASRA::IgnoreOnSerialize)) .reject do |ivar| not_exposed = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :all && !ivar.annotation(ASRA::Expose) excluded = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :none && ivar.annotation(ASRA::Exclude) !ivar.annotation(ASRA::IgnoreOnDeserialize) && (not_exposed || excluded) end %} {% property_hash = {} of Nil => Nil %} {% for ivar in instance_vars %} {% ivar_name = ivar.name.stringify %} # Determine the serialized name of the ivar: # 1. If the ivar has an `ASRA::Name` annotation with a `serialize` field, use that # 2. If the type has an `ASRA::Name` annotation with a `strategy`, use that strategy # 3. Fallback on the name of the ivar {% external_name = if (name_ann = ivar.annotation(ASRA::Name)) && (serialized_name = name_ann[:serialize] || name_ann[:key]) serialized_name elsif (name_ann = @type.annotation(ASRA::Name)) && (strategy = name_ann[:serialization_strategy] || name_ann[:strategy]) if strategy == :camelcase ivar_name.camelcase lower: true elsif strategy == :underscore ivar_name.underscore elsif strategy == :identical ivar_name else strategy.raise "Invalid ASRA::Name strategy: '#{strategy}'." end else ivar_name end %} {% annotation_configurations = {} of Nil => Nil %} {% for ann_class in ADI::CUSTOM_ANNOTATIONS %} {% ann_class = ann_class.resolve %} {% annotations = [] of Nil %} {% for ann in ivar.annotations ann_class %} {% pos_args = ann.args.empty? ? "Tuple.new".id : ann.args %} {% named_args = ann.named_args.empty? ? "NamedTuple.new".id : ann.named_args %} {% annotations << "#{ann_class}Configuration.new(#{ann.args.empty? ? "".id : "#{ann.args.splat},".id}#{ann.named_args.double_splat})".id %} {% end %} {% annotation_configurations[ann_class] = "#{annotations} of ADI::AnnotationConfigurations::ConfigurationBase".id unless annotations.empty? %} {% end %} {% value = (accessor = ivar.annotation(ASRA::Accessor)) && nil != accessor[:getter] ? accessor[:getter].id : %(@#{ivar.id}).id property_hash[external_name] = %(ASR::PropertyMetadata(#{ivar.type}, typeof(#{value})).new( name: #{ivar.name.stringify}, external_name: #{external_name}, annotation_configurations: ADI::AnnotationConfigurations.new(#{annotation_configurations} of ADI::AnnotationConfigurations::Classes => Array(ADI::AnnotationConfigurations::ConfigurationBase)), value: #{value}, skip_when_empty: #{!!ivar.annotation(ASRA::SkipWhenEmpty)}, groups: #{(ann = ivar.annotation(ASRA::Groups)) && !ann.args.empty? ? [ann.args.splat] : ["default"]}, since_version: #{(ann = ivar.annotation(ASRA::Since)) && nil != ann[0] ? "SemanticVersion.parse(#{ann[0]})".id : nil}, until_version: #{(ann = ivar.annotation(ASRA::Until)) && nil != ann[0] ? "SemanticVersion.parse(#{ann[0]})".id : nil}, )).id %} {% end %} {% for m in @type.methods.select &.annotation(ASRA::VirtualProperty) %} {% method_name = m.name %} {% m.raise "ASRA::VirtualProperty return type must be set for '#{@type.name}##{method_name}'." if m.return_type.is_a? Nop %} {% external_name = (ann = m.annotation(ASRA::Name)) && (name = ann[:serialize]) ? name : m.name.stringify %} {% method_annotation_configurations = {} of Nil => Nil %} {% for ann_class in ADI::CUSTOM_ANNOTATIONS %} {% ann_class = ann_class.resolve %} {% annotations = [] of Nil %} {% for ann in m.annotations ann_class %} {% pos_args = ann.args.empty? ? "Tuple.new".id : ann.args %} {% named_args = ann.named_args.empty? ? "NamedTuple.new".id : ann.named_args %} {% annotations << "#{ann_class}Configuration.new(#{ann.args.empty? ? "".id : "#{ann.args.splat},".id}#{ann.named_args.double_splat})".id %} {% end %} {% method_annotation_configurations[ann_class] = "#{annotations} of ADI::AnnotationConfigurations::ConfigurationBase".id unless annotations.empty? %} {% end %} {% property_hash[external_name] = %(ASR::PropertyMetadata(#{m.return_type}, #{m.return_type}).new( name: #{m.name.stringify}, external_name: #{external_name}, annotation_configurations: ADI::AnnotationConfigurations.new(#{method_annotation_configurations} of ADI::AnnotationConfigurations::Classes => Array(ADI::AnnotationConfigurations::ConfigurationBase)), value: #{m.name.id}, skip_when_empty: #{!!m.annotation(ASRA::SkipWhenEmpty)}, groups: #{(ann = m.annotation(ASRA::Groups)) && !ann.args.empty? ? [ann.args.splat] : ["default"]}, since_version: #{(ann = m.annotation(ASRA::Since)) && nil != ann[0] ? "SemanticVersion.parse(#{ann[0]})".id : nil}, until_version: #{(ann = m.annotation(ASRA::Until)) && nil != ann[0] ? "SemanticVersion.parse(#{ann[0]})".id : nil}, )).id %} {% end %} {% if (ann = @type.annotation(ASRA::AccessorOrder)) && nil != ann[0] %} {% if ann[0] == :alphabetical %} {% properties = property_hash.keys.sort.map { |key| property_hash[key] } %} {% elsif ann[0] == :custom && nil != ann[:order] %} {% ann.raise "Not all properties were defined in the custom order for '#{@type}'." unless property_hash.keys.all? { |prop| ann[:order].map(&.id.stringify).includes? prop } %} {% properties = ann[:order].map { |val| property_hash[val.id.stringify] || raise "Unknown instance variable: '#{val.id}'." } %} {% else %} {% ann.raise "Invalid ASR::AccessorOrder value: '#{ann[0].id}'." %} {% end %} {% else %} {% properties = property_hash.values %} {% end %} {{properties}} of ASR::PropertyMetadataBase {% end %} end # :nodoc: def self.deserialization_properties : Array(ASR::PropertyMetadataBase) {% verbatim do %} {% begin %} # Construct the array of metadata from the properties on `self`. # Takes into consideration some annotations to control how/when a property should be serialized {% instance_vars = @type.instance_vars .reject(&.annotation(ASRA::Skip)) .reject { |ivar| (ann = ivar.annotation(ASRA::ReadOnly)); ann && !ivar.has_default_value? && !ivar.type.nilable? ? ivar.raise "#{@type}##{ivar.name} is read-only but is not nilable nor has a default value" : ann } .reject(&.annotation(ASRA::IgnoreOnDeserialize)) .reject do |ivar| not_exposed = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :all && !ivar.annotation(ASRA::Expose) excluded = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :none && ivar.annotation(ASRA::Exclude) !ivar.annotation(ASRA::IgnoreOnSerialize) && (not_exposed || excluded) end %} {{instance_vars.map do |ivar| ivar_name = ivar.name.stringify annotation_configurations = {} of Nil => Nil ADI::CUSTOM_ANNOTATIONS.each do |ann_class| ann_class = ann_class.resolve annotations = [] of Nil ivar.annotations(ann_class).each do |ann| pos_args = ann.args.empty? ? "Tuple.new".id : ann.args named_args = ann.named_args.empty? ? "NamedTuple.new".id : ann.named_args annotations << "#{ann_class}Configuration.new(#{ann.args.empty? ? "".id : "#{ann.args.splat},".id}#{ann.named_args.double_splat})".id end annotation_configurations[ann_class] = "#{annotations} of ADI::AnnotationConfigurations::ConfigurationBase".id unless annotations.empty? end # Determine the serialized name of the ivar: # 1. If the ivar has an `ASRA::Name` annotation with a `deserialize` field, use that # 2. If the type has an `ASRA::Name` annotation with a `strategy`, use that strategy # 3. Fallback on the name of the ivar external_name = if (name_ann = ivar.annotation(ASRA::Name)) && (deserialized_name = name_ann[:deserialize] || name_ann[:key]) deserialized_name elsif (name_ann = @type.annotation(ASRA::Name)) && (strategy = name_ann[:deserialization_strategy] || name_ann[:strategy]) if strategy == :camelcase ivar_name.camelcase lower: true elsif strategy == :underscore ivar_name.underscore elsif strategy == :identical ivar_name else strategy.raise "Invalid ASRA::Name strategy: '#{strategy}'." end else ivar_name end %(ASR::PropertyMetadata(#{ivar.type}, #{ivar.type}?).new( name: #{ivar.name.stringify}, external_name: #{external_name}, annotation_configurations: ADI::AnnotationConfigurations.new(#{annotation_configurations} of ADI::AnnotationConfigurations::Classes => Array(ADI::AnnotationConfigurations::ConfigurationBase)), aliases: #{(ann = ivar.annotation(ASRA::Name)) && (aliases = ann[:aliases]) ? aliases : "[] of String".id}, groups: #{(ann = ivar.annotation(ASRA::Groups)) && !ann.args.empty? ? [ann.args.splat] : ["default"]}, since_version: #{(ann = ivar.annotation(ASRA::Since)) && nil != ann[0] ? "SemanticVersion.parse(#{ann[0]})".id : nil}, until_version: #{(ann = ivar.annotation(ASRA::Until)) && nil != ann[0] ? "SemanticVersion.parse(#{ann[0]})".id : nil}, )).id end}} of ASR::PropertyMetadataBase {% end %} {% end %} end # :nodoc: def apply(navigator : ASR::Navigators::DeserializationNavigator, properties : Array(ASR::PropertyMetadataBase), data : ASR::Any) self.initialize navigator, properties, data end # :nodoc: def initialize(navigator : ASR::Navigators::DeserializationNavigatorInterface, properties : Array(ASR::PropertyMetadataBase), data : ASR::Any) {% begin %} {% for ivar, idx in @type.instance_vars %} if (prop = properties.find { |p| p.name == {{ivar.name.stringify}} }) && (val = extract_value(prop, data, {{(ann = ivar.annotation(ASRA::Accessor)) ? ann[:path] : nil}})) value = {% if (ann = ivar.annotation(ASRA::Accessor)) && (converter = ann[:converter]) %} {{converter.id}}.deserialize navigator, prop, val {% else %} navigator.accept {{ivar.type}}, val {% end %} unless value.nil? @{{ivar.id}} = value else {% if !ivar.type.nilable? && !ivar.has_default_value? %} raise ASR::Exception::NilRequiredProperty.new {{ivar.name.id.stringify}}, {{ivar.type.id.stringify}} {% end %} end else {% if !ivar.type.nilable? && !ivar.has_default_value? %} raise ASR::Exception::MissingRequiredProperty.new {{ivar.name.id.stringify}}, {{ivar.type.id.stringify}} {% end %} end {% if (ann = ivar.annotation(ASRA::Accessor)) && (setter = ann[:setter]) %} self.{{setter.id}}(@{{ivar.id}}) {% end %} {% end %} {% end %} end # Attempts to extract a value from the *data* for the given *property*. # Returns `nil` if a value could not be extracted. private def extract_value(property : ASR::PropertyMetadataBase, data : ASR::Any, path : Tuple?) : ASR::Any? return nil if data.raw.nil? if path && (value = data.dig?(*path)) return value end if (key = property.aliases.find { |a| data[a]? }) && (value = data[key]?) return value end if value = data[property.external_name]? return value end nil end {% end %} end end ================================================ FILE: src/components/serializer/src/serialization_context.cr ================================================ # The `ASR::Context` specific to serialization. # # Allows specifying if `nil` values should be serialized. class Athena::Serializer::SerializationContext < Athena::Serializer::Context # If `nil` values should be serialized. property? emit_nil : Bool = false def direction : ASR::Context::Direction ASR::Context::Direction::Serialization end end ================================================ FILE: src/components/serializer/src/serializer.cr ================================================ # Default implementation of `ASR::SerializerInterface`. # # Provides the main API used to (de)serialize objects. # # Custom formats can be implemented by creating the required visitors for that type, then overriding `#get_deserialization_visitor_class` and `#get_serialization_visitor_class`. # # ``` # # Redefine the visitor class getters in order to first check for custom formats. # # This assumes these visitor types are defined, with the proper logic to handle # # the (de)serialization process. # struct Athena::Serializer::Serializer # protected def get_deserialization_visitor_class(format : ASR::Format | String) # return MessagePackDeserializationVisitor if format == "message_pack" # # previous_def # end # # protected def get_serialization_visitor_class(format : ASR::Format | String) # return MessagePackSerializationVisitor if format == "message_pack" # # previous_def # end # end # ``` struct Athena::Serializer::Serializer include Athena::Serializer::SerializerInterface def initialize(@navigator_factory : ASR::Navigators::NavigatorFactoryInterface = ASR::Navigators::NavigatorFactory.new); end # :inherit: def deserialize(type : _, data : String | IO, format : ASR::Format | String, context : ASR::DeserializationContext = ASR::DeserializationContext.new) # Initialize the context. Currently just used to apply default exclusion strategies context.init visitor = self.get_deserialization_visitor_class(format).new navigator = @navigator_factory.get_deserialization_navigator visitor, context visitor.navigator = navigator navigator.accept type, visitor.prepare data end # :inherit: def serialize(data : _, format : ASR::Format | String, context : ASR::SerializationContext = ASR::SerializationContext.new, **named_args) : String String.build do |str| serialize data, format, str, context, **named_args end end # :inherit: def serialize(data : _, format : ASR::Format | String, io : IO, context : ASR::SerializationContext = ASR::SerializationContext.new, **named_args) : Nil # Initialize the context. Currently just used to apply default exclusion strategies context.init visitor = self.get_serialization_visitor_class(format).new(io, named_args) navigator = @navigator_factory.get_serialization_navigator visitor, context visitor.navigator = navigator visitor.prepare navigator.accept data visitor.finish end # Returns the `ASR::Visitors::DeserializationVisitorInterface.class` for the given *format*. # # Can be redefined in order to allow resolving custom formats. protected def get_deserialization_visitor_class(format : ASR::Format | String) return format.deserialization_visitor if format.is_a? ASR::Format ASR::Format.parse(format).deserialization_visitor end # Returns the `ASR::Visitors::SerializationVisitorInterface.class` for the given *format*. # # Can be redefined in order to allow resolving custom formats. protected def get_serialization_visitor_class(format : ASR::Format | String) return format.serialization_visitor if format.is_a? ASR::Format ASR::Format.parse(format).serialization_visitor end end ================================================ FILE: src/components/serializer/src/serializer_interface.cr ================================================ # The main entrypoint of `Athena::Serializer`. module Athena::Serializer::SerializerInterface # Deserializes the provided *input_data* in the provided *format* into an instance of *type*, optionally with the provided *context*. abstract def deserialize(type : ASR::Model.class, data : String | IO, format : ASR::Format | String, context : ASR::DeserializationContext = ASR::DeserializationContext.new) # Serializes the provided *data* into *format*, optionally with the provided *context*. abstract def serialize(data : _, format : ASR::Format | String, context : ASR::SerializationContext = ASR::SerializationContext.new, **named_args) : String # Serializes the provided *data* into *format* writing it to the provided *io*, optionally with the provided *context*.= abstract def serialize(data : _, format : ASR::Format | String, io : IO, context : ASR::SerializationContext = ASR::SerializationContext.new, **named_args) : Nil end ================================================ FILE: src/components/serializer/src/visitors/deserialization_visitor.cr ================================================ require "./deserialization_visitor_interface" # Implement deserialization logic based on `ASR::Any` common to all formats. abstract class Athena::Serializer::Visitors::DeserializationVisitor include Athena::Serializer::Visitors::DeserializationVisitorInterface property! navigator : Athena::Serializer::Navigators::DeserializationNavigatorInterface def visit(type : Nil.class, data : ASR::Any) : Nil end def visit(type : _, data : ASR::Any) type.deserialize self, data end def visit(type : T.class, data : _) forall T data.as T end end # Use a macro to build out primitive types {% begin %} {% primitives = { Bool => ".as_bool", Float32 => ".as_f32", Float64 => ".as_f", Int8 => ".as_i.to_i8", Int16 => ".as_i.to_i16", Int32 => ".as_i", Int64 => ".as_i64", UInt8 => ".as_i64.to_u8", UInt16 => ".as_i64.to_u16", UInt32 => ".as_i64.to_u32", UInt64 => ".as_i64.to_u64", String => ".as_s", } %} {% for type, method in primitives %} def {{type}}.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) data{{method.id}} rescue ex : TypeCastError raise ASR::Exception::DeserializationException.new "Could not parse {{type}} from '#{data}'." end {% end %} {% end %} # :nodoc: def Array.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) collection = new data.as_a.each do |item| value = visitor.navigator.accept(T, item) {% if T.nilable? %} collection << value {% else %} value.try { |v| collection << v } {% end %} end collection end # :nodoc: def Set.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) collection = new data.as_a.each do |item| value = visitor.navigator.accept(T, item) {% if T.nilable? %} collection << value {% else %} value.try { |v| collection << v } {% end %} end collection end # :nodoc: def Deque.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) collection = new data.as_a.each do |item| value = visitor.navigator.accept(T, item) {% if T.nilable? %} collection << value {% else %} value.try { |v| collection << v } {% end %} end collection end # :nodoc: def Hash.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) hash = new data.as_h.each do |key, value| value = visitor.navigator.accept(V, value) {% if T.nilable? %} hash[visitor.visit(K, key)] = value {% else %} value.try { |v| hash[visitor.visit(K, key)] = v } {% end %} end hash end # :nodoc: def Tuple.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) arr = data.as_a {% begin %} Tuple.new( {% for type, idx in T %} visitor.visit({{type}}, arr[{{idx}}]), {% end %} ) {% end %} end # :nodoc: def NamedTuple.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) {% begin %} {% for key, type in T %} %var{key.id} = (val = data[{{key.id.stringify}}]?) ? visitor.visit({{type}}, val) : nil {% end %} {% for key, type in T %} if %var{key.id}.nil? && !{{type.nilable?}} raise ASR::Exception::MissingRequiredProperty.new {{key.id.stringify}}, {{type.id.stringify}} end {% end %} { {% for key, type in T %} {{key.id}}: (%var{key.id}).as({{type}}), {% end %} } {% end %} end # :nodoc: def Enum.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) if val = data.as_i64? from_value val elsif val = data.as_s? parse val else raise ASR::Exception::DeserializationException.new "Couldn't parse #{self} from '#{data}'." end end # :nodoc: def Time.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) Time::Format::ISO_8601_DATE_TIME.parse(data.as_s) end # :nodoc: def Union.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) {% begin %} # Try to parse the value as a primitive type first # as its faster than trying to parse a non-primitive type {% for type, index in T %} {% if type == Nil %} return nil if data.is_nil? {% elsif type < Int %} if value = data.as_i64? return {{type}}.new! value end {% elsif type < Float %} if value = data.as_f? return {{type}}.new! value end {% elsif type == Bool || type == String %} value = data.raw.as? {{type}} return value unless value.nil? {% end %} {% end %} {% end %} # Lastly, try to parse a non-primitive type if there are more than 1. {% for type in T %} {% if type == Nil %} return nil if data.is_nil? {% else %} begin return visitor.navigator.accept {{type}}, data rescue ex # Ignore end {% end %} {% end %} raise ASR::Exception::DeserializationException.new "Couldn't parse #{self} from '#{data}'." end ================================================ FILE: src/components/serializer/src/visitors/deserialization_visitor_interface.cr ================================================ module Athena::Serializer::Visitors::DeserializationVisitorInterface abstract def prepare(data : IO | String) : ASR::Any abstract def visit(type : Nil.class, data : ASR::Any) : Nil abstract def visit(type : _, data : ASR::Any) abstract def visit(type : _, data : _) end ================================================ FILE: src/components/serializer/src/visitors/json_deserialization_visitor.cr ================================================ class Athena::Serializer::Visitors::JSONDeserializationVisitor < Athena::Serializer::Visitors::DeserializationVisitor def prepare(data : IO | String) : ASR::Any JSON.parse data end end # :nodoc: def JSON::Any.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) data.as JSON::Any end ================================================ FILE: src/components/serializer/src/visitors/json_serialization_visitor.cr ================================================ require "./serialization_visitor_interface" class Athena::Serializer::Visitors::JSONSerializationVisitor include Athena::Serializer::Visitors::SerializationVisitorInterface property! navigator : Athena::Serializer::Navigators::SerializationNavigatorInterface def initialize(io : IO, named_args : NamedTuple) : Nil @builder = JSON::Builder.new io if indent = named_args["indent"]? @builder.indent = indent end end def prepare : Nil @builder.start_document end def finish : Nil @builder.end_document end # :inherit: def visit(data : Array(PropertyMetadataBase)) : Nil @builder.object do data.each do |prop| @builder.field(prop.external_name) do visit prop.value end end end end def visit(data : Nil) : Nil @builder.null end def visit(data : String | Symbol) : Nil @builder.string data end def visit(data : Number) : Nil @builder.number data end def visit(data : Bool) : Nil @builder.bool data end def visit(data : ASR::Model) : Nil navigator.accept data end def visit(data : Hash | NamedTuple) : Nil @builder.object do data.each do |key, value| @builder.field key.to_s do visit value end end end end def visit(data : Enumerable) : Nil @builder.array do data.each { |v| visit v } end end def visit(data : ASR::Any) : Nil visit data.raw end def visit(data : Time) : Nil visit data.to_rfc3339 end def visit(data : Enum) : Nil visit data.to_s.underscore end def visit(data : UUID) : Nil visit data.to_s end def visit(data : _) : Nil # Set non serializable types to null @builder.null end end ================================================ FILE: src/components/serializer/src/visitors/serialization_visitor_interface.cr ================================================ module Athena::Serializer::Visitors::SerializationVisitorInterface abstract def prepare : Nil abstract def finish : Nil abstract def visit(data : Array(ASR::PropertyMetadataBase)) : Nil abstract def visit(data : Bool) : Nil abstract def visit(data : Enum) : Nil abstract def visit(data : Enumerable) : Nil abstract def visit(data : Hash) : Nil abstract def visit(data : ASR::Any) : Nil abstract def visit(data : NamedTuple) : Nil abstract def visit(data : Nil) : Nil abstract def visit(data : Number) : Nil abstract def visit(data : ASR::Model) : Nil abstract def visit(data : String) : Nil abstract def visit(data : Symbol) : Nil abstract def visit(data : Time) : Nil abstract def visit(data : UUID) : Nil end ================================================ FILE: src/components/serializer/src/visitors/yaml_deserialization_visitor.cr ================================================ class Athena::Serializer::Visitors::YAMLDeserializationVisitor < Athena::Serializer::Visitors::DeserializationVisitor def prepare(data : IO | String) : ASR::Any YAML.parse data end end # :nodoc: def YAML::Any.deserialize(visitor : ASR::Visitors::DeserializationVisitorInterface, data : ASR::Any) data.as YAML::Any end ================================================ FILE: src/components/serializer/src/visitors/yaml_serialization_visitor.cr ================================================ class Athena::Serializer::Visitors::YAMLSerializationVisitor include Athena::Serializer::Visitors::SerializationVisitorInterface property! navigator : Athena::Serializer::Navigators::SerializationNavigatorInterface def initialize(io : IO, named_args : NamedTuple) : Nil @builder = YAML::Builder.new io end def prepare : Nil @builder.start_stream @builder.start_document end def finish : Nil @builder.end_document @builder.end_stream end # :inherit: def visit(data : Array(PropertyMetadataBase)) : Nil @builder.mapping do data.each do |prop| @builder.scalar prop.external_name visit prop.value end end end def visit(data : String | Symbol | Number | Bool | Nil) : Nil @builder.scalar data end def visit(data : ASR::Model) : Nil navigator.accept data end def visit(data : Hash | NamedTuple) : Nil @builder.mapping do data.each do |key, value| @builder.scalar key visit value end end end def visit(data : Enumerable) : Nil @builder.sequence do data.each { |v| visit v } end end def visit(data : ASR::Any) : Nil visit data.raw end def visit(data : Time) : Nil visit data.to_rfc3339 end def visit(data : Enum) : Nil visit data.to_s.underscore end def visit(data : UUID) : Nil visit data.to_s end def visit(data : _) : Nil # Set non serializable types to null @builder.scalar nil end end ================================================ FILE: src/components/spec/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/spec/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/spec/CHANGELOG.md ================================================ # Changelog ## [0.4.2] - 2026-04-19 ### Added - Generate macro code coverage report for `ASPEC::Methods.assert_compiles` ([#642]) (George Dietrich) - Add `ASPEC.compile_time_assert` helper function for use with `assert_compiles` ([#686]) (George Dietrich) - Add ability to add code before/after the actual code of `ASPEC::Methods.assert_compiles` and `ASPEC::Methods.assert_compile_time_error` ([#687]) (George Dietrich) ### Fixed - Fix compile time error when inadvertently using a type name that conflicts with an internal component type ([#678]) (George Dietrich) - Fix incorrect macro code coverage line numbers ([#686]) (George Dietrich) - Fix macro code coverage output file writing on windows ([#696]) (George Dietrich) [0.4.2]: https://github.com/athena-framework/spec/releases/tag/v0.4.2 [#642]: https://github.com/athena-framework/athena/pull/642 [#686]: https://github.com/athena-framework/athena/pull/686 [#687]: https://github.com/athena-framework/athena/pull/687 [#678]: https://github.com/athena-framework/athena/pull/678 [#696]: https://github.com/athena-framework/athena/pull/696 ## [0.4.1] - 2025-11-12 ### Fixed - Fix segfault when interacting with a test case ivar object's ivar that was left uninitialized due to an exception in its initializer, within the `tear_down` method ([#613]) (George Dietrich) [0.4.1]: https://github.com/athena-framework/spec/releases/tag/v0.4.1 [#613]: https://github.com/athena-framework/athena/pull/613 ## [0.4.0] - 2025-09-04 ### Added - Add support for generating macro code coverage reports for `.assert_error` and `.assert_compiles` methods ([#551]) (George Dietrich) ### Removed - Remove `codegen` parameter from `ASPEC::Methods.assert_error` and `ASPEC::Methods.assert_success` ([#551]) (George Dietrich) - Remove `ASPEC::Methods.assert_error` in favor of `ASPEC::Methods.assert_compile_time_error` and `ASPEC::Methods.assert_runtime_error` ([#551]) (George Dietrich) - Remove `ASPEC::Methods.assert_success` in favor of `ASPEC::Methods.assert_compiles` and `ASPEC::Methods.assert_executes` ([#551]) (George Dietrich) [0.4.0]: https://github.com/athena-framework/spec/releases/tag/v0.4.0 [#551]: https://github.com/athena-framework/athena/pull/551 ## [0.3.11] - 2025-05-19 ### Fixed - Fix duplicate test case runs with abstract generic parent test case ([#538]) (George Dietrich) [0.3.11]: https://github.com/athena-framework/spec/releases/tag/v0.3.11 [#538]: https://github.com/athena-framework/athena/pull/538 ## [0.3.10] - 2025-02-08 ### Changed - **Breaking:** prevent defining `ASPEC::TestCase#initialize` methods that accepts arguments/blocks ([#516]) (George Dietrich) [0.3.10]: https://github.com/athena-framework/spec/releases/tag/v0.3.10 [#516]: https://github.com/athena-framework/athena/pull/516 ## [0.3.9] - 2025-01-26 _Administrative release, no functional changes_ [0.3.9]: https://github.com/athena-framework/spec/releases/tag/v0.3.9 ## [0.3.8] - 2024-07-31 ### Added - Add support for using the `CRYSTAL` ENV var to customize binary used for `ASPEC::Methods.assert_error` and `ASPEC::Methods.assert_success` ([#424]) (George Dietrich) [0.3.8]: https://github.com/athena-framework/spec/releases/tag/v0.3.8 [#424]: https://github.com/athena-framework/athena/pull/424 ## [0.3.7] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) [0.3.7]: https://github.com/athena-framework/spec/releases/tag/v0.3.7 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.3.6] - 2023-10-09 _Administrative release, no functional changes_ [0.3.6]: https://github.com/athena-framework/spec/releases/tag/v0.3.6 ## [0.3.5] - 2023-04-26 ### Fixed - Ensure `#before_all` runs exactly once, and before `#initialize` ([#285]) (George Dietrich) [0.3.5]: https://github.com/athena-framework/spec/releases/tag/v0.3.5 [#285]: https://github.com/athena-framework/athena/pull/285 ## [0.3.4] - 2023-03-19 ### Fixed - Fix exceptions not being counted as errors when raised within the `initialize` method of a test case ([#276]) (George Dietrich) - Fix a documentation typo in the `TestWith` example ([#269]) (George Dietrich) [0.3.4]: https://github.com/athena-framework/spec/releases/tag/v0.3.4 [#269]: https://github.com/athena-framework/athena/pull/269 [#276]: https://github.com/athena-framework/athena/pull/276 ## [0.3.3] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) [0.3.3]: https://github.com/athena-framework/spec/releases/tag/v0.3.3 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.3.2] - 2023-01-16 ### Added - Add `ASPEC::TestCase::TestWith` that works similar to the `ASPEC::TestCase::DataProvider` but without needing to create a dedicated method ([#254]) (George Dietrich) [0.3.2]: https://github.com/athena-framework/spec/releases/tag/v0.3.2 [#254]: https://github.com/athena-framework/athena/pull/254 ## [0.3.1] - 2023-01-07 ### Changed - Update the docs to clarify the component needs to be manually installed ([#247]) (George Dietrich) ### Added - Add support for *codegen* for the `ASPEC.assert_error` and `ASPEC.assert_success` methods ([#219]) (George Dietrich) - Add ability to skip running all examples within a test case via the `ASPEC::TestCase::Skip` annotation ([#248]) (George Dietrich) [0.3.1]: https://github.com/athena-framework/spec/releases/tag/v0.3.1 [#219]: https://github.com/athena-framework/athena/pull/219 [#247]: https://github.com/athena-framework/athena/pull/247 [#248]: https://github.com/athena-framework/athena/pull/248 ## [0.3.0] - 2022-05-14 _First release a part of the monorepo._ ### Changed - **Breaking:** change the `assert_error` to no longer be file based. Code should now be provided as a HEREDOC argument to the method ([#173]) (George Dietrich) - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Added - Add `VERSION` constant to `Athena::Spec` namespace ([#166]) (George Dietrich) - Add getting started documentation to API docs ([#172]) (George Dietrich) - Add [ASPEC::Methods.assert_success](https://athenaframework.org/Spec/Methods/#Athena::Spec::Methods#assert_success(code,*,line,file)) ([#173]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/spec/releases/tag/v0.3.0 [#166]: https://github.com/athena-framework/athena/pull/166 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 [#173]: https://github.com/athena-framework/athena/pull/173 ## [0.2.6] - 2021-11-03 ### Fixed - Fix `test` helper macro generating invalid method names by replacing all non alphanumeric chars with `_` ([#12]) (George Dietrich) [0.2.6]: https://github.com/athena-framework/spec/releases/tag/v0.2.6 [#12]: https://github.com/athena-framework/spec/pull/12 ## [0.2.5] - 2021-11-03 ### Fixed - Fix `test` helper macro not actually calling `yield` ([#11]) (George Dietrich) [0.2.5]: https://github.com/athena-framework/spec/releases/tag/v0.2.5 [#11]: https://github.com/athena-framework/spec/pull/11 ## [0.2.4] - 2021-01-29 ### Changed - Finish migration to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#9]) (George Dietrich) [0.2.4]: https://github.com/athena-framework/spec/releases/tag/v0.2.4 [#9]: https://github.com/athena-framework/spec/pull/9 ## [0.2.3] - 2020-12-03 ### Changed - Update `crystal` version to allow version greater than `1.0.0` ([#7]) (George Dietrich) [0.2.3]: https://github.com/athena-framework/spec/releases/tag/v0.2.3 [#7]: https://github.com/athena-framework/spec/pull/7 ## [0.2.2] - 2020-10-02 ### Added - Add support for data providers defined in parent types ([#6]) (George Dietrich) [0.2.2]: https://github.com/athena-framework/spec/releases/tag/v0.2.2 [#6]: https://github.com/athena-framework/spec/pull/6 ## [0.2.1] - 2020-09-25 ### Changed - Changed data provider generated `it` blocks have proper file names and line numbers ([#4]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/spec/releases/tag/v0.2.1 [#4]: https://github.com/athena-framework/spec/pull/4 ## [0.2.0] - 2020-08-08 ### Changed - **Breaking:** require [data providers](https://athenaframework.org/Spec/TestCase/DataProvider/) methods to declare a return type of `Hash`, `NamedTuple`, `Tuple`, or `Array` ([#3]) (George Dietrich) - Changed data provider generated `it` blocks to include the key/index ([#2]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/spec/releases/tag/v0.2.0 [#2]: https://github.com/athena-framework/spec/pull/2 [#3]: https://github.com/athena-framework/spec/pull/3 ## [0.1.0] - 2020-08-06 _Initial release._ [0.1.0]: https://github.com/athena-framework/spec/releases/tag/v0.1.0 ================================================ FILE: src/components/spec/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/spec/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2021 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/spec/README.md ================================================ # Spec [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/spec.svg)](https://github.com/athena-framework/spec/releases) Common/helpful Spec compliant testing utilities ## Getting Started Checkout the [Documentation](https://athenaframework.org/Spec). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/spec/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.4.0 ### Replace `ASPEC::Methods.assert_error` and `ASPEC::Methods.assert_success` The `ASPEC::Methods.assert_error` and `ASPEC::Methods.assert_success` methods have been removed in favor new methods that more clearly show intent: * If using `.assert_error` _without_ the `codegen` argument (the default), use `.assert_compile_time_error` instead * If using `.assert_error` _with_ `codegen: true` argument, use `.assert_runtime_error` instead * If using `.assert_success` _without_ the `codegen` argument (the default), use `.assert_compiles` instead * If using `.assert_success` _with_ `codegen: true` argument, use `.assert_executes` instead ## Upgrade to 0.3.10 ### `ASPEC::TestCase#initialize` must be argless Previously it was possible to define an `#initialize` method that accepted arguments/a block. This was unintended and now results in a compile time error. ================================================ FILE: src/components/spec/docs/README.md ================================================ The `Athena::Spec` component provides common/helpful [Spec](https://crystal-lang.org/api/Spec.html) compliant testing utilities. NOTE: This component is _NOT_ a standalone testing framework, but is fully intended to be mixed with standard `describe`, `it`, and/or `pending` blocks depending on which approach makes the most sense for what is being tested. ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-spec: github: athena-framework/spec version: ~> 0.4.0 ``` Next, require the shard within your `spec/spec_helper.cr` file, being sure things are required in this order: ```crystal require "spec" require "../src/main" # Or whatever the name of your entrypoint file is called require "athena-spec" ``` Finally, call [ASPEC.run_all](/Spec/top_level/#Athena::Spec.run_all) at the bottom of `spec/spec_helper.cr` to ensure [ASPEC::TestCase](/Spec/TestCase/) based specs are ran as expected. ## Usage A core focus of this component is allowing for a more classic unit testing approach that makes it easy to share/reduce test code duplication. [ASPEC::TestCase](/Spec/TestCase/) being the core type of this. The primary benefit of this approach is that logic is more easily shared/reused as compared to the normal block based approach. I.e. a component can provide a base test case type that can be inherited from, a few methods implemented, and tada. For example, [AVD::Spec::ConstraintValidatorTestCase](/Validator/Spec/ConstraintValidatorTestCase). ```crystal struct ExampleSpec < ASPEC::TestCase def test_add : Nil (1 + 2).should eq 3 end end ``` TIP: The [ASPEC::TestCase::DataProvider](/Spec/TestCase/DataProvider/) and [ASPEC::TestCase::TestWith](/Spec/TestCase/TestWith/) annotations can make testing similar code with different inputs super easy! ================================================ FILE: src/components/spec/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Spec site_url: https://athenaframework.org/Spec/ repo_url: https://github.com/athena-framework/spec nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-spec/src/athena-spec.cr source_locations: lib/athena-spec: https://github.com/athena-framework/spec/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/spec/shard.yml ================================================ name: athena-spec version: 0.4.2 crystal: ~> 1.17 license: MIT repository: https://github.com/athena-framework/spec documentation: https://athenaframework.org/Spec description: | Common/helpful Spec compliant testing utilities. authors: - George Dietrich ================================================ FILE: src/components/spec/spec/athena-spec_spec.cr ================================================ require "./spec_helper" # IDK how to test the testing stuff, # so I'll just use the example and call it good enough. private class Calculator def add(v1, v2) v1 + v2 end def subtract(v1, v2) raise NotImplementedError.new "TODO" end end @[ASPEC::TestCase::Skip] struct SkipSpec < ASPEC::TestCase def test_skipped fail "Test should have been skipped" end end struct ExampleSpec < ASPEC::TestCase @target : Calculator def initialize : Nil @target = Calculator.new end def test_add : Nil @target.add(1, 2).should eq 3 end # A pending test. def ptest_subtract : Nil @target.subtract(10, 5).should eq 5 end test "with macro helper" do @target.add(1, 2).should eq 3 end test "GET /api/:slug" do @target.add(1, 2).should eq 3 end test "123_foo bar" do @target.add(1, 2).should eq 3 end end abstract struct SomeTypeTestCase < ASPEC::TestCase protected abstract def get_object : Calculator def test_common : Nil self.get_object.is_a? Calculator end end struct CalculatorTest < SomeTypeTestCase protected def get_object : Calculator Calculator.new end def test_specific : Nil self.get_object.add(1, 1).should eq 2 end end struct DataProviderTest < ASPEC::TestCase @[DataProvider("get_values_hash")] @[DataProvider("get_values_named_tuple")] def test_squares(value : Int32, expected : Int32) : Nil (value ** 2).should eq expected end def get_values_hash : Hash { "two" => {2, 4}, "three" => {3, 9}, } end def get_values_named_tuple : NamedTuple { four: {4, 16}, five: {5, 25}, } end @[DataProvider("get_values_array")] @[DataProvider("get_values_tuple")] def test_cubes(value : Int32, expected : Int32) : Nil (value ** 3).should eq expected end def get_values_array : Array [ {2, 8}, {3, 27}, ] end def get_values_tuple : Tuple { {4, 64}, {5, 125}, } end end abstract struct AbstractParent < ASPEC::TestCase @[DataProvider("get_values")] def test_cubes(value : Int32, expected : Int32) : Nil value.should eq expected end def get_values : Tuple { {1, 1}, {2, 2}, } end end struct Child < AbstractParent; end struct TestWithTest < ASPEC::TestCase @[TestWith( {4, 64}, {5, 125}, )] def test_cubes(value : Int32, expected : Int32) : Nil (value ** 3).should eq expected end @[TestWith( two: {2, 4}, three: {3, 9}, "with spaces": {4, 16}, )] def test_squares(value : Int32, expected : Int32) : Nil (value ** 2).should eq expected end end struct BeforeAllTest < ASPEC::TestCase @count : Int32 = 0 def initialize @count.should eq 1 end def before_all : Nil @count += 1 end def test_before_all_runs_before_initialize : Nil # no-op end def test_before_all_runs_before_initialize2 : Nil # no-op end end abstract struct GenericTestCase(T) < ASPEC::TestCase end struct GenericIntTest < GenericTestCase(Int32) @@count : Int32 = 0 def test_runs_once 1.should eq 1 @@count += 1 end def after_all : Nil it "runs generic string inheritance test cases only once" { @@count.should eq 1 } end end struct GenericStringTest < GenericTestCase(String) @@count : Int32 = 0 def test_runs_once 1.should eq 1 @@count += 1 end def after_all : Nil it "runs generic int inheritance test cases only once" { @@count.should eq 1 } end end ================================================ FILE: src/components/spec/spec/compiler_spec.cr ================================================ require "./spec_helper" private def assert_compile_time_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compile_time_error message, code, line: line, preamble: %(require "./spec_helper.cr"), postamble: "TestTestCase.run" end private def assert_runtime_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_runtime_error message, <<-CR, line: line require "./spec_helper.cr" #{code} TestTestCase.run CR end private def assert_compiles(code : String, *, line : Int32 = __LINE__) : Nil ASPEC::Methods.assert_compiles code, line: line, preamble: %(require "./spec_helper.cr"), postamble: "ASPEC.run_all" end describe Athena::Spec do describe "compiler errors", tags: "compiled" do describe ASPEC::TestCase::TestWith do describe "args" do it "non tuple value" do assert_compile_time_error "Expected argument #0 of the 'ASPEC::TestCase::TestWith' annotation applied to 'TestTestCase#test_case' to be a Tuple, but got 'NumberLiteral'.", <<-CODE struct TestTestCase < ASPEC::TestCase @[TestWith( 125 )] def test_case(value : Int32, expected : Int32) : Nil end end CODE end it "argument count mismatch" do assert_compile_time_error "Expected argument #0 of the 'ASPEC::TestCase::TestWith' annotation applied to 'TestTestCase#test_case' to contain 2 values, but got 1.", <<-CODE struct TestTestCase < ASPEC::TestCase @[TestWith( {125} )] def test_case(value : Int32, expected : Int32) : Nil end end CODE end end describe "named args" do it "non tuple value" do assert_compile_time_error " Expected the value of argument 'value' of the 'ASPEC::TestCase::TestWith' annotation applied to 'TestTestCase#test_case' to be a Tuple, but got 'NumberLiteral'.", <<-CODE struct TestTestCase < ASPEC::TestCase @[TestWith( value: 125 )] def test_case(value : Int32, expected : Int32) : Nil end end CODE end it "argument count mismatch" do assert_compile_time_error "Expected the value of argument 'value' of the 'ASPEC::TestCase::TestWith' annotation applied to 'TestTestCase#test_case' to contain 2 values, but got 1.", <<-CODE struct TestTestCase < ASPEC::TestCase @[TestWith( value: {125} )] def test_case(value : Int32, expected : Int32) : Nil end end CODE end end end describe "exception during initialize" do it "reports the errors once per test case" do assert_runtime_error "oh noes", <<-CODE struct TestTestCase < ASPEC::TestCase def initialize raise "oh noes" end def test_one 1.should eq 1 end def test_two 2.should eq 2 end end CODE end it "reports actual failing tests" do assert_runtime_error " Expected: 2\n got: 1", <<-CODE struct TestTestCase < ASPEC::TestCase def test_one 1.should eq 2 end end CODE end end it "errors if defining a non-argless initializer" do assert_compile_time_error "`ASPEC::TestCase` initializers must be argless and non-yielding.", <<-CODE struct TestTestCase < ASPEC::TestCase def initialize(id : Int32); end end CODE end it "errors if defining a yielding initializer" do assert_compile_time_error "`ASPEC::TestCase` initializers must be argless and non-yielding.", <<-CODE struct TestTestCase < ASPEC::TestCase def initialize(&); end end CODE end it "bubbles up exceptions that happen when instantiating another object within initialize" do assert_runtime_error "Raise during initialize", <<-CODE struct TestTestCase < ASPEC::TestCase private class Foo # Needs some ivar that is uninitialized getter id : Int32 = 123 def initialize # Makes `@target` become not fully initialized raise "Raise during initialize" end end @target : Foo def initialize @target = Foo.new end def tear_down : Nil # Segfaults accessing uninitialized ivar @target.id.should eq 123 end def test_equals : Nil # no-op end end CODE end it "compiles when a TestCase type conflicts with internal ASPEC types" do assert_compiles <<-CR struct TestCase::Foo < ASPEC::TestCase def test_add (2 + 2).should eq 4 end end CR end end end ================================================ FILE: src/components/spec/spec/methods_spec.cr ================================================ require "./spec_helper" require "file_utils" describe ASPEC::Methods do describe ".assert_compile_time_error", tags: "compiled" do it "allows customizing crystal binary via CRYSTAL env var" do # Do this in its own sub-process to avoid mucking with ENV. message = {% if flag? "windows" %} "The system cannot find the file specified" {% else %} "No such file or directory" {% end %} assert_runtime_error message, <<-CR require "./spec_helper" ENV["CRYSTAL"] = "/path/to/crystal" assert_compile_time_error "", "" CR end it do assert_compile_time_error "can't instantiate abstract class Foo", <<-CR abstract class Foo; end Foo.new CR end end describe ".assert_runtime_error", tags: "compiled" do it do assert_runtime_error "Oh no", <<-CR raise "Oh no" CR end end describe ".assert_compiles" do it tags: "compiled" do assert_compiles <<-CR raise "Oh no" CR end # These run in the unit test suite to ensure this integration also works on other OSs. describe "adjusts macro coverage line numbers for the stdin file" do it "without before/after code" do temp_dir = File.tempname Dir.mkdir_p(temp_dir) ENV["ATHENA_SPEC_COVERAGE_OUTPUT_DIR"] = temp_dir # We expect the line `{% x = 1 %}` to be called. Using __LINE__ and adding 3 keeps this robust if other tests are added/removed/re-arranged. spec_line = __LINE__ + 2 code_line = __LINE__ + 3 ASPEC::Methods.assert_compiles <<-'CR' macro finished {% x = 1 %} end CR coverage_file = Dir.glob(::Path[temp_dir, "macro_coverage.*.codecov.json"]).first coverage_file.should end_with "macro_coverage.methods_spec#L#{spec_line}.codecov.json" File.open coverage_file do |file| coverage = JSON.parse file # Should be 1 coverage file. coverages = coverage.as_h["coverage"].as_h coverages.size.should eq 1 coverages.each_value do |file_coverage| # The expected line number should be called once file_coverage.as_h.should eq({code_line.to_s => 1}) end end ensure ENV.delete("ATHENA_SPEC_COVERAGE_OUTPUT_DIR") FileUtils.rm_rf(temp_dir) if temp_dir end it "with code before" do temp_dir = File.tempname Dir.mkdir_p(temp_dir) ENV["ATHENA_SPEC_COVERAGE_OUTPUT_DIR"] = temp_dir # We expect the line `{% x = 1 %}` to be called. Using __LINE__ and adding 3 keeps this robust if other tests are added/removed/re-arranged. spec_line = __LINE__ + 2 code_line = __LINE__ + 3 ASPEC::Methods.assert_compiles <<-'CR', preamble: %(puts "hi") macro finished {% x = 1 %} end CR coverage_file = Dir.glob(::Path[temp_dir, "macro_coverage.*.codecov.json"]).first coverage_file.should end_with "macro_coverage.methods_spec#L#{spec_line}.codecov.json" File.open coverage_file do |file| coverage = JSON.parse file # Should be 1 coverage file. coverages = coverage.as_h["coverage"].as_h coverages.size.should eq 1 coverages.each_value do |file_coverage| # The expected line number should be called once file_coverage.as_h.should eq({code_line.to_s => 1}) end end ensure ENV.delete("ATHENA_SPEC_COVERAGE_OUTPUT_DIR") FileUtils.rm_rf(temp_dir) if temp_dir end end end describe ".assert_executes", tags: "compiled" do it do assert_executes <<-CR puts 1 + 1 CR end end describe ".run_executable", tags: "compiled" do it "without input" do run_executable "echo", ["foo", "bar"] do |output, error, status| output.should eq "foo bar\n" error.should be_empty status.success?.should be_true end end it "with input" do input = IO::Memory.new "foo\nbar" run_executable "cat", input, ["-e"] do |output, error, status| output.should eq "foo$\nbar" error.should be_empty status.success?.should be_true end end it "with error output" do run_executable "cat", args: ["missing.txt"] do |output, error, status| output.should be_empty error.should contain "No such file or directory" status.success?.should be_false end end end end ================================================ FILE: src/components/spec/spec/spec_helper.cr ================================================ require "spec" require "../src/athena-spec" include ASPEC::Methods ASPEC.run_all ================================================ FILE: src/components/spec/src/athena-spec.cr ================================================ # Convenience alias to make referencing `Athena::Spec` types easier. alias ASPEC = Athena::Spec require "json" require "./methods" require "./test_case" # A set of common [Spec](https://crystal-lang.org/api/Spec.html) compliant testing utilities/types. module Athena::Spec VERSION = "0.4.2" # Asserts a *condition*, raising *message* if it is falsey. # This is primarily intended to be used with `ASPEC::Methods.assert_compiles` to assert state that exists at compile time. # An example of this is how internally Athena's specs do something like this to assert aspects of wired up services are correct: # # ``` # ASPEC::Methods.assert_compiles <<-'CR' # require "../spec_helper" # # @[ADI::Register(public: true)] # record MyService # # macro finished # macro finished # \{% # service = ADI::ServiceContainer::SERVICE_HASH["my_service"] # %} # ASPEC.compile_time_assert(\{{ service["public"] == true }}, "Expected service to be public") # end # end # CR # ``` macro compile_time_assert(condition, message = "Compile-time assertion failed") {% condition.raise message unless condition %} end # Runs all `ASPEC::TestCase`s. # # Is equivalent to manually calling `.run` on each test case. def self.run_all : Nil # `#uniq` is to work around https://github.com/crystal-lang/crystal/issues/15793. {% for unit_test in ASPEC::TestCase.all_subclasses.reject { |tc| tc.abstract? || tc.annotation(ASPEC::TestCase::Skip) }.uniq %} ::{{unit_test.id}}.run {% end %} end end ================================================ FILE: src/components/spec/src/methods.cr ================================================ # Namespace for common/helpful testing methods. # # This module can be included into your `spec_helper` in order # to allow your specs to use them all. This module is also # included into `ASPEC::TestCase` by default to allow using them # within your unit tests as well. # # May be reopened to add additional application specific helpers. module Athena::Spec::Methods extend self # Executes the provided Crystal *code* and asserts it results in a compile time error with the provided *message*. # # ``` # ASPEC::Methods.assert_compile_time_error "can't instantiate abstract class Foo", <<-CR # abstract class Foo; end # Foo.new # CR # ``` # # The *preamble* and *postamble* parameters may be used to add code before/after the actual *code*. # This is primarily useful when wrapping this method in another private method for use within a specific test file to share common before/after code. # # ``` # private def assert_compiles(message : String, code : String, *, line : Int32 = __LINE__) : Nil # ASPEC::Methods.assert_compile_time_error message, code, preamble: %(require "../spec_helper.cr"), postamble: "MyClass.new", line: line # end # ``` # # NOTE: When files are required within the *code*, they are relative to the file calling this method. def assert_compile_time_error(message : String, code : String, *, preamble : String = "", postamble : String = "", line : Int32 = __LINE__, file : String = __FILE__) : Nil full_code = String.build do |str| str.puts preamble unless preamble.empty? str.puts code str.puts postamble unless postamble.empty? end std_out = IO::Memory.new std_err = IO::Memory.new result = execute full_code, std_out, std_err, file, codegen: false, macro_code_coverage: true fail std_err.to_s, line: line if result.success? std_err.to_s.should contain(message), line: line std_err.close # Ignore coverage report output if the output dir is not defined, or if there is no report. # TODO: Maybe default this to something? if !std_out.empty? && (macro_coverage_output_dir = ENV["ATHENA_SPEC_COVERAGE_OUTPUT_DIR"]?.presence) coverage_line_offset = preamble.empty? ? line : line - preamble.count('\n') - 1 File.open ::Path[macro_coverage_output_dir, "macro_coverage.#{Path[file].stem}#L#{line}.codecov.json"], "w" do |coverage_report| coverage_report.print adjust_coverage_line_numbers(std_out, file, coverage_line_offset) end end std_out.close end # Executes the provided Crystal *code* and asserts it results in a runtime error with the provided *message*. # This can be helpful in order to test something in isolation, without affecting other test cases. # # ``` # ASPEC::Methods.assert_runtime_error "Oh noes!", <<-CR # raise "Oh noes!" # CR # ``` # # NOTE: When files are required within the *code*, they are relative to the file calling this method. def assert_runtime_error(message : String, code : String, *, line : Int32 = __LINE__, file : String = __FILE__) : Nil buffer = IO::Memory.new result = execute code, buffer, buffer, file, codegen: true fail buffer.to_s, line: line if result.success? buffer.to_s.should contain(message), line: line buffer.close end # Similar to `.assert_compile_time_error`, but asserts the provided Crystal *code* successfully compiles. # # ``` # ASPEC::Methods.assert_compiles <<-CR # raise "Still passes" # CR # ``` # # The *preamble* and *postamble* parameters may be used to add code before/after the actual *code*. # This is primarily useful when wrapping this method in another private method for use within a specific test file to share common before/after code. # # ``` # private def assert_compiles(code : String, *, line : Int32 = __LINE__) : Nil # ASPEC::Methods.assert_compiles code, preamble: %(require "../spec_helper.cr"), postamble: "MyClass.new", line: line # end # ``` # # NOTE: When files are required within the *code*, they are relative to the file calling this method. def assert_compiles(code : String, *, preamble : String = "", postamble : String = "", line : Int32 = __LINE__, file : String = __FILE__) : Nil full_code = String.build do |str| str.puts preamble unless preamble.empty? str.puts code str.puts postamble unless postamble.empty? end std_out = IO::Memory.new std_err = IO::Memory.new result = execute full_code, std_out, std_err, file, codegen: false, macro_code_coverage: true fail std_err.to_s, line: line unless result.success? std_err.close # Ignore coverage report output if the output dir is not defined, or if there is no report. # TODO: Maybe default this to something? if !std_out.empty? && (macro_coverage_output_dir = ENV["ATHENA_SPEC_COVERAGE_OUTPUT_DIR"]?.presence) coverage_line_offset = preamble.empty? ? line : line - preamble.count('\n') - 1 File.open ::Path[macro_coverage_output_dir, "macro_coverage.#{Path[file].stem}#L#{line}.codecov.json"], "w" do |coverage_report| coverage_report.print adjust_coverage_line_numbers(std_out, file, coverage_line_offset) end end std_out.close end # Similar to `.assert_runtime_error`, but asserts the provided Crystal *code* successfully executes. # # ``` # ASPEC::Methods.assert_executes <<-CR # puts 2 + 2 # CR # ``` # # NOTE: When files are required within the *code*, they are relative to the file calling this method. def assert_executes(code : String, *, line : Int32 = __LINE__, file : String = __FILE__) : Nil buffer = IO::Memory.new result = execute code, buffer, buffer, file, codegen: true fail buffer.to_s, line: line unless result.success? buffer.close end # Adjusts line numbers in the coverage JSON for the stdin file entry. # When code is piped via stdin with --stdin-filename, Crystal reports line numbers relative to the stdin content rather than the actual file. private def adjust_coverage_line_numbers(coverage_output : IO, stdin_filename : String, line_offset : Int32) : String coverage = JSON.parse(coverage_output.rewind.gets_to_end) coverage.as_h["coverage"].as_h.each do |filename, file_coverage| next unless stdin_filename.ends_with? filename file_coverage.as_h.transform_keys! { |key| (key.to_i + line_offset).to_s } end coverage.to_pretty_json end private def execute(code : String, std_out : IO, std_err : IO, file : String, codegen : Bool, macro_code_coverage : Bool = false) : Process::Status input = IO::Memory.new <<-CR #{code} CR args = [] of String if macro_code_coverage args.push "tool", "macro_code_coverage" else args << "run" end args << "--no-color" args.push "--stdin-filename", file args << "--no-codegen" if !macro_code_coverage && !codegen Process.run(ENV["CRYSTAL"]? || "crystal", args, input: input.rewind, output: std_out, error: std_err) end # Runs the executable at the given *path*, optionally with the provided *args*. # # The standard output, error output, and status of the execution are yielded. # # ``` # require "athena-spec" # # ASPEC::Methods.run_executable "/usr/bin/ls" do |output, error, status| # output # => "docs\n" + "LICENSE\n" + "README.md\n" + "shard.yml\n" + "spec\n" + "src\n" # error # => "" # status # => # # end # ``` def run_executable(path : String, args : Array(String) = [] of String, & : String, String, Process::Status ->) : Nil run_executable path, IO::Memory.new, args do |output_io, error_io, status| yield output_io, error_io, status end end # Runs the executable at the given *path*, with the given *input*, optionally with the provided *args*. # # The standard output, error output, and status of the execution are yielded. # # ``` # require "athena-spec" # # input = IO::Memory.new %({"id":1}) # # ASPEC::Methods.run_executable "jq", input, [".", "-c"] do |output, error, status| # output # => "{\"id\":1}\n" # error # => "" # status # => # # end # # invalid_input = IO::Memory.new %({"id"1}) # # ASPEC::Methods.run_executable "jq", invalid_input, [".", "-c"] do |output, error, status| # output # => "" # error # => "parse error: Expected separator between values at line 1, column 7\n" # status # => # # end # ``` def run_executable(path : String, input : IO, args : Array(String) = [] of String, & : String, String, Process::Status ->) : Nil output_io = IO::Memory.new error_io = IO::Memory.new status = Process.run path, args, error: error_io, output: output_io, input: input yield output_io.to_s, error_io.to_s, status end end ================================================ FILE: src/components/spec/src/test_case.cr ================================================ # `ASPEC::TestCase` provides a [Spec](https://crystal-lang.org/api/Spec.html) compliant # alternative DSL for creating unit and integration tests. It allows structuring tests # in a more OOP fashion, with the main benefits of reusability and extendability. # # This type can be extended to share common testing logic with groups of similar types. # Any tests defined within a parent will run for each child test case. # `abstract def`, `super`, and other OOP features can be used as well to reduce duplication. # Some additional features are also built in, such as the `DataProvider`. # # NOTE: This is _NOT_ a standalone testing framework. Everything boils down to standard `describe`, `it`, and/or `pending` blocks. # # A test case consists of a `struct` inheriting from `self`, optionally with an `#initialize` method in order to # initialize the state that should be used for each test. # # A test is a method that starts with `test_`, where the method name is used as the description. # For example, `test_some_method_some_context` becomes `"some method some context"`. # Internally each test method maps to an `it` block. # All of the stdlib's `Spec` assertions methods are available, in addition to # [#pending!](https://crystal-lang.org/api/Spec/Methods.html#pending!%28msg=%22Cannotrunexample%22,file=__FILE__,line=__LINE__%29-instance-method) and # [#fail](https://crystal-lang.org/api/Spec/Methods.html#fail%28msg,file=__FILE__,line=__LINE__%29-instance-method). # # A method may be focused by either prefixing the method name with an `f`, or applying the `Focus` annotation. # # A method may be marked pending by either prefixing the method name with a `p`, or applying the `Pending` annotation. # Internally this maps to a `pending` block. # # Tags may be applied to a method via the `Tags` annotation. # # The `Tags`, `Focus`, and `Pending` annotations may also be applied to the test case type as well, with a similar affect. # # ### Example # # ``` # # Require the stdlib's spec module. # require "spec" # # # Define a class to test. # class Calculator # def add(v1, v2) # v1 + v2 # end # # def subtract(v1, v2) # raise NotImplementedError.new "TODO" # end # end # # # An example test case. # struct ExampleSpec < ASPEC::TestCase # @target : Calculator # # # Initialize the test target along with any dependencies. # def initialize : Nil # @target = Calculator.new # end # # # All of the stdlib's `Spec` methods can be used, # # plus any custom methods defined in `ASPEC::Methods`. # def test_add : Nil # @target.add(1, 2).should eq 3 # end # # # A pending test. # def ptest_subtract : Nil # @target.subtract(10, 5).should eq 5 # end # # # Private/protected methods can be used to reduce duplication within the context of single test case. # private def helper_method # # ... # end # end # ``` # # ## Inheritance # # Inheritance can be used to build reusable test cases for groups of similar objects # # ``` # abstract struct SomeTypeTestCase < ASPEC::TestCase # # Require children to define a method to get the object. # protected abstract def get_object : Calculator # # # Test cases can use the abstract method for tests common to all test cases of this type. # def test_common : Nil # obj = self.get_object # # # ... # end # end # # struct CalculatorTest < SomeTypeTestCase # protected def get_object : Calculator # Calculator.new # end # # # Additional tests specific to this type. # def test_specific : Nil # # ... # end # end # ``` # # ## Data Providers # # A `DataProvider` can be used to reduce duplication, see the corresponding annotation or more information. # # ``` # struct DataProviderTest < ASPEC::TestCase # # Data Providers allow reusing a test's multiple times with different input. # @[DataProvider("get_values")] # def test_squares(value : Int32, expected : Int32) : Nil # (value ** 2).should eq expected # end # # # Returns a hash where the key represents the name of the test, # # and the value is a Tuple of data that should be provided to the test. # def get_values : Hash # { # "two" => {2, 4}, # "three" => {3, 9}, # } # end # end # ``` # # ``` # # Run all the test cases # ASPEC.run_all # => # # ExampleSpec # # add # # subtract # # a custom method name # # CalculatorTest # # common # # specific # # DataProviderTest # # squares two # # squares three # # # # Pending: # # ExampleSpec subtract # # # # Finished in 172 microseconds # # 7 examples, 0 failures, 0 errors, 1 pending # ``` # # ### TestWith # # The `TestWith` annotation is similar to `DataProvider`, but can be a bit simpler to use if the data doesn't need shared between multiple test methods. # # ``` # struct TestWithTest < ASPEC::TestCase # @[TestWith( # two: {2, 4}, # three: {3, 9}, # four: {4, 16}, # five: {5, 25}, # )] # def test_squares(value : Int32, expected : Int32) : Nil # (value ** 2).should eq expected # end # # @[TestWith( # {2, 8}, # {3, 27}, # {4, 64}, # {5, 125}, # )] # def test_cubes(value : Int32, expected : Int32) : Nil # (value ** 3).should eq expected # end # end # ``` abstract struct Athena::Spec::TestCase include Athena::Spec::Methods # Defines the tags tied to a specific test case (describe block) or method (it block). # # Maps to [Tagging Specs](https://crystal-lang.org/reference/guides/testing.html#tagging-specs) in the stdlib. annotation Tags; end # Focuses a specific test case (describe block) or method (it block). # # Maps to [Focusing Specs](https://crystal-lang.org/reference/guides/testing.html#focusing-on-a-group-of-specs) in the stdlib. annotation Focus; end # Marks a specific test case (describe block) or method (it block) as `pending`. # # Maps to the stdlib's [#pending](https://crystal-lang.org/api/master/Spec/Methods.html#pending%28description=%22assert%22,file=__FILE__,line=__LINE__,end_line=__END_LINE__,focus:Bool=false,tags:String%7CEnumerable%28String%29%7CNil=nil,&%29-instance-method) method. annotation Pending; end # Can be applied to an `ASPEC::TestCase` type to denote it should be skipped when running tests via `ASPEC.run_all`. # Useful for creating mock types, or to have more control over when it should be ran. annotation Skip; end # Tests can be defined with arbitrary arguments. These arguments are provided by one or more `DataProvider`. # # A data provider is a method that returns either a `Hash`, `NamedTuple`, `Array`, or `Tuple`. # # NOTE: The method's return type must be set to one of those types. # # If the return type is a `Hash` or `NamedTuple` then it is a keyed provider; # the key will be used as part of the description for each test. # # If the return type is an `Array` or `Tuple` it is considered a keyless provider; # the index will be used as part of the description for each test. # # NOTE: In both cases the value must be a `Tuple`; the values should be an ordered list of the arguments you want to provide to the test. # # One or more `DataProvider` annotations can be applied to a test # with a positional argument of the name of the providing methods. # An `it` block will be defined for each "set" of data. # # Data providers can be a very powerful tool when combined with inheritance and `abstract def`s. # A parent test case could define all the testing logic, and child implementations only provide the data. # # ### Example # # ``` # require "athena-spec" # # struct DataProviderTest < ASPEC::TestCase # @[DataProvider("get_values_hash")] # @[DataProvider("get_values_named_tuple")] # def test_squares(value : Int32, expected : Int32) : Nil # (value ** 2).should eq expected # end # # # A keyed provider using a Hash. # def get_values_hash : Hash # { # "two" => {2, 4}, # "three" => {3, 9}, # } # end # # # A keyed provider using a NamedTuple. # def get_values_named_tuple : NamedTuple # { # four: {4, 16}, # five: {5, 25}, # } # end # # @[DataProvider("get_values_array")] # @[DataProvider("get_values_tuple")] # def test_cubes(value : Int32, expected : Int32) : Nil # (value ** 3).should eq expected # end # # # A keyless provider using an Array. # def get_values_array : Array # [ # {2, 8}, # {3, 27}, # ] # end # # # A keyless provider using a Tuple. # def get_values_tuple : Tuple # { # {4, 64}, # {5, 125}, # } # end # end # # DataProviderTest.run # => # # DataProviderTest # # squares two # # squares three # # squares four # # squares five # # cubes 0 # # cubes 1 # # cubes 2 # # cubes 3 # ``` annotation DataProvider; end # Instead of created a dedicated methods for use with `DataProvider`, you can define a data set using the `TestWith` annotation. # The annotations accepts a variadic amount of `Tuple` positional/named arguments and will create a `it` case for each "set" of data. # # ### Example # # ``` # require "athena-spec" # # struct TestWithTest < ASPEC::TestCase # @[TestWith( # two: {2, 4}, # three: {3, 9}, # four: {4, 16}, # five: {5, 25}, # )] # def test_squares(value : Int32, expected : Int32) : Nil # (value ** 2).should eq expected # end # # @[TestWith( # {2, 8}, # {3, 27}, # {4, 64}, # {5, 125}, # )] # def test_cubes(value : Int32, expected : Int32) : Nil # (value ** 3).should eq expected # end # end # # TestWithTest.run # => # # TestWithTest # # squares two # # squares three # # squares four # # squares five # # cubes 0 # # cubes 1 # # cubes 2 # # cubes 3 # ``` annotation TestWith; end # :nodoc: def self.construct instance = allocate instance.initialize __init: nil instance end # :nodoc: def initialize(__init init : Nil) end macro inherited macro finished {% verbatim do %} {% @type.methods.select(&.name.==("initialize")).each do |a_def| if a_def.accepts_block? || a_def.args.size > 0 a_def.raise "`ASPEC::TestCase` initializers must be argless and non-yielding." end end %} {% end %} end end # Runs the tests contained within `self`. # # See `Athena::Spec.run_all` to run all test cases. def self.run : Nil instance = construct {% begin %} {{!!@type.annotation(Pending) ? "pending".id : "describe".id}} {{@type.name.stringify}}, focus: {{!!@type.annotation Focus}}{% if (tags = @type.annotation(Tags)) %}, tags: {{tags.args}}{% end %} do before_all do instance.before_all # Run this here to validate the instance is valid before calling tear_down, # which could possibly lead to segfaults if there was an exception raised during # initialization of an object when assigning an ivar in initialize and some state of that object is interacted with. instance.initialize end before_each do instance.initialize end after_each do instance.tear_down end after_all do instance.after_all end {% methods = [] of Nil %} {% for parent in @type.ancestors.select &.<(TestCase) %} {% for method in parent.methods.select { |m| m.name =~ /^(?:f|p)?test_/ } %} {% methods << method %} {% end %} {% end %} {% for test in methods + @type.methods.select { |m| m.name =~ /^(?:f|p)?test_/ } %} {% focus = test.name.starts_with?("ftest_") || !!test.annotation Focus %} {% tags = (tags = test.annotation(Tags)) ? tags.args : nil %} {% method = (test.name.starts_with?("ptest_") || !!test.annotation Pending) ? "pending" : "it" %} {% description = test.name.stringify.gsub(/^(?:f|p)?test_/, "").underscore.gsub(/_/, " ") %} {% if test_with = test.annotation(TestWith) %} # Treat args as Array/Tuple data providers {% for args, idx in test_with.args %} {% args.raise "Expected argument ##{idx} of the 'ASPEC::TestCase::TestWith' annotation applied to '#{@type}##{test.name.id}' to be a Tuple, but got '#{args.class_name.id}'." unless args.is_a? TupleLiteral %} {% args.raise "Expected argument ##{idx} of the 'ASPEC::TestCase::TestWith' annotation applied to '#{@type}##{test.name.id}' to contain #{test.args.size} values, but got #{args.size}." if test.args.size != args.size %} {{method.id}} "#{{{description}}} #{{{idx}}}", file: {{test.filename}}, line: {{test.line_number}}, end_line: {{test.end_line_number}}, focus: {{focus}}, tags: {{tags}} do instance.{{test.name.id}} *{{args}} end {% end %} # Treat named args as Hash/NamedTuple data providers {% for name, args in test_with.named_args %} {% args.raise "Expected the value of argument '#{name.id}' of the 'ASPEC::TestCase::TestWith' annotation applied to '#{@type}##{test.name.id}' to be a Tuple, but got '#{args.class_name.id}'." unless args.is_a? TupleLiteral %} {% args.raise "Expected the value of argument '#{name.id}' of the 'ASPEC::TestCase::TestWith' annotation applied to '#{@type}##{test.name.id}' to contain #{test.args.size} values, but got #{args.size}." if test.args.size != args.size %} {{method.id}} "#{{{description}}} #{{{name.stringify}}}", file: {{test.filename}}, line: {{test.line_number}}, end_line: {{test.end_line_number}}, focus: {{focus}}, tags: {{tags}} do instance.{{test.name.id}} *{{args}} end {% end %} {% elsif !test.annotations(DataProvider).empty? %} {% for data_provider in test.annotations DataProvider %} {% data_provider_method_name = data_provider[0] || data_provider.raise "One or more data provider for test '#{@type}##{test.name.id}' is missing its name." %} {% methods = @type.methods %} {% for ancestor in @type.ancestors.select &.<=(ASPEC::TestCase) %} {% methods += ancestor.methods %} {% end %} {% provider_method_return_type = (methods.find(&.name.stringify.==(data_provider_method_name)).return_type || raise "Data provider '#{@type}##{data_provider_method_name.id}' must have a return type of Hash, NamedTuple, Array, or Tuple.").resolve %} {% if provider_method_return_type == Hash || provider_method_return_type == NamedTuple %} instance.{{data_provider_method_name.id}}.each do |name, args| {{method.id}} "#{{{description}}} #{name}", file: {{test.filename}}, line: {{test.line_number}}, end_line: {{test.end_line_number}}, focus: {{focus}}, tags: {{tags}} do instance.{{test.name.id}} *args end end {% elsif provider_method_return_type == Array || provider_method_return_type == Tuple %} instance.{{data_provider_method_name.id}}.each_with_index do |args, idx| {{method.id}} "#{{{description}}} #{idx}", file: {{test.filename}}, line: {{test.line_number}}, end_line: {{test.end_line_number}}, focus: {{focus}}, tags: {{tags}} do instance.{{test.name.id}} *args end end {% else %} {% provider_method.raise "Unsupported data provider return type: '#{provider_method.return_type}'" %} {% end %} {% end %} {% else %} {{method.id}} {{description}}, file: {{test.filename}}, line: {{test.line_number}}, end_line: {{test.end_line_number}}, focus: {{focus}}, tags: {{tags}} do instance.{{test.name.id}} end {% end %} {% end %} end {% end %} end # Runs once before any tests within `self` have been executed. # # Can be used to initialize objects common to every test, # but that do not need to be reset before running each test. # # ``` # require "spec" # require "athena-spec" # # struct ExampleSpec < ASPEC::TestCase # def before_all : Nil # puts "This prints only once before anything else" # end # # def test_one : Nil # true.should be_true # end # # def test_two : Nil # 1.should eq 1 # end # end # # ExampleSpec.run # ``` def before_all : Nil end # Runs once after all tests within `self` have been executed. # # ``` # require "spec" # require "athena-spec" # # struct ExampleSpec < ASPEC::TestCase # def after_all : Nil # puts "This prints only once after anything else" # end # # def test_one : Nil # true.should be_true # end # # def test_two : Nil # 1.should eq 1 # end # end # # ExampleSpec.run # ``` def after_all : Nil end # Runs before each test. # # Used to create the objects that will be used within the tests. # # ``` # require "spec" # require "athena-spec" # # struct ExampleSpec < ASpec::TestCase # @value : Int32 # # def initialize : Nil # @value = 1 # end # # def test_one : Nil # @value += 1 # # @value # => 2 # end # # def test_two : Nil # @value # => 1 # end # end # # ExampleSpec.run # ``` def initialize : Nil end # Runs after each test. # # Can be used to cleanup data in between tests, such as releasing a connection or closing a file. # # ``` # require "spec" # require "athena-spec" # # struct ExampleSpec < ASPEC::TestCase # @file : File # # def initialize : Nil # @file = File.new "./foo.txt", "w" # end # # def tear_down : Nil # @file.close # end # # def test_one : Nil # @file.path # => "./foo.txt" # end # end # # ExampleSpec.run # ``` def tear_down : Nil end # :showdoc: # # Helper macro DSL for defining a test method. # # ``` # require "spec" # require "athena-spec" # # struct ExampleSpec < ASPEC::TestCase # test "2 is even" do # 2.even?.should be_true # end # end # # ExampleSpec.run # ``` private macro test(name, focus = false, *tags) {% if focus %}@[Focus]{% end %} {% unless tags.empty? %}@[Tags({{tags.splat}})]{% end %} def test_{{name.gsub(/[^\w]/, "_").underscore.downcase.id}} : Nil {{yield}} end end end ================================================ FILE: src/components/validator/.editorconfig ================================================ root = true [*.cr] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true ================================================ FILE: src/components/validator/.gitignore ================================================ /lib/ /bin/ /.shards/ *.dwarf # Libraries don't need dependency lock # Dependencies will be locked in applications that use them /shard.lock ================================================ FILE: src/components/validator/CHANGELOG.md ================================================ # Changelog ## [0.5.0] - 2026-04-19 ### Changed - **Breaking:** Split `AVD::Constraints::Size` into `Count` and `Length` constraints ([#611]) (George Dietrich) - Make identifying constraint violation inequality easier within spec failures ([#610]) (George Dietrich) ### Added - Allow picking the unit used for `AVD::Constraints::Length` validations ([#612]) (George Dietrich) [0.5.0]: https://github.com/athena-framework/validator/releases/tag/v0.5.0 [#610]: https://github.com/athena-framework/athena/pull/610 [#611]: https://github.com/athena-framework/athena/pull/611 [#612]: https://github.com/athena-framework/athena/pull/612 ## [0.4.1] - 2025-09-04 ### Changed - Leverage `mime` component for more robust `AVD::Constraints::File` MIME type validation ([#545]) (George Dietrich) ### Added - Add `AVD::Spec::CompoundConstraintTestCase` to make testing `AVD::Constraints::Compound` easier ([#540]) (George Dietrich) - Add support for `ATH::UploadedFile` to `AVD::Constraints::File` and `AVD::Constraints::Image` ([#559]) (George Dietrich) ### Fixed - Fix equality between `AVD::Constraint` instances ([#540]) (George Dietrich) [0.4.1]: https://github.com/athena-framework/validator/releases/tag/v0.4.1 [#545]: https://github.com/athena-framework/athena/pull/545 [#540]: https://github.com/athena-framework/athena/pull/540 [#559]: https://github.com/athena-framework/athena/pull/559 ## [0.4.0] - 2025-01-26 ### Changed - **Breaking:** Normalize exception types ([#428]) (George Dietrich) ### Added - **Breaking:** Add and make `require_tld: true` the default for `AVD::Constraints::URL` ([#492]) (George Dietrich) - Add example usages to `AVD::Constraints::*` docs ([#483], [#493]) (Zohir Tamda, George Dietrich) [0.4.0]: https://github.com/athena-framework/validator/releases/tag/v0.4.0 [#428]: https://github.com/athena-framework/athena/pull/428 [#483]: https://github.com/athena-framework/athena/pull/483 [#492]: https://github.com/athena-framework/athena/pull/492 [#493]: https://github.com/athena-framework/athena/pull/493 ## [0.3.4] - 2024-07-31 ### Changed - Update minimum `crystal` version to `~> 1.13.0` ([#433]) (George Dietrich) [0.3.4]: https://github.com/athena-framework/validator/releases/tag/v0.3.4 [#433]: https://github.com/athena-framework/athena/pull/433 ## [0.3.3] - 2024-04-09 ### Changed - Integrate website into monorepo ([#365]) (George Dietrich) [0.3.3]: https://github.com/athena-framework/validator/releases/tag/v0.3.3 [#365]: https://github.com/athena-framework/athena/pull/365 ## [0.3.2] - 2023-10-09 ### Fixed - Fix compiler error when using a composite constraint with a single member and no `of AVD::Constraint` ([#292]) (George Dietrich) [0.3.2]: https://github.com/athena-framework/validator/releases/tag/v0.3.2 [#292]: https://github.com/athena-framework/athena/pull/292 ## [0.3.1] - 2023-02-18 ### Changed - Update some links in preparation for Athena Framework `0.18.0` ([#261]) (George Dietrich) ### Fixed - Fix issue when using `AVD::Metadata::GetterMetadata` with methods that have parameters ([#252]) (George Dietrich) [0.3.1]: https://github.com/athena-framework/validator/releases/tag/v0.3.1 [#252]: https://github.com/athena-framework/athena/pull/252 [#261]: https://github.com/athena-framework/athena/pull/261 ## [0.3.0] - 2023-01-07 ### Changed - **Breaking:** update default `AVD::Constraints::Email::Mode` to be `:html5` ([#230]) (George Dietrich) - Refactor `AVD::Constraints::IP` to use new dedicated `Socket::IPAddress` methods ([#205]) (George Dietrich) - Update minimum `crystal` version to `~> 1.6` ([#205]) (George Dietrich) ### Added - Add `AVD::Constraints::Collection` ([#229]) (George Dietrich) - Add `AVD::Constraints::Existence`, `AVD::Constraints::Required`, and `AVD::Constraints::Optional` for use with the collection constraint ([#229]) (George Dietrich) - Add `AVD::Spec::ConstraintValidatorTestCase#expect_validate_value_at` to more easily handle validation of nested constraints ([#229]) (George Dietrich) - Add `AVD::Constraints::Email::Mode::HTML5_ALLOW_NO_TLD` that allows matching `HTML` input field validation exactly ([#231]) (George Dietrich) ### Removed - **Breaking:** remove `AVD::Constraints::Email::Mode::Loose` ([#230]) (George Dietrich) ### Fixed - **Breaking:** fix spelling of `AVD::Constraints::ISSN#require_hyphen` parameter ([#222]) (George Dietrich) - Fix property path display issue with `Enumerable` objects ([#229]) (George Dietrich) - Fix `AVD::Constraints::Valid` constraints incorrectly being allowed within `AVD::Constraints::Composite` ([#229]) (George Dietrich) [0.3.0]: https://github.com/athena-framework/validator/releases/tag/v0.3.0 [#205]: https://github.com/athena-framework/athena/pull/205 [#222]: https://github.com/athena-framework/athena/pull/222 [#229]: https://github.com/athena-framework/athena/pull/229 [#230]: https://github.com/athena-framework/athena/pull/230 [#231]: https://github.com/athena-framework/athena/pull/231 ## [0.2.1] - 2022-09-05 ### Added - Add support for exclusive end support to `AVD::Constraints::Range` ([#184]) (George Dietrich) ### Changed - Include allowed MIME types within `AVD::Constraints::Image` if they were customized ([#183]) (George Dietrich) - **Breaking:** ensure parameter names defined on interfaces match the implementation ([#188]) (George Dietrich) ### Fixed - Fix some file size factorization edge cases in `AVD::Constraints::File` ([#182]) (George Dietrich) - Fix duplicating constraints due to Crystal generics bug ([#192]) (George Dietrich) [0.2.1]: https://github.com/athena-framework/validator/releases/tag/v0.2.1 [#182]: https://github.com/athena-framework/athena/pull/182 [#183]: https://github.com/athena-framework/athena/pull/183 [#184]: https://github.com/athena-framework/athena/pull/184 [#188]: https://github.com/athena-framework/athena/pull/188 [#192]: https://github.com/athena-framework/athena/pull/192 ## [0.2.0] - 2022-05-14 ### Added - Add the [AVD::Constraints::File](https://athenaframework.org/Validator/Constraints/File/) constraint ([#153]) (George Dietrich) - Allow `AVD::Spec::MockValidator` to dynamically configure returned violations ([#155], [#157]) (George Dietrich) - Add the [AVD::Constraints::Image](https://athenaframework.org/Validator/Constraints/Image/) constraint ([#153]) (George Dietrich) - Add getting started documentation to API docs ([#172]) (George Dietrich) ### Changed - **Breaking:** make `AVD::ConstraintValidator` classes ([#154]) (George Dietrich) - **Breaking:** `AVD::ExecutionContext` is no longer a generic type ([#156]) (George Dietrich) - Update `assert_violation` to use a clearer failure message if no violations were found ([#153]) (George Dietrich) - Update `AVD::Constraints::ISIN` to use the validator off the context versus an ivar ([#155]) (George Dietrich) - Update minimum `crystal` version to `~> 1.4.0` ([#169]) (George Dietrich) ### Removed - **Breaking:** removed `AVD::Spec::MockValidator#violations=` ([#155]) (George Dietrich) ### Fixed - Fix `AVD::Violation::ConstraintViolation` not comparing correctly ([#153]) (George Dietrich) - Ensure only `Indexable` types can be used with `AVD::Constraints::Unique` ([#168]) (George Dietrich) [0.2.0]: https://github.com/athena-framework/validator/releases/tag/v0.2.0 [#153]: https://github.com/athena-framework/athena/pull/153 [#154]: https://github.com/athena-framework/athena/pull/154 [#155]: https://github.com/athena-framework/athena/pull/155 [#156]: https://github.com/athena-framework/athena/pull/156 [#157]: https://github.com/athena-framework/athena/pull/157 [#168]: https://github.com/athena-framework/athena/pull/168 [#169]: https://github.com/athena-framework/athena/pull/169 [#172]: https://github.com/athena-framework/athena/pull/172 ## [0.1.7] - 2021-12-27 _First release a part of the monorepo._ ### Fixed - Fix callback constraint methods being incorrectly added as getters ([#132]) (George Dietrich) [0.1.7]: https://github.com/athena-framework/validator/releases/tag/v0.1.7 [#132]: https://github.com/athena-framework/athena/pull/132 ## [0.1.6] - 2021-12-13 ### Fixed - Fix `AVD::Validatable` not working when included into parent types ([#16]) (George Dietrich) [0.1.6]: https://github.com/athena-framework/validator/releases/tag/v0.1.6 [#16]: https://github.com/athena-framework/validator/pull/16 ## [0.1.5] - 2021-10-30 ### Added - Add `VERSION` constant to `Athena::Validator` namespace ([#12]) (George Dietrich) ### Fixed - Fix incorrect type restriction on validator factory ([#12]) (George Dietrich) - Fix incorrect link within the docs ([#14]) (George Dietrich) [0.1.5]: https://github.com/athena-framework/validator/releases/tag/v0.1.5 [#12]: https://github.com/athena-framework/validator/pull/12 [#14]: https://github.com/athena-framework/validator/pull/14 ## [0.1.4] - 2021-01-30 ### Changed - Finish migration to [MkDocs](https://mkdocstrings.github.io/crystal/) ([#10], [#11]) (George Dietrich) [0.1.4]: https://github.com/athena-framework/validator/releases/tag/v0.1.4 [#10]: https://github.com/athena-framework/validator/pull/10 [#11]: https://github.com/athena-framework/validator/pull/11 ## [0.1.3] - 2020-12-07 ### Changed - Update `crystal` version to allow version greater than `1.0.0` ([#9]) (George Dietrich) [0.1.3]: https://github.com/athena-framework/validator/releases/tag/v0.1.3 [#9]: https://github.com/athena-framework/validator/pull/9 ## [0.1.2] - 2020-11-25 ### Added - Add the [AVD::Constraints::Choice](https://athenaframework.org/Validator/Constraints/Choice/) constraint ([#7]) (George Dietrich) ### Changed - Allow setting violations directly on mock validators ([#7]) (George Dietrich) [0.1.2]: https://github.com/athena-framework/validator/releases/tag/v0.1.2 [#7]: https://github.com/athena-framework/validator/pull/7 ## [0.1.1] - 2020-11-08 ### Fixed - Fix compiler error due to less strict `abstract def` implementations ([#6]) (George Dietrich) [0.1.1]: https://github.com/athena-framework/validator/releases/tag/v0.1.1 [#6]: https://github.com/athena-framework/validator/pull/6 ## [0.1.0] - 2020-10-17 _Initial release._ [0.1.0]: https://github.com/athena-framework/validator/releases/tag/v0.1.0 ================================================ FILE: src/components/validator/CONTRIBUTING.md ================================================ # Contributing This repository is a read-only mirror. Please refer the [main Athena repository](https://github.com/athena-framework/athena/blob/master/CONTRIBUTING.md) on how to start contributing. ================================================ FILE: src/components/validator/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2021 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/components/validator/README.md ================================================ # Validator [![Common Changelog](https://common-changelog.org/badge.svg)](https://common-changelog.org) [![CI](https://github.com/athena-framework/athena/actions/workflows/ci.yml/badge.svg?branch=master&event=schedule)](https://github.com/athena-framework/athena/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/release/athena-framework/validator.svg)](https://github.com/athena-framework/validator/releases) Object/value validation library ## Getting Started Checkout the [Documentation](https://athenaframework.org/Validator). ## Contributing Read the general [Contributing Guide](./CONTRIBUTING.md) for information on how to get started. ================================================ FILE: src/components/validator/UPGRADING.md ================================================ # Upgrading Documents the changes that may be required when upgrading to a newer component version. ## Upgrade to 0.5.0 ### Split `AVD::Constraints::Size` constraint `AVD:Constraints::Size` handled both `Indexable` and `String` values. It has been split into dedicated `Count` and `Length` constraints respectively. Usages of `Size` should be updated to use one of the new constraints depending on if the value being validated is a string or a collection. The new constraints have new error names/UUIDs and potentially different error placeholders. ## Upgrade to 0.4.0 ### New `AVD::Constraints::URL#require_tld` option `AVD::Constraints::URL` now requires URLs have a TLD by default; `https://example.com` is valid while `https://example` is not. If your logic requires the latter to be considered valid, you will need to ensure `require_tld` is set to `false` on usages of this constraint. ### Normalization of Exception types The namespace exception types live in has changed from `AVD::Exceptions` to `AVD::Exception`. Any usages of `validator` exception types will need to be updated. Some additional types have also been removed/renamed: * `AVD::Exceptions::ValidatorError` has been removed in favor of using `AVD::Exception` directly If using a `rescue` statement with a parent exception type, either from the `validator` component or Crystal stdlib, double check it to ensure it'll still rescue what you are expecting it will. ================================================ FILE: src/components/validator/docs/README.md ================================================ The `Athena::Validator` component provides a robust object/value validation framework. * [AVD::Constraint](/Validator/Constraint/)s describe some assertion; such as a string should be [AVD::Constraints::NotBlank](/Validator/Constraints/NotBlank/) or that a value is [AVD::Constraints::GreaterThanOrEqual](/Validator/Constraints/GreaterThanOrEqual/) another value * Constraints, along with a value, are then passed to an [AVD::ConstraintValidatorInterface](/Validator/ConstraintValidatorInterface/) that actually performs the validation, using the data defined in the constraint * If the validator determines that the value is invalid in some way, it creates and adds an [AVD::Violation::ConstraintViolationInterface](/Validator/Violation/ConstraintViolationInterface/) to this runs' [AVD::ExecutionContextInterface](/Validator/ExecutionContextInterface/) * The [AVD::Validator::ValidatorInterface](/Validator/Validator/ValidatorInterface/) then returns an [AVD::Violation::ConstraintViolationListInterface](/Validator/Violation/ConstraintViolationListInterface/) that contains all the violations * The object/value can be considered valid if that list is empty ## Installation First, install the component by adding the following to your `shard.yml`, then running `shards install`: ```yaml dependencies: athena-validator: github: athena-framework/validator version: ~> 0.5.0 ``` ## Usage `Athena::Validator` comes with a set of common [AVD::Constraints](/Validator/Constraints/) built-in that any project could find useful. When used on its own, the [Athena::Validator.validator](/Validator/top_level/#Athena::Validator.validator) method can be used to obtain an [AVD::Validator::ValidatorInterface](/Validator/Validator/ValidatorInterface/) instance to validate a given value/object. ### Basics A validator accepts a value, and one or more [AVD::Constraint](/Validator/Constraint/) to validate the value against. The validator then returns an [AVD::Violation::ConstraintViolationListInterface](/Validator/Violation/ConstraintViolationListInterface/) that includes all the violations, if any. ```crystal # Obtain a validator instance. validator = AVD.validator # Use the validator to validate a value. violations = validator.validate "foo", AVD::Constraints::NotBlank.new # The validator returns an empty list of violations, indicating the value is valid. violations.inspect # => Athena::Validator::Violation::ConstraintViolationList(@violations=[]) ``` In this case it returns an empty list of violations, meaning the value is valid. ```crystal # Using the validator instance from the previous example violations = validator.validate "", AVD::Constraints::NotBlank.new violations.inspect # => # Athena::Validator::Violation::ConstraintViolationList( # @violations=[ # Athena::Validator::Violation::ConstraintViolation( # @cause=nil, # @code="0d0c3254-3642-4cb0-9882-46ee5918e6e3", # @constraint=#, # @invalid_value_container=Athena::Validator::ValueContainer(String)(@value=""), # @message="This value should not be blank.", # @message_template="This value should not be blank.", # @parameters={"{{ value }}" => ""}, # @plural=nil, # @property_path="", # @root_container=Athena::Validator::ValueContainer(String)(@value="") # ) # ] ) # Both the ConstraintViolationList and ConstraintViolation implement a `#to_s` method. puts violations # => # : # This value should not be blank. (code: 0d0c3254-3642-4cb0-9882-46ee5918e6e3) ``` However in the case of the value _NOT_ being valid, the list includes all of the [AVD::Violation::ConstraintViolationInterface](/Validator/Violation/ConstraintViolationInterface/)s produced during this run. Each violation includes some metadata; such as the related constraint that failed, a machine readable code, a human readable message, any parameters that should be used to render that message, etc. The extra context allows for a lot of flexibility; both in terms of how the error could be rendered or handled. By default, in addition to any constraint specific arguments, the majority of the constraints have three optional arguments: `message`, `groups`, and `payload`. * The `message` argument represents the message that should be used if the value is found to not be valid. The message can also include placeholders, in the form of `{{ key }}`, that will be replaced when the message is rendered. Most commonly this includes the invalid value itself, but some constraints have additional placeholders. * The `payload` argument can be used to attach any domain specific data to the constraint; such as attaching a severity with each constraint to have more serious violations be handled differently. * The `groups` argument can be used to run a subset of the defined constraints. More on this in the [Validation Groups](#validation-groups) section. ```crystal validator = AVD.validator # Instantiate a constraint with a custom message, using a placeholder. violations = validator.validate -4, AVD::Constraints::PositiveOrZero.new message: "{{ value }} is not a valid age. A user cannot have a negative age." puts violations # => # -4: # -4 is not a valid age. A user cannot have a negative age. (code: e09e52d0-b549-4ba1-8b4e-420aad76f0de) ``` Customizing the message can be a good way for those consuming the errors to determine _WHY_ a given value is not valid. ### Validating Objects Validating arbitrary values against a set of arbitrary constraints can be useful in smaller applications and/or for one off use cases. However to keep in line with our Object Oriented Programming (OOP) principles, we can also validate objects. The object could be either a struct or a class. The only requirements are that the object includes a specific module, [AVD::Validatable](/Validator/Validatable/), and specifies which properties should be validated and against what constraints. The easiest/most common way to do this is via annotations and the [Assert](/Validator/aliases/#Assert) alias. ```crystal # Define a class that can be validated. class User include AVD::Validatable def initialize(@name : String, @age : Int32? = nil); end # Specify that we want to assert that the user's name is not blank. # Multiple constraints can be defined on a single property. @[Assert::NotBlank] getter name : String # Arguments to the constraint can be used normally as well. # The constraint's default argument can also be supplied positionally: `@[Assert::GreaterThan(0)]`. @[Assert::NotNil(message: "A user's age cannot be null")] getter age : Int32? end # Obtain a validator instance. validator = AVD.validator # Validate a user instance, notice we're not passing in any constraints. validator.validate(User.new("Jim", 10)).empty? # => true validator.validate User.new "", 10 # => # Object(User).name: # This value should not be blank. (code: 0d0c3254-3642-4cb0-9882-46ee5918e6e3) ``` Notice that in this case we do not need to supply the constraints to the `#validate` method. This is because the validator is able to extract them from the annotations on the properties. An array of constraints can still be supplied, and will take precedence over the constraints defined within the type. NOTE: By default if a property's value is another object, the sub object will not be validated. use the [AVD::Constraints::Valid](/Validator/Constraints/Valid/) constraint if you wish to also validate the sub object. This also applies to arrays of objects. Another important thing to point out is that no custom DSL is required to define these constraints. [Athena::Validator](/Validator/top_level/) is intended to be a generic validation solution that could be used outside of the [Athena](https://github.com/athena-framework) ecosystem. However, in order to be able to use the annotation based approach, you need to be able to apply the annotations to the underlying properties. If this is not possible due to how a specific type is implemented, or if you just don't like the annotation syntax, the type can also be configured via code. ```crystal # Define a class that can be validated. class User include AVD::Validatable # This class method is invoked when building the metadata associated with a type, # and can be used to manually wire up the constraints. def self.load_metadata(metadata : AVD::Metadata::ClassMetadata) : Nil metadata.add_property_constraint "name", AVD::Constraints::NotBlank.new end def initialize(@name : String); end getter name : String end # Obtain a validator instance. validator = AVD.validator # Validate a user instance, notice we're not passing in any constraints. validator.validate(User.new("Jim")).empty? # => true validator.validate User.new "" # => # Object(User).name: # This value should not be blank. (code: 0d0c3254-3642-4cb0-9882-46ee5918e6e3) ``` The metadata for each type is lazily loaded when an instance of that type is validated, and is only built once. See [AVD::Metadata::ClassMetadata](/Validator/Metadata/ClassMetadata/) for some additional ways to register property constraints. #### Getters Constraints can also be applied to getter methods of an object. This allows for dynamic validations based on the return value of the method. For example, say we wanted to assert that a user's name is not the same as their password. ```crystal class User include AVD::Validatable property name : String property password : String def initialize(@name : String, @password : String); end @[Assert::IsTrue(message: "Your password cannot be the same as your name.")] def is_safe_password? : Bool @name != @password end end validator = AVD.validator user = User.new "foo", "foo" validator.validate(user).empty? # => false user.password = "bar" validator.validate(user).empty? # => true ``` ### Custom Constraints If the built in [AVD::Constraints](/Validator/Constraints/) are not sufficient to handle validating a given value/object; custom ones can be defined. Let's make a new constraint that asserts a string contains only alphanumeric characters. This is accomplished by first defining a new class within the [AVD::Constraints](/Validator/Constraints/) namespace that inherits from [AVD::Constraint](/Validator/Constraint/). Then define a `Validator` struct within our constraint that inherits from [AVD::ConstraintValidator](/Validator/ConstraintValidator/) that actually implements the validation logic. ```crystal class AVD::Constraints::AlphaNumeric < AVD::Constraint # (Optional) A unique error code can also be defined to provide a machine readable identifier for a specific error. NOT_ALPHANUMERIC_ERROR = "1a83a8bd-ff79-4d5c-96e7-86d0b25b8a09" # (Optional) Allows using the `.error_message(code : String) : String` method with this constraint. @@error_names = { NOT_ALPHANUMERIC_ERROR => "NOT_ALPHANUMERIC_ERROR", } # Define an initializer with our default message, and any additional arguments specific to this constraint. def initialize( message : String = "This value should contain only alphanumeric characters.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil ) super message, groups, payload end # Define the validator within our constraint that'll contain our validation logic. class Validator < AVD::ConstraintValidator # Define our validate method that accepts the value to be validated, and the constraint. # # Overloads can be used to filter values of specific types. def validate(value : _, constraint : AVD::Constraints::AlphaNumeric) : Nil # Custom constraints should ignore nil and empty values to allow # other constraints (NotBlank, NotNil, etc.) take care of that return if value.nil? || value == "" # We'll cast the value to a string, # alternatively we could just ignore non `String?` values. value = value.to_s # If all the characters of this string are alphanumeric, then it is valid return if value.each_char.all? &.alphanumeric? # Otherwise, it is invalid and we need to add a violation, # see `AVD::ExecutionContextInterface` for additional information. self.context.add_violation constraint.message, NOT_ALPHANUMERIC_ERROR, value end end end puts AVD.validator.validate "$", AVD::Constraints::AlphaNumeric.new # => # $: # This value should contain only alphanumeric characters. (code: 1a83a8bd-ff79-4d5c-96e7-86d0b25b8a09) ``` NOTE: The constraint _MUST_ be defined within the [AVD::Constraints](/Validator/Constraints/) namespace for implementation reasons. This may change in the future. We are now able to use this constraint as we would one of the built in ones; either by manually instantiating it, or applying an `@[Assert::AlphaNumeric]` annotation to a property. See [AVD::ConstraintValidatorInterface](/Validator/ConstraintValidatorInterface/) for more information on custom validators. ### Validation Groups By default when validating an object, all constraints defined on that type will be checked. However, in some cases you may only want to validate the object against _some_ of those constraints. This can be accomplished via assigning each constraint to a validation group, then apply validation against one specific group of constraints. For example, using our `User` class from earlier, say we only want to validate certain properties when the user is first created. To do this we can utilize the `groups` argument that all constraints have. ```crystal class User include AVD::Validatable def initialize(@email : String, @password : String, @city : String); end @[Assert::Email(groups: "create")] getter email : String @[Assert::NotBlank(groups: "create")] @[Assert::Size(7.., groups: "create")] getter password : String @[Assert::Size(2..)] getter city : String end user = User.new "contact@athenaframework.org", "monkey123", "" # Validate the user object, but only for those in the "create" group, # if no groups are supplied, then all constraints in the "default" group will be used. violations = AVD.validator.validate user, groups: "create" # There are no violations since the city's size is not validated since it's not in the "create" group. violations.empty? # => true ``` See `AVD::Constraint@validation-groups` for some expanded information. ### Sequential Validation By default, all constraints are validated in a single "batch". I.e. all constraints within the provided group(s) are validated, without regard to if the previous/next constraint is/was (in)valid. However, an [AVD::Constraints::GroupSequence](/Validator/Constraints/GroupSequence/) can be used to validate batches of constraints in steps. I.e. validate the first "batch" of constraints, and only advance to the next batch if all constraints in that step are valid. ```crystal @[Assert::GroupSequence("User", "Secondary")] class User include AVD::Validatable @[Assert::NotBlank] getter username : String @[Assert::NotBlank(groups: "Secondary")] getter password : String def initialize(@username : String, @password : String); end end # Instantiate a new `User` object where both properties are invalid. user = User.new "", "" # Notice there is only one violation since there was a violation in the `User` group, # it did not advance to the `Secondary` group. AVD.validator.validate user # => # Object(User).username: # This value should not be blank. (code: 0d0c3254-3642-4cb0-9882-46ee5918e6e3) ``` #### Group Sequence Providers The [AVD::Constraints::GroupSequence](/Validator/Constraints/GroupSequence/) can be a useful tool for creating efficient validations, but it is quite limiting since the sequence is static on the type. If more flexibility is required the [AVD::Constraints::GroupSequence::Provider](/Validator/Constraints/GroupSequence/Provider/) module can be included into a type. The module allows the object to return the sequence it should use dynamically at runtime. ```crystal class User include AVD::Validatable include AVD::Constraints::GroupSequence::Provider # ... def group_sequence : Array(Array(String) | String) | AVD::Constraints::GroupSequence # Build out and return the sequence `self` should use. end end ``` Alternatively, if you only want to apply constraints sequentially on a single property, the [AVD::Constraints::Sequentially](/Validator/Constraints/Sequentially/) constraint can be used to do this in a simpler way. ================================================ FILE: src/components/validator/mkdocs.yml ================================================ INHERIT: ../../../mkdocs-common.yml site_name: Validator site_url: https://athenaframework.org/Validator/ repo_url: https://github.com/athena-framework/validator nav: - Introduction: README.md - Back to Manual: project://. - API: - Aliases: aliases.md - Top Level: top_level.md - '*' plugins: - search - section-index - literate-nav - gen-files: scripts: - ../../../gen_doc_stubs.py - mkdocstrings: default_handler: crystal custom_templates: ../../../docs/templates handlers: crystal: crystal_docs_flags: - ../../../docs/index.cr - ./lib/athena-http/src/athena-http.cr - ./lib/athena-image_size/src/athena-image_size.cr - ./lib/athena-mime/src/athena-mime.cr - ./lib/athena-validator/src/athena-validator.cr - ./lib/athena-validator/src/spec.cr source_locations: lib/athena-validator: https://github.com/athena-framework/validator/blob/v{shard_version}/{file}#L{line} ================================================ FILE: src/components/validator/shard.yml ================================================ name: athena-validator version: 0.5.0 crystal: ~> 1.13 license: MIT repository: https://github.com/athena-framework/validator documentation: https://athenaframework.org/Validator description: | Object/value validation library. authors: - George Dietrich dependencies: athena-http: github: athena-framework/http version: ~> 0.1.0 athena-image_size: github: athena-framework/image-size version: ~> 0.1.0 athena-mime: github: athena-framework/mime version: ~> 0.2.0 ================================================ FILE: src/components/validator/spec/athena-validator_spec.cr ================================================ require "./spec_helper" describe Athena::Validator do describe ".validator" do it "returns a validator" do AVD.validator.should be_a AVD::Validator::ValidatorInterface end end end ================================================ FILE: src/components/validator/spec/constraint_spec.cr ================================================ require "./spec_helper" describe AVD::Constraint do describe ".error_name" do it "exists" do CustomConstraint.error_name("abc123").should eq "FAKE_ERROR" end it "does not add non _ERROR constants" do expect_raises(AVD::Exception::InvalidArgument, "The error code 'BLAH' does not exist for constraint of type 'CustomConstraint'.") do CustomConstraint.error_name "BLAH" end end it "does not exist" do expect_raises(AVD::Exception::InvalidArgument, "The error code 'foo' does not exist for constraint of type 'CustomConstraint'.") do CustomConstraint.error_name "foo" end end end describe "#add_implicit_group" do it "adds group when only group is default" do constraint = MockConstraint.new "" constraint.groups.should eq ["default"] constraint.add_implicit_group "foo" constraint.groups.should eq ["default", "foo"] end it "does not add when it's already included" do constraint = MockConstraint.new "" constraint.groups.should eq ["default"] constraint.add_implicit_group "foo" constraint.groups.should eq ["default", "foo"] constraint.add_implicit_group "foo" constraint.groups.should eq ["default", "foo"] end it "does not add when there are more than the default group" do constraint = MockConstraint.new "", groups: ["custom_group"] constraint.groups.should eq ["custom_group"] constraint.add_implicit_group "foo" constraint.groups.should eq ["custom_group"] end end describe "#initialize" do it "allows setting custom values" do constraint = CustomConstraint.new("MESSAGE", ["GROUP"], {"key" => "value"}) constraint.message.should eq "MESSAGE" constraint.groups.should eq ["GROUP"] constraint.payload.should eq({"key" => "value"}) end end end ================================================ FILE: src/components/validator/spec/constraints/all_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::All struct AllValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint constraints: AVD::Constraints::NotBlank.new self.assert_no_violation end def test_raises_if_value_is_not_hash_or_indexable : Nil expect_raises AVD::Exception::UnexpectedValueError, "Expected argument of type 'Hash | Indexable', 'String' given." do self.validator.validate "FOO", self.new_constraint constraints: AVD::Constraints::NotBlank.new end end def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/at_least_one_of_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::AtLeastOneOf struct AtLeastOneOfValidatorTest < AVD::Spec::ConstraintValidatorTestCase def valid_combinations : Tuple { {"athena", [AVD::Constraints::Length.new(range: (10..)), AVD::Constraints::EqualTo.new(value: "athena")]}, {150, [AVD::Constraints::Range.new(range: (10..20)), AVD::Constraints::GreaterThanOrEqual.new(value: 100)]}, {[1, 3, 5], [AVD::Constraints::Count.new(range: (5..)), AVD::Constraints::Unique.new]}, } end @[DataProvider("valid_combinations")] def test_valid_combinations(value : _, constraints : Array(AVD::Constraint)) : Nil constraints.each_with_index do |constraint, idx| self.expect_violation_at idx, value, constraint end self.validator.validate value, self.new_constraint constraints: constraints self.assert_no_violation end def invalid_combinations : Tuple { {"athenaa", [AVD::Constraints::Length.new(range: (10..)), AVD::Constraints::EqualTo.new(value: "athena")]}, {50, [AVD::Constraints::Range.new(range: (10..20)), AVD::Constraints::GreaterThanOrEqual.new(value: 100)]}, {[1, 3, 3], [AVD::Constraints::Count.new(range: (5..)), AVD::Constraints::Unique.new]}, } end @[DataProvider("invalid_combinations")] def test_invalid_combinations_default_message(value : _, constraints : Array(AVD::Constraint)) : Nil constraint = self.new_constraint constraints: constraints message = [constraint.message] constraints.each_with_index do |c, idx| message << " [#{idx + 1}] #{self.expect_violation_at(idx, value, c).first.message}" end self.validator.validate value, constraint self .build_violation(message.join, CONSTRAINT::AT_LEAST_ONE_OF_ERROR) .assert_violation end @[DataProvider("invalid_combinations")] def test_invalid_combinations_custom_message(value : _, constraints : Array(AVD::Constraint)) : Nil constraints.each_with_index do |constraint, idx| self.expect_violation_at idx, value, constraint end self.validator.validate value, self.new_constraint constraints: constraints, message: "my_message", include_internal_messages: false self .build_violation("my_message", CONSTRAINT::AT_LEAST_ONE_OF_ERROR) .assert_violation end def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/blank_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Blank struct BlankValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_blank_is_valid : Nil self.validator.validate "", self.new_constraint self.assert_no_violation end def test_blank_spaces_is_valid : Nil self.validator.validate " ", self.new_constraint self.assert_no_violation end @[DataProvider("invalid_values")] def test_invalid_values(value : _) : Nil self.validator.validate value, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::NOT_BLANK_ERROR, value end def invalid_values : Tuple { {"foobar"}, {0}, {false}, {1234}, } end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/callback_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Callback struct CallbackValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_callback : Nil constraint = CONSTRAINT.with_callback(payload: {"foo" => "bar"}) do |value, context, payload| value.should eq 123 payload.should eq({"foo" => "bar"}) context.add_violation("my_message") end self.validator.validate 123, constraint self.assert_violation "my_message" end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/choice_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Choice struct ChoiceValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_requires_enumerable_if_multiple_is_true : Nil expect_raises AVD::Exception::UnexpectedValueError, "Enumerable" do self.validator.validate "foo", self.new_constraint choices: ["foo", "bar"], multiple: true end end def test_requires_enumerable_if_multiple_is_false : Nil expect_raises AVD::Exception::UnexpectedValueError, "Enumerable" do self.validator.validate [1, 2], self.new_constraint choices: ["foo", "bar"], multiple: false end end def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint choices: ["foo", "bar"] self.assert_no_violation end def test_valid_choice : Nil self.validator.validate "bar", self.new_constraint choices: ["foo", "bar"] self.assert_no_violation end def test_multiple_choices : Nil self.validator.validate ["foo", "bar"], self.new_constraint choices: ["foo", "bar"], multiple: true self.assert_no_violation end def test_invalid_choice : Nil self.validator.validate "baz", self.new_constraint choices: ["foo", "bar"], message: "my_message" self .build_violation("my_message", CONSTRAINT::NO_SUCH_CHOICE_ERROR, "baz") .add_parameter("{{ choices }}", ["foo", "bar"]) .assert_violation end def test_invalid_choice_empty_choices_array : Nil self.validator.validate "baz", self.new_constraint choices: [] of String, message: "my_message" self .build_violation("my_message", CONSTRAINT::NO_SUCH_CHOICE_ERROR, "baz") .add_parameter("{{ choices }}", [] of String) .assert_violation end def test_invalid_choices_multiple : Nil self.validator.validate ["foo", "baz"], self.new_constraint choices: ["foo", "bar"], multiple: true, multiple_message: "my_message" self .build_violation("my_message", CONSTRAINT::NO_SUCH_CHOICE_ERROR, "baz") .add_parameter("{{ choices }}", ["foo", "bar"]) .invalid_value("baz") .assert_violation end def test_invalid_choices_too_few : Nil value = ["foo"] self.value = value self.validator.validate value, self.new_constraint choices: ["foo", "bar", "moo", "maa"], multiple: true, range: (2..), min_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_FEW_ERROR, value) .add_parameter("{{ limit }}", 2) .add_parameter("{{ choices }}", ["foo", "bar", "moo", "maa"]) .invalid_value(value) .plural(2) .assert_violation end def test_invalid_choices_too_many : Nil value = ["foo", "bar", "moo"] self.value = value self.validator.validate value, self.new_constraint choices: ["foo", "bar", "moo", "maa"], multiple: true, range: (..2), max_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_MANY_ERROR, value) .add_parameter("{{ limit }}", 2) .add_parameter("{{ choices }}", ["foo", "bar", "moo", "maa"]) .invalid_value(value) .plural(2) .assert_violation end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/collection_spec.cr ================================================ require "../spec_helper" describe AVD::Constraints::Collection do it "transforms non optional/required constraints to required" do constraint = AVD::Constraints::Collection.new({"name" => ic = AVD::Constraints::NotBlank.new}) constraint.constraints.size.should eq 1 c = constraint.constraints["name"]?.should be_a AVD::Constraints::Required c.constraints.should eq({0 => ic}) end it "allows explicit required constraints" do constraint = AVD::Constraints::Collection.new({"name" => ic = AVD::Constraints::Required.new(nb = AVD::Constraints::NotBlank.new)}) constraint.constraints.size.should eq 1 c = constraint.constraints["name"]?.should be_a AVD::Constraints::Required c.should eq ic c.constraints.should eq({0 => nb}) end it "allows explicit optional constraints" do constraint = AVD::Constraints::Collection.new({"name" => ic = AVD::Constraints::Optional.new(nb = AVD::Constraints::NotBlank.new)}) constraint.constraints.size.should eq 1 c = constraint.constraints["name"]?.should be_a AVD::Constraints::Optional c.should eq ic c.constraints.should eq({0 => nb}) end end ================================================ FILE: src/components/validator/spec/constraints/collection_validator_test_case.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Collection abstract struct CollectionValidatorTestCase < AVD::Spec::ConstraintValidatorTestCase private abstract def prepare_test_data(contents : Hash) def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint fields: {"foo" => AVD::Constraints::NotBlank.new} self.assert_no_violation end def test_invalid_type : Nil expect_raises AVD::Exception::UnexpectedValueError, "Expected argument of type 'Enumerable({K, V})', 'String' given." do self.validator.validate "foobar", self.new_constraint fields: {"foo" => AVD::Constraints::NotBlank.new} end end def test_walks_single_constraint : Nil constraint = AVD::Constraints::Range.new 4.. data = { "foo" => 3, "bar" => 5, } idx = 0 data.each do |k, v| self.expect_validate_value_at idx, "[#{k}]", v, [constraint] idx += 1 end data = self.prepare_test_data data self.validator.validate data, self.new_constraint fields: { "foo" => constraint, "bar" => constraint, } self.assert_no_violation end def test_walks_multiple_constraints : Nil constraints = [ AVD::Constraints::Range.new(4..), AVD::Constraints::NotNil.new, ] data = { "foo" => 3, "bar" => 5, } idx = 0 data.each do |k, v| self.expect_validate_value_at idx, "[#{k}]", v, constraints idx += 1 end data = self.prepare_test_data data self.validator.validate data, self.new_constraint fields: { "foo" => constraints, "bar" => constraints, } self.assert_no_violation end def test_extra_fields_disallowed : Nil constraint = AVD::Constraints::Range.new(4..) data = self.prepare_test_data({ "foo" => 5, "baz" => 6, }) self.expect_validate_value_at 0, "[foo]", data["foo"], [constraint] self.validator.validate data, self.new_constraint extra_fields_message: "my_message", fields: { "foo" => constraint, } self .build_violation("my_message", CONSTRAINT::NO_SUCH_FIELD_ERROR) .invalid_value(6) .add_parameter("{{ field }}", "baz") .at_path("property.path[baz]") .assert_violation end def test_extra_fields_disallowed_with_optional_values : Nil constraint = AVD::Constraints::Optional.new data = self.prepare_test_data({ "baz" => 6, }) self.validator.validate data, self.new_constraint extra_fields_message: "my_message", fields: { "foo" => constraint, } self .build_violation("my_message", CONSTRAINT::NO_SUCH_FIELD_ERROR) .invalid_value(6) .add_parameter("{{ field }}", "baz") .at_path("property.path[baz]") .assert_violation end def test_nil_not_considered_extra_field : Nil constraint = AVD::Constraints::Range.new(4..) data = self.prepare_test_data({ "foo" => nil, }) self.expect_validate_value_at 0, "[foo]", data["foo"], [constraint] self.validator.validate data, self.new_constraint fields: { "foo" => constraint, } self.assert_no_violation end def test_extra_fields_allowed : Nil constraint = AVD::Constraints::Range.new(4..) data = self.prepare_test_data({ "foo" => 5, "baz" => 6, }) self.expect_validate_value_at 0, "[foo]", data["foo"], [constraint] self.validator.validate data, self.new_constraint allow_extra_fields: true, fields: { "foo" => constraint, } self.assert_no_violation end def test_missing_fields_disallowed : Nil constraint = AVD::Constraints::Range.new(4..) data = self.prepare_test_data({} of String => Int32) self.validator.validate data, self.new_constraint missing_fields_message: "my_message", fields: { "foo" => constraint, } self .build_violation("my_message", CONSTRAINT::MISSING_FIELD_ERROR) .at_path("property.path[foo]") .add_parameter("{{ field }}", "foo") .invalid_value(nil) .assert_violation end def test_missing_fields_allowed : Nil constraint = AVD::Constraints::Range.new(4..) data = self.prepare_test_data({} of String => Int32) self.validator.validate data, self.new_constraint allow_missing_fields: true, fields: { "foo" => constraint, } self.assert_no_violation end def test_optional_field_present_null : Nil data = self.prepare_test_data({ "foo" => nil, }) self.validator.validate data, self.new_constraint fields: { "foo" => AVD::Constraints::Optional.new, } self.assert_no_violation end def test_optional_field_not_present : Nil data = self.prepare_test_data({} of String => Int32) self.validator.validate data, self.new_constraint fields: { "foo" => AVD::Constraints::Optional.new, } self.assert_no_violation end def test_optional_field_single_constraint : Nil data = { "foo" => 5, } constraint = AVD::Constraints::Range.new(4..) self.expect_validate_value_at 0, "[foo]", data["foo"], [constraint] data = self.prepare_test_data data self.validator.validate data, self.new_constraint fields: { "foo" => AVD::Constraints::Optional.new constraint, } self.assert_no_violation end def test_optional_field_multiple_constraints : Nil data = { "foo" => 5, } constraints = [ AVD::Constraints::NotNil.new, AVD::Constraints::Range.new(4..), ] self.expect_validate_value_at 0, "[foo]", data["foo"], constraints data = self.prepare_test_data data self.validator.validate data, self.new_constraint fields: { "foo" => AVD::Constraints::Optional.new constraints, } self.assert_no_violation end def test_required_field_present_null : Nil data = self.prepare_test_data({ "foo" => nil, }) self.validator.validate data, self.new_constraint fields: { "foo" => AVD::Constraints::Required.new, } self.assert_no_violation end def test_required_field_not_present : Nil data = self.prepare_test_data({} of String => Int32) self.validator.validate data, self.new_constraint missing_fields_message: "my_message", fields: { "foo" => AVD::Constraints::Required.new, } self .build_violation("my_message", CONSTRAINT::MISSING_FIELD_ERROR) .at_path("property.path[foo]") .add_parameter("{{ field }}", "foo") .invalid_value(nil) .assert_violation end def test_required_field_single_constraint : Nil data = { "foo" => 5, } constraint = AVD::Constraints::Range.new(4..) self.expect_validate_value_at 0, "[foo]", data["foo"], [constraint] data = self.prepare_test_data data self.validator.validate data, self.new_constraint fields: { "foo" => AVD::Constraints::Required.new constraint, } self.assert_no_violation end def test_required_field_multiple_constraints : Nil data = { "foo" => 5, } constraints = [ AVD::Constraints::NotNil.new, AVD::Constraints::Range.new(4..), ] self.expect_validate_value_at 0, "[foo]", data["foo"], constraints data = self.prepare_test_data data self.validator.validate data, self.new_constraint fields: { "foo" => AVD::Constraints::Required.new constraints, } self.assert_no_violation end def test_does_not_mutate_object : Nil hash = { "foo" => 3, } constraint = AVD::Constraints::Range.new(2..) self.expect_validate_value_at 0, "[foo]", hash["foo"], [constraint] data = self.prepare_test_data hash self.validator.validate data, self.new_constraint fields: { "foo" => constraint, } hash.should eq({"foo" => 3}) end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/composite_spec.cr ================================================ require "../spec_helper" private class ConcreteComposite < AVD::Constraints::Composite def initialize( constraints : Array(AVD::Constraint) | AVD::Constraint = [] of AVD::Constraint, groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super constraints, "", groups, payload end # :inherit: def validated_by : NoReturn raise "BUG: #{self} cannot be validated" end end struct CompositeTest < ASPEC::TestCase def test_default_group : Nil constraint = ConcreteComposite.new([ AVD::Constraints::NotNil.new, AVD::Constraints::NotBlank.new, ]) constraint.groups.should eq ["default"] constraint.constraints[0].groups.should eq ["default"] constraint.constraints[1].groups.should eq ["default"] end def test_nested_composite_constraint_has_default_group : Nil constraint = ConcreteComposite.new([ ConcreteComposite.new, ConcreteComposite.new, ] of AVD::Constraint) constraint.groups.should eq ["default"] constraint.constraints[0].groups.should eq ["default"] constraint.constraints[1].groups.should eq ["default"] end def test_implicit_nested_groups_if_explicit_parent_group : Nil constraint = ConcreteComposite.new([ AVD::Constraints::NotNil.new, AVD::Constraints::NotBlank.new, ], groups: ["default", "strict"]) constraint.groups.should eq ["default", "strict"] constraint.constraints[0].groups.should eq ["default", "strict"] constraint.constraints[1].groups.should eq ["default", "strict"] end def test_explicit_nested_groups_must_be_subset_of_explicit_parent_groups : Nil constraint = ConcreteComposite.new([ AVD::Constraints::NotNil.new(groups: "default"), AVD::Constraints::NotBlank.new(groups: "strict"), ], groups: ["default", "strict"]) constraint.groups.should eq ["default", "strict"] constraint.constraints[0].groups.should eq ["default"] constraint.constraints[1].groups.should eq ["strict"] end def test_fail_if_explicit_nest_group_not_subset_of_explicit_parent_groups : Nil expect_raises AVD::Exception::Logic, "The group(s) 'foobar' passed to the constraint 'Athena::Validator::Constraints::NotNil' should also be passed to its containing constraint 'ConcreteComposite'." do ConcreteComposite.new([ AVD::Constraints::NotNil.new(groups: ["default", "foobar"]), ] of AVD::Constraint, groups: ["default", "strict"]) end end def test_implicit_group_names_are_forwarded : Nil constraint = ConcreteComposite.new([ AVD::Constraints::NotNil.new(groups: "default"), AVD::Constraints::NotBlank.new(groups: "strict"), ]) constraint.add_implicit_group "implicit" constraint.groups.should eq ["default", "strict", "implicit"] constraint.constraints[0].groups.should eq ["default", "implicit"] constraint.constraints[1].groups.should eq ["strict"] end def test_valid_cannot_be_nested : Nil expect_raises AVD::Exception::Logic, "The 'Athena::Validator::Constraints::Valid' constraint cannot be nested inside a 'ConcreteComposite' constraint." do ConcreteComposite.new([ AVD::Constraints::Valid.new, ] of AVD::Constraint) end end def test_single_element_inferred_type_array : Nil constraint = ConcreteComposite.new([ AVD::Constraints::Positive.new, ]) constraint.constraints.size.should eq 1 constraint.groups.should eq ["default"] end end ================================================ FILE: src/components/validator/spec/constraints/compound_validator_spec.cr ================================================ require "../spec_helper" private class DummyCompoundConstraint < AVD::Constraints::Compound def constraints : Type [ AVD::Constraints::NotBlank.new, AVD::Constraints::Length.new(..3), ] end end struct CompoundValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_valid_value : Nil self.validator.validate "foo", DummyCompoundConstraint.new self.assert_no_violation end private def create_validator : AVD::ConstraintValidatorInterface AVD::Constraints::Compound::Validator.new end private def constraint_class : AVD::Constraint.class DummyCompoundConstraint end end ================================================ FILE: src/components/validator/spec/constraints/count_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Count struct CountValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint(range: (1..1), exact_message: "my_message") self.assert_no_violation end def three_or_less : Tuple { { {1} }, { {1, 2} }, { {1, 2, 3} }, {[1]}, {[1, 2]}, {[1, 2, 3]}, } end def four : Tuple { { {1, 2, 3, 4} }, {[4, 3, 2, 1]}, } end def five_or_more : Tuple { { {1, 2, 3, 4, 5} }, {[5, 4, 3, 2, 1]}, } end @[DataProvider("three_or_less")] def test_valid_values_max(value : Indexable) : Nil self.validator.validate value, self.new_constraint range: (..3) self.assert_no_violation end @[DataProvider("five_or_more")] def test_valid_values_min(value : Indexable) : Nil self.validator.validate value, self.new_constraint range: (5..) self.assert_no_violation end @[DataProvider("four")] def test_values_exact(value : Indexable) : Nil self.validator.validate value, self.new_constraint range: (4..4) self.assert_no_violation end @[DataProvider("five_or_more")] def test_invalid_values_max(value : Indexable) : Nil self.validator.validate value, self.new_constraint range: (..4), max_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_MANY_ERROR, value) .add_parameter("{{ count }}", value.size) .add_parameter("{{ limit }}", 4) .plural(4) .invalid_value(value) .assert_violation end @[DataProvider("three_or_less")] def test_invalid_values_min(value : Indexable) : Nil self.validator.validate value, self.new_constraint range: (4..), min_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_FEW_ERROR, value) .add_parameter("{{ count }}", value.size) .add_parameter("{{ limit }}", 4) .plural(4) .invalid_value(value) .assert_violation end @[DataProvider("five_or_more")] def test_invalid_values_exact_more_than_four(value : Indexable) : Nil self.validator.validate value, self.new_constraint range: (4..4), exact_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_EQUAL_COUNT_ERROR, value) .add_parameter("{{ count }}", value.size) .add_parameter("{{ limit }}", 4) .plural(4) .invalid_value(value) .assert_violation end @[DataProvider("three_or_less")] def test_invalid_values_exact_less_than_four(value : Indexable) : Nil self.validator.validate value, self.new_constraint range: (4..4), exact_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_EQUAL_COUNT_ERROR, value) .add_parameter("{{ count }}", value.size) .add_parameter("{{ limit }}", 4) .plural(4) .invalid_value(value) .assert_violation end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/email_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Email private class EmptyEmailObject def to_s(io : IO) : Nil io << "" end end struct EmailValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_empty_string_is_valid : Nil self.validator.validate "", self.new_constraint end def test_empty_string_from_object_is_valid : Nil self.validator.validate EmptyEmailObject.new, self.new_constraint self.assert_no_violation end @[DataProvider("valid_emails")] def test_valid_emails(value : String) : Nil self.validator.validate value, self.new_constraint self.assert_no_violation end def valid_emails : Tuple { {"blacksmoke16@dietrich.app"}, {"example@example.co.uk"}, {"blacksmoke_blacksmoke@example.fr"}, {"{}~!@example.com"}, } end @[DataProvider("invalid_emails")] def test_invalid_emails(value : String) : Nil self.validator.validate value, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::INVALID_FORMAT_ERROR, value end def invalid_emails : Tuple { {"example"}, {"example@"}, {"example@localhost"}, {"example@example.co..uk"}, {"foo@example.com bar"}, {"example@example."}, {"example@.fr"}, {"@example.com"}, {"example@example.com;example@example.com"}, {"example@."}, {" example@example.com"}, {"example@ "}, {" example@example.com "}, {" example @example .com "}, {"example@-example.com"}, {"example@#{"a"*64}.com"}, } end @[DataProvider("invalid_emails_html5_allow_no_tld")] def test_invalid_emails_html5_allow_no_tld(value : String) : Nil self.validator.validate value, self.new_constraint mode: CONSTRAINT::Mode::HTML5_ALLOW_NO_TLD, message: "my_message" self.assert_violation "my_message", CONSTRAINT::INVALID_FORMAT_ERROR, value end def invalid_emails_html5_allow_no_tld : Tuple { {"example bar"}, {"example@"}, {"example@ bar"}, {"example@localhost bar"}, {"foo@example.com bar"}, } end def test_valid_email_html5_allow_no_tld : Nil self.validator.validate "example@example", self.new_constraint mode: CONSTRAINT::Mode::HTML5_ALLOW_NO_TLD self.assert_no_violation end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/equal_to_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::EqualTo struct EqualToValidatorTest < AVD::Spec::ComparisonConstraintValidatorTestCase def valid_comparisons : Tuple { {3, 3}, {'a', 'a'}, {"a", "a"}, {Time.utc(2020, 4, 7), Time.utc(2020, 4, 7)}, {nil, false}, } end def invalid_comparisons : Tuple { {1, 3}, {'b', 'a'}, {"b", "a"}, {Time.utc(2020, 4, 8), Time.utc(2020, 4, 7)}, } end def error_code : String CONSTRAINT::NOT_EQUAL_ERROR end def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/file_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::File struct FileTest < ASPEC::TestCase @[DataProvider("valid_sizes")] def test_max_size(max_size : String | Int, bytes : Int, binary_format : Bool) : Nil constraint = CONSTRAINT.new max_size: max_size constraint.max_size.should eq bytes constraint.binary_format?.should eq binary_format end @[DataProvider("invalid_sizes")] def test_invalid_max_size(max_size : String | Int) : Nil expect_raises ArgumentError do CONSTRAINT.new max_size: max_size end end @[DataProvider("formats")] def test_binary_format(max_size : String | Int, guessed_format : Bool?, binary_format : Bool) : Nil CONSTRAINT.new(max_size: max_size, binary_format: guessed_format).binary_format?.should eq binary_format end def valid_sizes : Tuple { {"500", 500, false}, {12_300, 12_300, false}, {"1ki", 1_024, true}, {"1KI", 1_024, true}, {"2k", 2_000, false}, {"2K", 2_000, false}, {"1mi", 1_048_576, true}, {"1MI", 1_048_576, true}, {"3m", 3_000_000, false}, {"3M", 3_000_000, false}, {"1gi", 1_073_741_824, true}, {"1GI", 1_073_741_824, true}, {"4g", 4_000_000_000, false}, {"4G", 4_000_000_000, false}, } end def invalid_sizes : Tuple { {"foo"}, {"1Ko"}, {"1kio"}, } end def formats : Tuple { {100, nil, false}, {100, true, true}, {100, false, false}, {"100K", nil, false}, {"100K", true, true}, {"100K", false, false}, {"100Ki", nil, true}, {"100Ki", true, true}, {"100Ki", false, false}, } end end ================================================ FILE: src/components/validator/spec/constraints/file_validator_ath_file_spec.cr ================================================ require "../spec_helper" require "./file_validator_test_case" private alias CONSTRAINT = AVD::Constraints::File struct FileValidatorATHFileTest < FileValidatorTestCase protected def get_file(file_path : String) Athena::HTTP::File.new file_path end end ================================================ FILE: src/components/validator/spec/constraints/file_validator_path_spec.cr ================================================ require "../spec_helper" require "./file_validator_test_case" private alias CONSTRAINT = AVD::Constraints::File struct FileValidatorPathTest < FileValidatorTestCase def test_not_found : Nil self.validator.validate "foo", self.new_constraint not_found_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_FOUND_ERROR) .add_parameter("{{ file }}", "foo") .assert_violation end protected def get_file(file_path : String) file_path end end ================================================ FILE: src/components/validator/spec/constraints/file_validator_std_file_spec.cr ================================================ require "../spec_helper" require "./file_validator_test_case" private alias CONSTRAINT = AVD::Constraints::File struct FileValidatorStdlibFileTest < FileValidatorTestCase protected def get_file(file_path : String) ::File.new file_path end end ================================================ FILE: src/components/validator/spec/constraints/file_validator_test_case.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::File abstract struct FileValidatorTestCase < AVD::Spec::ConstraintValidatorTestCase @file : File def initialize super @file = File.open Path[Dir.tempdir, "file_validator_test"], "w" @file.print " " @file.flush end def tear_down : Nil super @file.delete end def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_blank_is_valid : Nil self.validator.validate "", self.new_constraint self.assert_no_violation end def test_valid_file : Nil self.validator.validate @file.path, self.new_constraint self.assert_no_violation end def test_valid_uploaded_file : Nil File.write @file.path, "1" self.validator.validate AHTTP::UploadedFile.new(@file.path, "original_name", test: true), self.new_constraint self.assert_no_violation end @[DataProvider("max_size_exceeded")] def test_max_size_exceeded(bytes_written : Int, limit : Int | String, size_as_string : String, limit_as_string : String, suffix : String) : Nil self.write_bytes bytes_written self.validator.validate self.get_file(@file.path), self.new_constraint max_size: limit, max_size_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_LARGE_ERROR) .add_parameter("{{ limit }}", limit_as_string) .add_parameter("{{ size }}", size_as_string) .add_parameter("{{ suffix }}", suffix) .add_parameter("{{ file }}", @file.path) .add_parameter("{{ name }}", File.basename @file.path) .assert_violation end def max_size_exceeded : Tuple { # Limit in bytes {1_001, 1_000, "1001.0", "1000.0", "bytes"}, {1_004, 1_000, "1004.0", "1000.0", "bytes"}, # {1005, 1000, "1.01", "1.0", "kB"}, {1_006, 1_000, "1.01", "1.0", "kB"}, {1_000_001, 1_000_000, "1000001.0", "1000000.0", "bytes"}, {1_004_999, 1_000_000, "1005.0", "1000.0", "kB"}, # {1_005_000, 1_000_000, "1.01", "1.0", "MB"}, {1_006_000, 1_000_000, "1.01", "1.0", "MB"}, # Limit in kB {1_001, "1k", "1001.0", "1000.0", "bytes"}, {1_004, "1k", "1004.0", "1000.0", "bytes"}, # {1005, "1k", "1.01", "1.0", "kB"}, {1_006, "1k", "1.01", "1.0", "kB"}, {1_000_001, "1000k", "1000001.0", "1000000.0", "bytes"}, {1_004_999, "1000k", "1005.0", "1000.0", "kB"}, # {1_005_000, "1000k", "1.01", "1.0", "MB"}, {1_006_000, "1000k", "1.01", "1.0", "MB"}, # Limit in MB {1_000_001, "1M", "1000001.0", "1000000.0", "bytes"}, {1_004_999, "1M", "1005.0", "1000.0", "kB"}, # {1_005_000, "1M", "1.01", "1.0", "MB"}, {1_006_000, "1M", "1.01", "1.0", "MB"}, # Limit in KiB {1_025, "1Ki", "1025.0", "1024.0", "bytes"}, {1_029, "1Ki", "1029.0", "1024.0", "bytes"}, {1_030, "1Ki", "1.01", "1.0", "KiB"}, {1_048_577, "1024Ki", "1048577.0", "1048576.0", "bytes"}, {1_053_818, "1024Ki", "1029.12", "1024.0", "KiB"}, {1_053_819, "1024Ki", "1.01", "1.0", "MiB"}, # Limit in MiB {1_048_577, "1Mi", "1048577.0", "1048576.0", "bytes"}, {1_053_818, "1Mi", "1029.12", "1024.0", "KiB"}, {1_053_819, "1Mi", "1.01", "1.0", "MiB"}, # limit < coef {169_632, "100k", "169.63", "100.0", "kB"}, {1_000_001, "990K", "1000.0", "990.0", "kB"}, {123, "80", "123.0", "80.0", "bytes"}, } end @[DataProvider("max_size_not_exceeded")] def test_max_size_exceeded(bytes_written : Int, limit : Int | String) : Nil self.write_bytes bytes_written self.validator.validate self.get_file(@file.path), self.new_constraint max_size: limit, max_size_message: "my_message" self.assert_no_violation end def max_size_not_exceeded : Tuple { # Limit in bytes {1_000, 1_000}, {1_000_000, 1_000_000}, # Limit in kB {1_000, "1k"}, {1_000_000, "1000k"}, # Limit in MB {1_000_000, "1M"}, # Limit in KiB {1_024, "1Ki"}, {1_048_576, "1024Ki"}, # Limit in MiB {1_048_576, "1Mi"}, } end @[DataProvider("max_size_exceeded_binary_format")] def test_max_size_exceeded(bytes_written : Int, limit : Int | String, binary_format : Bool?, size_as_string : String, limit_as_string : String, suffix : String) : Nil self.write_bytes bytes_written self.validator.validate self.get_file(@file.path), self.new_constraint max_size: limit, binary_format: binary_format, max_size_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_LARGE_ERROR) .add_parameter("{{ limit }}", limit_as_string) .add_parameter("{{ size }}", size_as_string) .add_parameter("{{ suffix }}", suffix) .add_parameter("{{ file }}", @file.path) .add_parameter("{{ name }}", File.basename @file.path) .assert_violation end def max_size_exceeded_binary_format : Tuple { {11, 10, nil, "11.0", "10.0", "bytes"}, {11, 10, true, "11.0", "10.0", "bytes"}, {11, 10, false, "11.0", "10.0", "bytes"}, {1_010, 1000, nil, "1.01", "1.0", "kB"}, {1_010, "1k", nil, "1.01", "1.0", "kB"}, {1_035, "1Ki", nil, "1.01", "1.0", "KiB"}, {1_035, 1024, true, "1.01", "1.0", "KiB"}, {1_034_240, "1024k", true, "1010.0", "1000.0", "KiB"}, {1_035, "1Ki", true, "1.01", "1.0", "KiB"}, {1_010, 1000, false, "1.01", "1.0", "kB"}, {1_010, "1k", false, "1.01", "1.0", "kB"}, {10_343, "10Ki", false, "10.34", "10.24", "kB"}, } end def test_valid_mime_type : Nil self.validator.validate Path[__DIR__, "fixtures/foo.png"], self.new_constraint mime_types: {"image/png", "image/jpeg"} self.assert_no_violation end def test_valid_wildcard_mime_type : Nil self.validator.validate Path[__DIR__, "fixtures/foo.png"], self.new_constraint mime_types: {"image/*"} self.assert_no_violation end def test_invalid_mime_type : Nil File.copy Path[__DIR__, "fixtures/foo.png"], dest_path = Path[Dir.tempdir, "/file_validator_test.png"] self.validator.validate self.get_file(dest_path.to_s), self.new_constraint mime_types: {"application/pdf"}, mime_type_message: "my_message" self .build_violation("my_message", CONSTRAINT::INVALID_MIME_TYPE_ERROR) .add_parameter("{{ type }}", "image/png") .add_parameter("{{ types }}", %(Set{"application/pdf"})) .add_parameter("{{ file }}", dest_path) .add_parameter("{{ name }}", "file_validator_test.png") .assert_violation end def test_invalid_wildcard_mime_type : Nil File.copy Path[__DIR__, "fixtures/foo.png"], dest_path = Path[Dir.tempdir, "/file_validator_test.png"] self.validator.validate self.get_file(dest_path.to_s), self.new_constraint mime_types: {"application/*"}, mime_type_message: "my_message" self .build_violation("my_message", CONSTRAINT::INVALID_MIME_TYPE_ERROR) .add_parameter("{{ type }}", "image/png") .add_parameter("{{ types }}", %(Set{"application/*"})) .add_parameter("{{ file }}", dest_path) .add_parameter("{{ name }}", "file_validator_test.png") .assert_violation end def test_uploaded_file_error : Nil AHTTP::UploadedFile.max_file_size = 100 uploaded_file = AHTTP::UploadedFile.new "#{__DIR__}/fixtures/file-big.txt", "file-big.txt", "text/plain", :size_limit_exceeded self.validator.validate uploaded_file, self.new_constraint max_size: 50, upload_file_size_message: "my_message" self .build_violation("my_message", CONSTRAINT::UPLOAD_FILE_SIZE_ERROR) .add_parameter("{{ limit }}", "50.0") .add_parameter("{{ suffix }}", "bytes") .assert_violation ensure AHTTP::UploadedFile.max_file_size = 0 end def test_uploaded_file_error_mib : Nil AHTTP::UploadedFile.max_file_size = 1024 * 1024 * 10 uploaded_file = AHTTP::UploadedFile.new "#{__DIR__}/fixtures/file-big.txt", "file-big.txt", "text/plain", :size_limit_exceeded self.validator.validate uploaded_file, self.new_constraint upload_file_size_message: "my_message" self .build_violation("my_message", CONSTRAINT::UPLOAD_FILE_SIZE_ERROR) .add_parameter("{{ limit }}", "10.0") .add_parameter("{{ suffix }}", "MiB") .assert_violation ensure AHTTP::UploadedFile.max_file_size = 0 end # def test_uploaded_file_extension : Nil # end # def test_uploaded_file_name_max_length : Nil # end # def test_uploaded_file_charset : Nil # end def test_empty_file : Nil @file.truncate self.validator.validate self.get_file(@file.path), self.new_constraint empty_message: "my_message" self .build_violation("my_message", CONSTRAINT::EMPTY_ERROR) .add_parameter("{{ file }}", @file.path) .add_parameter("{{ name }}", File.basename @file.path) .assert_violation end protected abstract def get_file(file_path : String) private def write_bytes(bytes : Int) : Nil @file.write Random.new.random_bytes bytes - 1 @file.flush end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/fixtures/file-big.txt ================================================ I'm not big, but I'm big enough to carry more than 50 bytes inside me. ================================================ FILE: src/components/validator/spec/constraints/greater_than_or_equal_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::GreaterThanOrEqual struct GreaterThanOrEqualValidatorTest < AVD::Spec::ComparisonConstraintValidatorTestCase def valid_comparisons : Tuple { {3, 2}, {0, 0_u8}, {"333", "22"}, {"22", "22"}, {Time.utc(2020, 4, 8), Time.utc(2020, 4, 7)}, {nil, false}, } end def invalid_comparisons : Tuple { {2, 3}, {"a", "b"}, {Time.utc(2020, 4, 6), Time.utc(2020, 4, 7)}, } end def test_invalid_type : Nil expect_raises AVD::Exception::UnexpectedValueError, "Expected argument of type 'Number | String | Time', 'Bool' given." do self.validator.validate false, new_constraint value: 50 end end def error_code : String CONSTRAINT::TOO_LOW_ERROR end def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/greater_than_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::GreaterThan struct GreaterThanValidatorTest < AVD::Spec::ComparisonConstraintValidatorTestCase def valid_comparisons : Tuple { {3, 2}, {"333", "22"}, {Time.utc(2020, 4, 8), Time.utc(2020, 4, 7)}, {nil, false}, } end def invalid_comparisons : Tuple { {2, 3}, {3, 3}, {"a", "b"}, {Time.utc(2020, 4, 6), Time.utc(2020, 4, 7)}, } end def test_invalid_type : Nil expect_raises AVD::Exception::UnexpectedValueError, "Expected argument of type 'Number | String | Time', 'Bool' given." do self.validator.validate false, new_constraint value: 50 end end def error_code : String CONSTRAINT::TOO_LOW_ERROR end def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/hash_collection_validator_spec.cr ================================================ require "../spec_helper" require "./collection_validator_test_case" struct HashCollectionValidatorTest < CollectionValidatorTestCase private def prepare_test_data(contents : Hash) contents end end ================================================ FILE: src/components/validator/spec/constraints/hash_like_object_collection_validator_spec.cr ================================================ require "../spec_helper" require "./collection_validator_test_case" private struct HashLikeObject include Enumerable({String | Int32, Int32?}) @data = {} of String => Int32? delegate :each, to: @data def has_key?(key : String | Int32) : Bool @data.has_key? key end def [](key : String | Int32) : Int32? @data[key] end def []=(key : String | Int32, value : Int32?) @data[key] = value end end struct HashLikeObjectCollectionValidatorTest < CollectionValidatorTestCase private def prepare_test_data(contents : Hash) collection = HashLikeObject.new contents.each do |k, v| collection[k] = v end collection end end ================================================ FILE: src/components/validator/spec/constraints/image_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Image struct ImageValidatorTestCase < AVD::Spec::ConstraintValidatorTestCase def initialize super @image = "#{__DIR__}/fixtures/2x2.gif" @image_landscape = "#{__DIR__}/fixtures/landscape.gif" @image_portrait = "#{__DIR__}/fixtures/portrait.gif" @image_4x3 = "#{__DIR__}/fixtures/4x3.gif" @image_16x9 = "#{__DIR__}/fixtures/16x9.gif" end def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_blank_is_valid : Nil self.validator.validate "", self.new_constraint self.assert_no_violation end def test_valid_image : Nil self.validator.validate @image, self.new_constraint self.assert_no_violation end def test_image_not_found : Nil self.validator.validate "foo", self.new_constraint not_found_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_FOUND_ERROR) .add_parameter("{{ file }}", "foo") .assert_violation end def test_valid_sizes : Nil self.validator.validate @image, self.new_constraint min_width: 1, max_width: 2, min_height: 1, max_height: 2 self.assert_no_violation end def test_width_too_small : Nil self.validator.validate @image, self.new_constraint min_width: 3, min_width_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_NARROW_ERROR) .add_parameter("{{ width }}", 2) .add_parameter("{{ min_width }}", 3) .assert_violation end def test_width_too_big : Nil self.validator.validate @image, self.new_constraint max_width: 1, max_width_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_WIDE_ERROR) .add_parameter("{{ width }}", 2) .add_parameter("{{ max_width }}", 1) .assert_violation end def test_height_too_small : Nil self.validator.validate @image, self.new_constraint min_height: 3, min_height_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_LOW_ERROR) .add_parameter("{{ height }}", 2) .add_parameter("{{ min_height }}", 3) .assert_violation end def test_height_too_big : Nil self.validator.validate @image, self.new_constraint max_height: 1, max_height_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_HIGH_ERROR) .add_parameter("{{ height }}", 2) .add_parameter("{{ max_height }}", 1) .assert_violation end def test_too_few_pixels : Nil self.validator.validate @image, self.new_constraint min_pixels: 5.0, min_pixels_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_FEW_PIXEL_ERROR) .add_parameter("{{ pixels }}", 4) .add_parameter("{{ min_pixels }}", 5.0) .add_parameter("{{ width }}", 2) .add_parameter("{{ height }}", 2) .assert_violation end def test_too_many_pixels : Nil self.validator.validate @image, self.new_constraint max_pixels: 3.0, max_pixels_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_MANY_PIXEL_ERROR) .add_parameter("{{ pixels }}", 4) .add_parameter("{{ max_pixels }}", 3.0) .add_parameter("{{ width }}", 2) .add_parameter("{{ height }}", 2) .assert_violation end def test_ratio_too_small : Nil self.validator.validate @image, self.new_constraint min_ratio: 2.0, min_ratio_message: "my_message" self .build_violation("my_message", CONSTRAINT::RATIO_TOO_SMALL_ERROR) .add_parameter("{{ ratio }}", 1.0) .add_parameter("{{ min_ratio }}", 2.0) .assert_violation end def test_ratio_too_big : Nil self.validator.validate @image, self.new_constraint max_ratio: 0.5, max_ratio_message: "my_message" self .build_violation("my_message", CONSTRAINT::RATIO_TOO_BIG_ERROR) .add_parameter("{{ ratio }}", 1.0) .add_parameter("{{ max_ratio }}", 0.5) .assert_violation end def test_max_ratio_uses_two_decimals : Nil self.validator.validate @image_4x3, self.new_constraint max_ratio: 1.33 self.assert_no_violation end def test_min_ratio_uses_input_more_decimals : Nil self.validator.validate @image_4x3, self.new_constraint min_ratio: 4 / 3 self.assert_no_violation end def test_max_ratio_uses_input_more_decimals : Nil self.validator.validate @image_16x9, self.new_constraint min_ratio: 16 / 9 self.assert_no_violation end def test_square_not_allowed : Nil self.validator.validate @image, self.new_constraint allow_square: false, allow_square_message: "my_message" self .build_violation("my_message", CONSTRAINT::SQUARE_NOT_ALLOWED_ERROR) .add_parameter("{{ width }}", 2) .add_parameter("{{ height }}", 2) .assert_violation end def test_landscape_not_allowed : Nil self.validator.validate @image_landscape, self.new_constraint allow_landscape: false, allow_landscape_message: "my_message" self .build_violation("my_message", CONSTRAINT::LANDSCAPE_NOT_ALLOWED_ERROR) .add_parameter("{{ width }}", 2) .add_parameter("{{ height }}", 1) .assert_violation end def test_portrait_not_allowed : Nil self.validator.validate @image_portrait, self.new_constraint allow_portrait: false, allow_portrait_message: "my_message" self .build_violation("my_message", CONSTRAINT::PORTRAIT_NOT_ALLOWED_ERROR) .add_parameter("{{ width }}", 1) .add_parameter("{{ height }}", 2) .assert_violation end def ptest_invalid_mime_narrowed_set : Nil self.validator.validate @image, self.new_constraint mime_types: ["image/jpeg", "image/png"] # TODO: Figure out a good way to make it so it doesn't actually translate the message of the actual violation. # Possibly some internal `TranslatorInterface` implementation to support a future `Athena::Translator` component. self .build_violation("The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}.", CONSTRAINT::INVALID_MIME_TYPE_ERROR) .add_parameter("{{ file }}", @image) .add_parameter("{{ type }}", "image/gif") .add_parameter("{{ types }}", %(Set{"image/jpeg", "image/png"})) .add_parameter("{{ name }}", "2x2.gif") .assert_violation end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/ip_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::IP struct IPValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_empty_string_is_valid : Nil self.validator.validate "", self.new_constraint end @[DataProvider("valid_v4s")] def test_valid_v4s(value : String) : Nil self.validator.validate value, self.new_constraint self.assert_no_violation end def valid_v4s : Tuple { {"0.0.0.0"}, {"10.0.0.0"}, {"123.45.67.178"}, {"172.16.0.0"}, {"192.168.1.0"}, {"224.0.0.1"}, {"255.255.255.255"}, {"127.0.0.0"}, } end @[DataProvider("valid_v6s")] def test_valid_v6s(value : String) : Nil self.validator.validate value, self.new_constraint version: CONSTRAINT::Version::V6 self.assert_no_violation end def valid_v6s : Tuple { {"2001:0db8:85a3:0000:0000:8a2e:0370:7334"}, {"2001:0DB8:85A3:0000:0000:8A2E:0370:7334"}, {"2001:0Db8:85a3:0000:0000:8A2e:0370:7334"}, {"fdfe:dcba:9876:ffff:fdc6:c46b:bb8f:7d4c"}, {"fdc6:c46b:bb8f:7d4c:fdc6:c46b:bb8f:7d4c"}, {"fdc6:c46b:bb8f:7d4c:0000:8a2e:0370:7334"}, {"fe80:0000:0000:0000:0202:b3ff:fe1e:8329"}, {"fe80:0:0:0:202:b3ff:fe1e:8329"}, {"fe80::202:b3ff:fe1e:8329"}, {"0:0:0:0:0:0:0:0"}, {"::"}, {"0::"}, {"::0"}, {"0::0"}, {"2001:0db8:85a3:0000:0000:8a2e:0.0.0.0"}, # IPv4 mapped to IP {"::0.0.0.0"}, {"::255.255.255.255"}, {"::123.45.67.178"}, } end @[DataProvider("valid_v4s_v6s")] def test_valid_v4s_v6s(value : String) : Nil self.validator.validate value, self.new_constraint version: CONSTRAINT::Version::V4_V6 self.assert_no_violation end def valid_v4s_v6s : Tuple self.valid_v4s + self.valid_v6s end @[DataProvider("invalid_v4s")] def test_invalid_v4s(value : String) : Nil self.validator.validate value, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::INVALID_IP_ERROR, value end def invalid_v4s : Tuple { {"0"}, {"0.0"}, {"0.0.0"}, {"256.0.0.0"}, {"0.256.0.0"}, {"0.0.256.0"}, {"0.0.0.256"}, {"-1.0.0.0"}, {"foobar"}, } end @[DataProvider("invalid_v6s")] def test_invalid_v6s(value : String) : Nil self.validator.validate value, self.new_constraint message: "my_message", version: CONSTRAINT::Version::V6 self.assert_violation "my_message", CONSTRAINT::INVALID_IP_ERROR, value end def invalid_v6s : Tuple { {"z001:0db8:85a3:0000:0000:8a2e:0370:7334"}, {"fe80"}, {"fe80:8329"}, {"fe80:::202:b3ff:fe1e:8329"}, {"fe80::202:b3ff::fe1e:8329"}, {"2001:0db8:85a3:0000:0000:8a2e:0370:0.0.0.0"}, # IPv4 mapped to IPv6 {"::0.0"}, {"::0.0.0"}, {"::256.0.0.0"}, {"::0.256.0.0"}, {"::0.0.256.0"}, {"::0.0.0.256"}, } end @[DataProvider("invalid_v4s_v6s")] def test_invalid_v4s_v6s(value : String) : Nil self.validator.validate value, self.new_constraint message: "my_message", version: CONSTRAINT::Version::V4_V6 self.assert_violation "my_message", CONSTRAINT::INVALID_IP_ERROR, value end def invalid_v4s_v6s : Tuple self.invalid_v4s + self.invalid_v6s end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/is_false_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::IsFalse struct IsFalseValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_false_is_valid : Nil self.validator.validate false, self.new_constraint self.assert_no_violation end def test_true_is_invalid : Nil self.validator.validate true, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::NOT_FALSE_ERROR, true end def test_zero_is_invalid : Nil self.validator.validate 0, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::NOT_FALSE_ERROR, 0 end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/is_nil_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::IsNil struct IsNilValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end @[DataProvider("invalid_values")] def test_invalid_values(value : _) : Nil self.validator.validate value, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::NOT_NIL_ERROR, value end def invalid_values : Tuple { {"foobar"}, {0}, {false}, {true}, {""}, {Time.utc}, {[] of Int32}, {Pointer(Void).null}, {1234}, } end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/is_true_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::IsTrue struct IsTrueValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_true_is_valid : Nil self.validator.validate true, self.new_constraint self.assert_no_violation end def test_false_is_invalid : Nil self.validator.validate false, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::NOT_TRUE_ERROR, false end def test_one_is_invalid : Nil self.validator.validate 1, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::NOT_TRUE_ERROR, 1 end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/isbn_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::ISBN struct ISBNValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_empty_string_is_valid : Nil self.validator.validate "", self.new_constraint end def test_message_is_used_if_set : Nil self.validator.validate "asdf", self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::INVALID_CHARACTERS_ERROR, "asdf" end @[DataProvider("valid_isbn10s")] def test_valid_isbn10s(value : String) : Nil self.validator.validate value, self.new_constraint type: CONSTRAINT::Type::ISBN10 self.assert_no_violation end def valid_isbn10s : Tuple { {"2723442284"}, {"2723442276"}, {"2723455041"}, {"2070546810"}, {"2711858839"}, {"2756406767"}, {"2870971648"}, {"226623854X"}, {"2851806424"}, {"0321812700"}, {"0-45122-5244"}, {"0-4712-92311"}, {"0-9752298-0-X"}, } end @[DataProvider("valid_isbn13s")] def test_valid_isbn13s(value : String) : Nil self.validator.validate value, self.new_constraint type: CONSTRAINT::Type::ISBN13 self.assert_no_violation end def valid_isbn13s : Tuple { {"978-2723442282"}, {"978-2723442275"}, {"978-2723455046"}, {"978-2070546817"}, {"978-2711858835"}, {"978-2756406763"}, {"978-2870971642"}, {"978-2266238540"}, {"978-2851806420"}, {"978-0321812704"}, {"978-0451225245"}, {"978-0471292319"}, } end @[DataProvider("valid_isbns")] def test_valid_both(value : String) : Nil self.validator.validate value, self.new_constraint type: CONSTRAINT::Type::Both self.assert_no_violation end def valid_isbns : Tuple self.valid_isbn10s + self.valid_isbn13s end @[DataProvider("invalid_isbn10s")] def test_invalid_isbn10s(value : String, code : String) : Nil self.validator.validate value, self.new_constraint type: CONSTRAINT::Type::ISBN10, isbn10_message: "my_message" self.assert_violation "my_message", code, value end def invalid_isbn10s : Tuple { {"27234422841", CONSTRAINT::TOO_LONG_ERROR}, {"272344228", CONSTRAINT::TOO_SHORT_ERROR}, {"0-4712-9231", CONSTRAINT::TOO_SHORT_ERROR}, {"1234567890", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"0987656789", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"7-35622-5444", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"0-4X19-92611", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"0_45122_5244", CONSTRAINT::INVALID_CHARACTERS_ERROR}, {"2870#971#648", CONSTRAINT::INVALID_CHARACTERS_ERROR}, {"0-9752298-0-x", CONSTRAINT::INVALID_CHARACTERS_ERROR}, {"1A34567890", CONSTRAINT::INVALID_CHARACTERS_ERROR}, {"2#{1.chr}70546810", CONSTRAINT::INVALID_CHARACTERS_ERROR}, } end @[DataProvider("invalid_isbn13s")] def test_invalid_isbn13s(value : String, code : String) : Nil self.validator.validate value, self.new_constraint type: CONSTRAINT::Type::ISBN13, isbn13_message: "my_message" self.assert_violation "my_message", code, value end def invalid_isbn13s : Tuple { {"978-27234422821", CONSTRAINT::TOO_LONG_ERROR}, {"978-272344228", CONSTRAINT::TOO_SHORT_ERROR}, {"978-2723442-82", CONSTRAINT::TOO_SHORT_ERROR}, {"978-2723442281", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"978-0321513774", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"979-0431225385", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"980-0474292319", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"0-4X19-92619812", CONSTRAINT::INVALID_CHARACTERS_ERROR}, {"978_2723442282", CONSTRAINT::INVALID_CHARACTERS_ERROR}, {"978#2723442282", CONSTRAINT::INVALID_CHARACTERS_ERROR}, {"978-272C442282", CONSTRAINT::INVALID_CHARACTERS_ERROR}, {"978-2#{1.chr}70546817", CONSTRAINT::INVALID_CHARACTERS_ERROR}, } end @[DataProvider("invalid_isbn10s")] def test_invalid_both_isbn10s(value : String, code : String) : Nil self.validator.validate value, self.new_constraint type: CONSTRAINT::Type::Both, both_message: "my_message" # Too long for ISBN-10, but not long enough for ISBN-13 if CONSTRAINT::TOO_LONG_ERROR == code code = CONSTRAINT::TYPE_NOT_RECOGNIZED_ERROR end self.assert_violation "my_message", code, value end @[DataProvider("invalid_isbn13s")] def test_invalid_both_isbn13s(value : String, code : String) : Nil self.validator.validate value, self.new_constraint type: CONSTRAINT::Type::Both, both_message: "my_message" # Too short for an ISBN-13, but not short enough for an ISBN-10 if CONSTRAINT::TOO_SHORT_ERROR == code code = CONSTRAINT::TYPE_NOT_RECOGNIZED_ERROR end self.assert_violation "my_message", code, value end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/isin_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::ISIN struct ISINValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_empty_string_is_valid : Nil self.validator.validate "", self.new_constraint end @[DataProvider("valid_isins")] def test_valid_isins(value : String) : Nil self.validator.validate value, self.new_constraint self.expect_violation_at 0, value, AVD::Constraints::Luhn.new self.assert_no_violation end def valid_isins : Tuple { {"XS2125535901"}, # Goldman Sachs International {"DE0005140008"}, # Deutsche Bankg AG {"CH0528261156"}, # Leonteq Securities AG [Guernsey] {"US0378331005"}, # Apple, Inc. {"AU0000XVGZA3"}, # TREASURY CORP VICTORIA 5 3/4% 2005-2016 {"GB0002634946"}, # BAE Systems {"CH0528261099"}, # Leonteq Securities AG [Guernsey] {"XS2155672814"}, # OP Corporate Bank plc {"XS2155687259"}, # Orbian Financial Services III, LLC {"XS2155696672"}, # Sheffield Receivables Company LLC } end @[DataProvider("invalid_length_isins")] def test_invalid_length_isins(value : String) : Nil self.assert_violation value, CONSTRAINT::INVALID_LENGTH_ERROR end def invalid_length_isins : Tuple { {"X"}, {"XS"}, {"XS2"}, {"XS21"}, {"XS215"}, {"XS2155"}, {"XS21556"}, {"XS215569"}, {"XS2155696"}, {"XS21556966"}, {"XS215569667"}, } end @[DataProvider("invalid_pattern_isins")] def test_invalid_pattern_isins(value : String) : Nil self.assert_violation value, CONSTRAINT::INVALID_PATTERN_ERROR end def invalid_pattern_isins : Tuple { {"X12155696679"}, {"123456789101"}, {"XS215569667E"}, {"XS215E69667A"}, } end @[DataProvider("invalid_checksum_isins")] def test_invalid_checksum_isins(value : String) : Nil self.expect_violation_at 0, value, AVD::Constraints::Luhn.new self.assert_violation value, CONSTRAINT::INVALID_CHECKSUM_ERROR end def invalid_checksum_isins : Tuple { {"XS2112212144"}, {"DE013228VA77"}, {"CH0512361156"}, {"XS2125660123"}, {"XS2012587408"}, {"XS2012380102"}, {"XS2012239364"}, } end private def assert_violation(isin : String, code : String) : Nil self.validator.validate isin, self.new_constraint message: "my_message" self.assert_violation "my_message", code, isin end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/issn_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::ISSN struct ISSNValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_empty_string_is_valid : Nil self.validator.validate "", self.new_constraint end @[DataProvider("valid_lowercase_issns")] def test_case_sensitive_issns(value : String) : Nil self.validator.validate value, self.new_constraint message: "my_message", case_sensitive: true self.assert_violation "my_message", CONSTRAINT::INVALID_CASE_ERROR, value end def valid_lowercase_issns : Tuple { {"2162-321x"}, {"2160-200x"}, {"1537-453x"}, {"1937-710x"}, {"0002-922x"}, {"1553-345x"}, {"1553-619x"}, } end @[DataProvider("valid_non_hyphenated_issns")] def test_hyphen_required_issns(value : String) : Nil self.validator.validate value, self.new_constraint message: "my_message", require_hyphen: true self.assert_violation "my_message", CONSTRAINT::MISSING_HYPHEN_ERROR, value end def valid_non_hyphenated_issns : Tuple { {"2162321X"}, {"01896016"}, {"15744647"}, {"14350645"}, {"07174055"}, {"20905076"}, {"14401592"}, } end def valid_full_issns : Tuple { {"1550-7416"}, {"1539-8560"}, {"2156-5376"}, {"1119-023X"}, {"1684-5315"}, {"1996-0786"}, {"1684-5374"}, {"1996-0794"}, } end @[DataProvider("valid_issns")] def test_valid_issns(value : String) : Nil self.validator.validate value, self.new_constraint self.assert_no_violation end def valid_issns : Tuple self.valid_lowercase_issns + self.valid_non_hyphenated_issns + self.valid_full_issns end def invalid_issns : Tuple { {0, CONSTRAINT::TOO_SHORT_ERROR}, {"1539", CONSTRAINT::TOO_SHORT_ERROR}, {"2156-537A", CONSTRAINT::INVALID_CHARACTERS_ERROR}, {"1119-0231", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"1684-5312", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"1996-0783", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"1684-537X", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"1996-0795", CONSTRAINT::CHECKSUM_FAILED_ERROR}, } end @[DataProvider("invalid_issns")] def test_invalid_issns(value : String | Number, code : String) : Nil self.validator.validate value, self.new_constraint message: "my_message" self.assert_violation "my_message", code, value end private def create_validator : AVD::ConstraintValidatorInterface AVD::Constraints::ISSN::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/length_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Length struct LengthValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint(range: (1..1), exact_message: "my_message") self.assert_no_violation end def three_or_less : Tuple { {12, 2}, {"12", 2}, {"üü", 2}, {"éé", 2}, {123, 3}, {"123", 3}, {"üüü", 3}, {"ééé", 3}, } end def four : Tuple { {1234}, {"1234"}, {"üüüü"}, {"éééé"}, } end def five_or_more : Tuple { {12345, 5}, {"12345", 5}, {"üüüüü", 5}, {"ééééé", 5}, {123_456, 6}, {"123456", 6}, {"üüüüüü", 6}, {"éééééé", 6}, } end @[DataProvider("five_or_more")] def test_valid_values_min(value : Int32 | String, value_length : Int32) : Nil self.validator.validate value, self.new_constraint range: (5..) self.assert_no_violation end @[DataProvider("three_or_less")] def test_valid_values_max(value : Int32 | String, value_length : Int32) : Nil self.validator.validate value, self.new_constraint range: (..3) self.assert_no_violation end @[DataProvider("four")] def test_valid_values_exact(value : Int32 | String) : Nil self.validator.validate value, self.new_constraint range: (4..4) self.assert_no_violation end def test_valid_graphemes_values : Nil self.validator.validate "A\u{0300}", self.new_constraint range: (1..1), unit: CONSTRAINT::Unit::GRAPHEMES self.assert_no_violation end def test_valid_codepoints_values : Nil self.validator.validate "A\u{0300}", self.new_constraint range: (2..2), unit: CONSTRAINT::Unit::CODEPOINTS self.assert_no_violation end def test_valid_bytes_values : Nil self.validator.validate "A\u{0300}", self.new_constraint range: (3..3), unit: CONSTRAINT::Unit::BYTES self.assert_no_violation end @[DataProvider("three_or_less")] def test_invalid_values_min(value : Int32 | String, value_length : Int32) : Nil self.validator.validate value, self.new_constraint range: (4..), min_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_SHORT_ERROR, value) .add_parameter("{{ value }}", value.to_s) .add_parameter("{{ limit }}", 4) .add_parameter("{{ min }}", 4) .add_parameter("{{ value_length }}", value_length) .plural(4) .invalid_value(value) .assert_violation end @[DataProvider("five_or_more")] def test_invalid_values_max(value : Int32 | String, value_length : Int32) : Nil self.validator.validate value, self.new_constraint range: (..4), max_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_LONG_ERROR, value) .add_parameter("{{ value }}", value.to_s) .add_parameter("{{ limit }}", 4) .add_parameter("{{ max }}", 4) .add_parameter("{{ value_length }}", value_length) .plural(4) .invalid_value(value) .assert_violation end @[DataProvider("three_or_less")] def test_invalid_values_exact_less_than_four(value : Int32 | String, value_length : Int32) : Nil self.validator.validate value, self.new_constraint range: (4..4), exact_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_EQUAL_LENGTH_ERROR, value) .add_parameter("{{ value }}", value.to_s) .add_parameter("{{ limit }}", 4) .add_parameter("{{ min }}", 4) .add_parameter("{{ max }}", 4) .add_parameter("{{ value_length }}", value_length) .plural(4) .invalid_value(value) .assert_violation end @[DataProvider("five_or_more")] def test_invalid_values_exact_more_than_four(value : Int32 | String, value_length : Int32) : Nil self.validator.validate value, self.new_constraint range: (4..4), exact_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_EQUAL_LENGTH_ERROR, value) .add_parameter("{{ value }}", value.to_s) .add_parameter("{{ limit }}", 4) .add_parameter("{{ min }}", 4) .add_parameter("{{ max }}", 4) .add_parameter("{{ value_length }}", value_length) .plural(4) .invalid_value(value) .assert_violation end def test_invalid_values_exact_default_unit_with_grapheme_input : Nil self.validator.validate value = "A\u{0300}", self.new_constraint range: (1..1), exact_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_EQUAL_LENGTH_ERROR, value) .add_parameter("{{ value }}", value) .add_parameter("{{ limit }}", 1) .add_parameter("{{ min }}", 1) .add_parameter("{{ max }}", 1) .add_parameter("{{ value_length }}", 2) .plural(1) .invalid_value(value) .assert_violation end def test_invalid_values_exact_bytes_unit_with_grapheme_input : Nil self.validator.validate value = "A\u{0300}", self.new_constraint range: (1..1), exact_message: "my_message", unit: CONSTRAINT::Unit::BYTES self .build_violation("my_message", CONSTRAINT::NOT_EQUAL_LENGTH_ERROR, value) .add_parameter("{{ value }}", value) .add_parameter("{{ limit }}", 1) .add_parameter("{{ min }}", 1) .add_parameter("{{ max }}", 1) .add_parameter("{{ value_length }}", 3) .plural(1) .invalid_value(value) .assert_violation end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/less_than_or_equal_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::LessThanOrEqual struct LessThanOrEqualValidatorTest < AVD::Spec::ComparisonConstraintValidatorTestCase def valid_comparisons : Tuple { {2, 3}, {0, 0_u8}, {"a", "b"}, {"22", "22"}, {Time.utc(2020, 4, 6), Time.utc(2020, 4, 7)}, {nil, false}, } end def invalid_comparisons : Tuple { {3, 2}, {"333", "22"}, {Time.utc(2020, 4, 8), Time.utc(2020, 4, 7)}, } end def test_invalid_type : Nil expect_raises AVD::Exception::UnexpectedValueError, "Expected argument of type 'Number | String | Time', 'Bool' given." do self.validator.validate false, new_constraint value: 50 end end def error_code : String CONSTRAINT::TOO_HIGH_ERROR end def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/less_than_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::LessThan struct LessThanValidatorTest < AVD::Spec::ComparisonConstraintValidatorTestCase def valid_comparisons : Tuple { {2, 3}, {"a", "b"}, {Time.utc(2020, 4, 6), Time.utc(2020, 4, 7)}, {nil, false}, } end def invalid_comparisons : Tuple { {3, 2}, {3, 3}, {"333", "22"}, {Time.utc(2020, 4, 8), Time.utc(2020, 4, 7)}, } end def test_invalid_type : Nil expect_raises AVD::Exception::UnexpectedValueError, "Expected argument of type 'Number | String | Time', 'Bool' given." do self.validator.validate false, new_constraint value: 50 end end def error_code : String CONSTRAINT::TOO_HIGH_ERROR end def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/luhn_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Luhn struct LuhnValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_empty_string_is_valid : Nil self.validator.validate "", self.new_constraint end @[DataProvider("valid_numbers")] def test_valid_numbers(value : String) : Nil self.validator.validate value, self.new_constraint self.assert_no_violation end def valid_numbers : Tuple { {"42424242424242424242"}, {"378282246310005"}, {"371449635398431"}, {"378734493671000"}, {"5610591081018250"}, {"30569309025904"}, {"38520000023237"}, {"6011111111111117"}, {"6011000990139424"}, {"3530111333300000"}, {"3566002020360505"}, {"5555555555554444"}, {"5105105105105100"}, {"4111111111111111"}, {"4012888888881881"}, {"4222222222222"}, {"5019717010103742"}, {"6331101999990016"}, } end @[DataProvider("invalid_numbers")] def test_invalid_numbers(value : String, code : String) : Nil self.validator.validate value, self.new_constraint message: "my_message" self.assert_violation "my_message", code, value end def invalid_numbers : Tuple { {"1234567812345678", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"4222222222222222", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"0000000000000000", CONSTRAINT::CHECKSUM_FAILED_ERROR}, {"000000!000000000", CONSTRAINT::INVALID_CHARACTERS_ERROR}, {"42-22222222222222", CONSTRAINT::INVALID_CHARACTERS_ERROR}, } end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/negative_or_zero_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::NegativeOrZero struct NegativeOrZeroValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_zero_is_valid : Nil self.validator.validate 0, self.new_constraint self.assert_no_violation end def test_valid_value : Nil self.validator.validate -1, self.new_constraint self.assert_no_violation end @[DataProvider("invalid_values")] def test_invalid_values(value : _) : Nil self.validator.validate value, self.new_constraint message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_HIGH_ERROR, value) .add_parameter("{{ compared_value }}", "0") .add_parameter("{{ compared_value_type }}", "Int32") .assert_violation end def invalid_values : Tuple { {1}, {1234}, } end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/negative_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Negative struct NegativeValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_valid_value : Nil self.validator.validate -1, self.new_constraint self.assert_no_violation end @[DataProvider("invalid_values")] def test_invalid_values(value : _) : Nil self.validator.validate value, self.new_constraint message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_HIGH_ERROR, value) .add_parameter("{{ compared_value }}", "0") .add_parameter("{{ compared_value_type }}", "Int32") .assert_violation end def invalid_values : Tuple { {0}, {1}, {1234}, } end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/not_blank_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::NotBlank struct NotBlankValidatorTest < AVD::Spec::ConstraintValidatorTestCase @[DataProvider("valid_values")] def test_valid_values(value : _) : Nil self.validator.validate value, self.new_constraint self.assert_no_violation end def valid_values : NamedTuple { string: {"foo"}, array: {[1, 2, 3]}, bool: {true}, } end def test_blank_is_invalid self.validator.validate "", self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::IS_BLANK_ERROR, "" end def test_false_is_invalid self.validator.validate false, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::IS_BLANK_ERROR, false end def test_empty_array_is_invalid self.validator.validate [] of String, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::IS_BLANK_ERROR, [] of String end def test_allow_nil_true self.validator.validate nil, self.new_constraint message: "my_message", allow_nil: true self.assert_no_violation end def test_allow_nil_false self.validator.validate nil, self.new_constraint message: "my_message", allow_nil: false self.assert_violation "my_message", CONSTRAINT::IS_BLANK_ERROR, nil end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/not_equal_to_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::NotEqualTo struct NotEqualToValidatorTest < AVD::Spec::ComparisonConstraintValidatorTestCase def valid_comparisons : Tuple { {1, 2}, {'b', 'a'}, {"b", "a"}, {Time.utc(2020, 4, 8), Time.utc(2020, 4, 7)}, {nil, false}, } end def invalid_comparisons : Tuple { {3, 3}, {'a', 'a'}, {"a", "a"}, {Time.utc(2020, 4, 7), Time.utc(2020, 4, 7)}, } end def error_code : String CONSTRAINT::IS_EQUAL_ERROR end def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/not_nil_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::NotNil struct NotNilValidatorTest < AVD::Spec::ConstraintValidatorTestCase @[DataProvider("valid_values")] def test_valid_values(value : _) : Nil self.validator.validate value, self.new_constraint self.assert_no_violation end def valid_values : Tuple { {""}, {false}, {true}, {0}, {Pointer(Void).null}, } end def test_nil_is_invalid self.validator.validate nil, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::IS_NIL_ERROR, nil end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/positive_or_zero_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::PositiveOrZero struct PositiveOrZeroValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_zero_is_valid : Nil self.validator.validate 0, self.new_constraint self.assert_no_violation end def test_valid_value : Nil self.validator.validate 1, self.new_constraint self.assert_no_violation end @[DataProvider("invalid_values")] def test_invalid_values(value : _) : Nil self.validator.validate value, self.new_constraint message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_LOW_ERROR, value) .add_parameter("{{ compared_value }}", "0") .add_parameter("{{ compared_value_type }}", "Int32") .assert_violation end def invalid_values : Tuple { {-1}, {-1234}, } end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/positive_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Positive struct PositiveValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_valid_value : Nil self.validator.validate 1, self.new_constraint self.assert_no_violation end @[DataProvider("invalid_values")] def test_invalid_values(value : _) : Nil self.validator.validate value, self.new_constraint message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_LOW_ERROR, value) .add_parameter("{{ compared_value }}", "0") .add_parameter("{{ compared_value_type }}", "Int32") .assert_violation end def invalid_values : Tuple { {0}, {-1}, {-1234}, } end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/range_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Range struct RangeValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint range: 0..10 self.assert_no_violation end @[DataProvider("ten_to_twenty")] def test_valid_values_min(value : Number?) : Nil self.validator.validate value, self.new_constraint range: (10..) self.assert_no_violation end @[DataProvider("ten_to_twenty")] def test_valid_values_max(value : Number?) : Nil self.validator.validate value, self.new_constraint range: (..20) self.assert_no_violation end @[DataProvider("ten_to_twenty")] def test_valid_values_minmax(value : Number?) : Nil self.validator.validate value, self.new_constraint range: (10..20) self.assert_no_violation end @[DataProvider("less_than_ten")] def test_invalid_values_min(value : Number?) : Nil self.validator.validate value, self.new_constraint range: (10..), min_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_LOW_ERROR, value) .add_parameter("{{ limit }}", 10) .assert_violation end @[DataProvider("more_than_twenty")] def test_invalid_values_max(value : Number?) : Nil self.validator.validate value, self.new_constraint range: (..20), max_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_HIGH_ERROR, value) .add_parameter("{{ limit }}", 20) .assert_violation end @[DataProvider("more_than_twenty")] def test_invalid_values_minmax_max(value : Number?) : Nil self.validator.validate value, self.new_constraint range: (10..20), not_in_range_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_IN_RANGE_ERROR, value) .add_parameter("{{ min }}", 10) .add_parameter("{{ max }}", 20) .assert_violation end @[DataProvider("less_than_ten")] def test_invalid_values_minmax_min(value : Number?) : Nil self.validator.validate value, self.new_constraint range: (10..20), not_in_range_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_IN_RANGE_ERROR, value) .add_parameter("{{ min }}", 10) .add_parameter("{{ max }}", 20) .assert_violation end def test_exclusive_range_included : Nil self.validator.validate 15, self.new_constraint range: (10...20) self.assert_no_violation end def test_exclusive_range_excluded : Nil self.validator.validate 20, self.new_constraint range: (10...20), not_in_range_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_IN_RANGE_ERROR, 20) .add_parameter("{{ min }}", 10) .add_parameter("{{ max }}", 19) .assert_violation end @[DataProvider("ten_to_twnentieth_april_2020")] def test_valid_datetimes_min(value : Time) : Nil self.validator.validate value, self.new_constraint range: (Time.utc(2020, 4, 10)..) self.assert_no_violation end @[DataProvider("ten_to_twnentieth_april_2020")] def test_valid_datetimes_max(value : Time) : Nil self.validator.validate value, self.new_constraint range: (..Time.utc(2020, 4, 20)) self.assert_no_violation end @[DataProvider("ten_to_twnentieth_april_2020")] def test_valid_datetimes_range(value : Time) : Nil self.validator.validate value, self.new_constraint range: (Time.utc(2020, 4, 10)..Time.utc(2020, 4, 20)) self.assert_no_violation end @[DataProvider("before_tenth_april_2020")] def test_invalid_datetimes_min(value : Time) : Nil expected_start_date = Time.utc(2020, 4, 10) self.validator.validate value, self.new_constraint range: (expected_start_date..), min_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_LOW_ERROR, value) .add_parameter("{{ limit }}", expected_start_date) .assert_violation end @[DataProvider("after_twentieth_april_2020")] def test_invalid_datetimes_max(value : Time) : Nil expected_end_date = Time.utc(2020, 4, 20) self.validator.validate value, self.new_constraint range: (..expected_end_date), max_message: "my_message" self .build_violation("my_message", CONSTRAINT::TOO_HIGH_ERROR, value) .add_parameter("{{ limit }}", expected_end_date) .assert_violation end @[DataProvider("after_twentieth_april_2020")] def test_invalid_datetimes_minmax_max(value : Time) : Nil expected_begin_date = Time.utc(2020, 4, 10) expected_end_date = Time.utc(2020, 4, 20) self.validator.validate value, self.new_constraint range: (expected_begin_date..expected_end_date), not_in_range_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_IN_RANGE_ERROR, value) .add_parameter("{{ min }}", expected_begin_date) .add_parameter("{{ max }}", expected_end_date) .assert_violation end @[DataProvider("before_tenth_april_2020")] def test_invalid_datetimes_minmax_min(value : Time) : Nil expected_begin_date = Time.utc(2020, 4, 10) expected_end_date = Time.utc(2020, 4, 20) self.validator.validate value, self.new_constraint range: (expected_begin_date..expected_end_date), not_in_range_message: "my_message" self .build_violation("my_message", CONSTRAINT::NOT_IN_RANGE_ERROR, value) .add_parameter("{{ min }}", expected_begin_date) .add_parameter("{{ max }}", expected_end_date) .assert_violation end def test_invalid_type : Nil expect_raises AVD::Exception::UnexpectedValueError, "Expected argument of type 'Number | Time', 'Bool' given." do self.validator.validate false, self.new_constraint range: (10..20) end end def ten_to_twenty : Tuple { {10.000_01}, {19.999_99}, {10}, {20}, {20_i64}, {10.0}, {10.0_f32}, {20.0}, {nil}, } end def less_than_ten : Tuple { {9.999_99}, {5}, {1.0}, } end def more_than_twenty : Tuple { {20.000_001}, {21}, {30.0}, } end def ten_to_twnentieth_april_2020 : Tuple { {Time.utc(2020, 4, 10)}, {Time.utc(2020, 4, 15)}, {Time.utc(2020, 4, 20)}, } end def before_tenth_april_2020 : Tuple { {Time.utc(2019, 4, 20)}, {Time.utc(2020, 4, 9)}, } end def after_twentieth_april_2020 : Tuple { {Time.utc(2020, 4, 21)}, {Time.utc(2021, 4, 9)}, } end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/regex_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Regex private class Stringifiable def initialize(@value : String); end def to_s(io : IO) : Nil io << @value end end struct RegexValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint pattern: /^[0-9]+$/ self.assert_no_violation end def test_empty_string_is_valid : Nil self.validator.validate "", self.new_constraint pattern: /^[0-9]+$/ self.assert_no_violation end @[DataProvider("valid_values")] def test_valid_values(value : _) : Nil self.validator.validate value, self.new_constraint pattern: /^[0-9]+$/ self.assert_no_violation end def valid_values : Tuple { {0}, {"0"}, {909_090}, {"0909090"}, {Stringifiable.new("909090")}, } end @[DataProvider("invalid_values")] def test_invalid_values(value : _) : Nil self.validator.validate value, self.new_constraint pattern: /^[0-9]+$/, message: "my_message" self .build_violation("my_message", CONSTRAINT::REGEX_FAILED_ERROR, value) .add_parameter("{{ pattern }}", /^[0-9]+$/) .assert_violation end def invalid_values : Tuple { {"abcd"}, {"090foo"}, {Stringifiable.new("abcd")}, } end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/sequentially_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Sequentially struct SequentiallyValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_walk_though_constraints : Nil self.validator.validate 6, self.new_constraint constraints: [AVD::Constraints::Range.new(4..), AVD::Constraints::Positive.new] self.assert_no_violation end def ptest_stop_at_first_constraint_with_violation : Nil self.validator.validate nil, self.new_constraint constraints: [AVD::Constraints::NotBlank.new, AVD::Constraints::NotNil.new] # TODO: Determine how to test this given it depends on an actual validator instance end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/unique_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::Unique private record Foo struct UniqueValidatorTest < AVD::Spec::ConstraintValidatorTestCase @[DataProvider("valid_values")] def test_valid_values(value : _) : Nil self.validator.validate value, self.new_constraint self.assert_no_violation end @[DataProvider("invalid_values")] def test_invalid_values(value : _) : Nil self.validator.validate value, self.new_constraint message: "my_message" self.assert_violation "my_message", CONSTRAINT::IS_NOT_UNIQUE_ERROR, value end def test_invalid_type : Nil expect_raises AVD::Exception::UnexpectedValueError, "Expected argument of type 'Indexable', 'Int32' given." do self.validator.validate 123, new_constraint message: "my_message" end end def valid_values : NamedTuple { nil: {nil}, empty_array: {[] of Int32}, single_nil: {[nil]}, single_integer: {[1]}, single_string: {["foo"]}, single_object: {[Foo.new]}, single_tuple: { {1} }, unique_booleans: {[true, false]}, unique_integers: {[1, 2, 3, 4, 5, 6]}, unique_floats: {[1.0, 2.0, 3.0]}, unique_strings: {["a", "b", "c"]}, unique_arrays: {[[1, 2], [2, 4], [4, 6]]}, unique_tuples: { { {1, 2}, {2, 4}, {4, 6} } }, unique_mixed: {["a", true, 10.0, 7_u8]}, unique_dequeue: {Deque{1, 4, 9}}, } end def invalid_values : NamedTuple object = Foo.new { not_unique_nil: {[nil, nil]}, not_unique_booleans: {[true, true]}, not_unique_integers: {[1, 2, 2, 3]}, not_unique_floats: {[0.1, 0.2, 0.1]}, not_unique_strings: {["a", "a"]}, not_unique_arrays: {[[1, 1], [2, 3], [1, 1]]}, not_unique_objects: {[object, object]}, not_unique_tuples: { { {1, 1}, {2, 3}, {1, 1} } }, not_unique_mixed: {["a", true, 10.0, 7_u8, "a"]}, not_unique_dequeue: {Deque{1, 5, 1}}, } end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/url_validator_spec.cr ================================================ require "../spec_helper" private alias CONSTRAINT = AVD::Constraints::URL private class EmptyURLObject def to_s(io : IO) : Nil io << "" end end struct URLValidatorTest < AVD::Spec::ConstraintValidatorTestCase def test_nil_is_valid : Nil self.validator.validate nil, self.new_constraint self.assert_no_violation end def test_empty_string_is_valid : Nil self.validator.validate "", self.new_constraint end def test_empty_string_from_object_is_valid : Nil self.validator.validate EmptyURLObject.new, self.new_constraint self.assert_no_violation end @[DataProvider("valid_urls")] def test_valid_urls(value : String) : Nil self.validator.validate value, self.new_constraint require_tld: false self.assert_no_violation end @[DataProvider("valid_urls")] @[DataProvider("valid_relative_urls")] def test_valid_relative_urls(value : String) : Nil self.validator.validate value, self.new_constraint relative_protocol: true, require_tld: false self.assert_no_violation end def valid_urls : Tuple { {"http://a.pl"}, {"http://www.example.com"}, {"http://www.example.com."}, {"http://www.example.museum"}, {"https://example.com/"}, {"https://example.com:80/"}, {"http://examp_le.com"}, {"http://www.sub_domain.examp_le.com"}, {"http://www.example.coop/"}, {"http://www.test-example.com/"}, {"http://www.crystal-lang.org/"}, {"http://crystal.fake/blog/"}, {"http://crystal-lang.org/?"}, {"http://crystal-lang.org/search?type=&q=url+validator"}, {"http://crystal-lang.org/#"}, {"http://crystal-lang.org/#?"}, {"http://crystal-lang.org/reference/getting_started/http_server.html#http-server"}, {"http://very.long.domain.name.com/"}, {"http://localhost/"}, {"http://myhost123/"}, {"http://127.0.0.1/"}, {"http://127.0.0.1:80/"}, {"http://[::1]/"}, {"http://[::1]:80/"}, {"http://[1:2:3::4:5:6:7]/"}, {"http://sãopaulo.com/"}, {"http://xn--sopaulo-xwa.com/"}, {"http://sãopaulo.com.br/"}, {"http://xn--sopaulo-xwa.com.br/"}, {"http://пример.испытание/"}, {"http://xn--e1afmkfd.xn--80akhbyknj4f/"}, {"http://مثال.إختبار/"}, {"http://xn--mgbh0fb.xn--kgbechtv/"}, {"http://例子.测试/"}, {"http://xn--fsqu00a.xn--0zwm56d/"}, {"http://例子.測試/"}, {"http://xn--fsqu00a.xn--g6w251d/"}, {"http://例え.テスト/"}, {"http://xn--r8jz45g.xn--zckzah/"}, {"http://مثال.آزمایشی/"}, {"http://xn--mgbh0fb.xn--hgbk6aj7f53bba/"}, {"http://실례.테스트/"}, {"http://xn--9n2bp8q.xn--9t4b11yi5a/"}, {"http://العربية.idn.icann.org/"}, {"http://xn--ogb.idn.icann.org/"}, {"http://xn--e1afmkfd.xn--80akhbyknj4f.xn--e1afmkfd/"}, {"http://xn--espaa-rta.xn--ca-ol-fsay5a/"}, {"http://xn--d1abbgf6aiiy.xn--p1ai/"}, {"http://☎.com/"}, {"http://username:password@crystal-lang.org"}, {"http://user.name:password@crystal-lang.org"}, {"http://user_name:pass_word@crystal-lang.org"}, {"http://username:pass.word@crystal-lang.org"}, {"http://user.name:pass.word@crystal-lang.org"}, {"http://user-name@crystal-lang.org"}, {"http://user_name@crystal-lang.org"}, {"http://u%24er:password@crystal-lang.org"}, {"http://user:pa%24%24word@crystal-lang.org"}, {"http://crystal-lang.org?"}, {"http://crystal-lang.org?query=1"}, {"http://crystal-lang.org/?query=1"}, {"http://crystal-lang.org#"}, {"http://crystal-lang.org#fragment"}, {"http://crystal-lang.org/#fragment"}, {"http://crystal-lang.org/#one_more%20test"}, {"http://example.com/exploit.html?hello[0]=test"}, } end def valid_relative_urls : Tuple { {"//example.com"}, {"//examp_le.com"}, {"//example.fake/blog/"}, {"//example.com/search?type=&q=url+validator"}, } end @[DataProvider("invalid_urls")] def test_invalid_urls(value : String) : Nil self.validator.validate value, self.new_constraint message: "my_message", require_tld: false self.assert_violation "my_message", CONSTRAINT::INVALID_URL_ERROR, value end @[DataProvider("invalid_urls")] @[DataProvider("invalid_relative_urls")] def test_invalid_relative_urls(value : String) : Nil self.validator.validate value, self.new_constraint message: "my_message", relative_protocol: true, require_tld: false self.assert_violation "my_message", CONSTRAINT::INVALID_URL_ERROR, value end def invalid_urls : Tuple { {"google.com"}, {"://google.com"}, {"http ://google.com"}, {"http:/google.com"}, {"http://google.com::aa"}, {"http://google.com:aa"}, {"ftp://google.fr"}, {"faked://google.fr"}, {"http://127.0.0.1:aa/"}, {"ftp://[::1]/"}, {"http://[::1"}, {"http://hello.☎/"}, {"http://:password@example.com"}, {"http://:password@@example.com"}, {"http://username:passwordexample.com"}, {"http://usern@me:password@example.com"}, {"http://nota%hex:password@example.com"}, {"http://example.com/exploit.html?"}, {"http://example.com/exploit.html?hel lo"}, {"http://example.com/exploit.html?not_a%hex"}, {"http://"}, } end def invalid_relative_urls : Tuple { {"/google.com"}, {"//google.com::aa"}, {"//google.com:aa"}, {"//127.0.0.1:aa/"}, {"//[::1"}, {"//hello.☎/"}, {"//:password@example.com"}, {"//:password@@example.com"}, {"//username:passwordexample.com"}, {"//usern@me:password@example.com"}, {"//example.com/exploit.html?"}, {"//example.com/exploit.html?hel lo"}, {"//example.com/exploit.html?not_a%hex"}, {"//"}, } end @[DataProvider("valid_custom_urls")] def test_custom_protocols_are_valid(value : String) : Nil self.validator.validate value, self.new_constraint protocols: ["ftp", "file", "git"], require_tld: false self.assert_no_violation end def valid_custom_urls : Tuple { {"ftp://example.com"}, {"file://127.0.0.1"}, {"git://[::1]/"}, } end @[TestWith( {"https://aaa", true, false}, {"https://aaa", false, true}, {"https://localhost", true, false}, {"https://localhost", false, true}, {"http://127.0.0.1", false, true}, {"http://127.0.0.1", true, false}, {"http://user.pass@local", false, true}, {"http://user.pass@local", true, false}, {"https://example.com", true, true}, {"https://example.com", false, true}, {"http://foo/bar.png", false, true}, {"http://foo/bar.png", true, false}, {"https://example.com.org", true, true}, {"https://example.com.org", false, true}, )] def test_require_tld(value : String, require_tld : Bool, is_valid : Bool) : Nil self.validator.validate value, self.new_constraint require_tld: require_tld, tld_message: "my_message" if is_valid self.assert_no_violation else self .build_violation("my_message", CONSTRAINT::MISSING_TLD_ERROR, value) .assert_violation end end private def create_validator : AVD::ConstraintValidatorInterface CONSTRAINT::Validator.new end private def constraint_class : AVD::Constraint.class CONSTRAINT end end ================================================ FILE: src/components/validator/spec/constraints/valid_validator_spec.cr ================================================ require "../spec_helper" class FooBarBaz include AVD::Validatable @[Assert::NotBlank(groups: ["nested"])] @foo : String? = nil end class FooBar include AVD::Validatable @[Assert::Valid(groups: ["nested"])] @foo_bar_baz : FooBarBaz = FooBarBaz.new end class Foo include AVD::Validatable @[Assert::Valid(groups: ["nested"])] setter foo_bar : FooBar? = FooBar.new end describe AVD::Constraints::Valid::Validator do it "should pass property paths to nested contexts" do violations = AVD.validator.validate Foo.new, groups: "nested" violations.size.should eq 1 violations[0].property_path.should eq "foo_bar.foo_bar_baz.foo" end it "should pass with null value" do foo = Foo.new foo.foo_bar = nil violations = AVD.validator.validate foo, groups: "nested" violations.should be_empty end end ================================================ FILE: src/components/validator/spec/metadata/class_metadata_spec.cr ================================================ require "../spec_helper" private record Entity do include AVD::Validatable end struct ClassMetadataTest < ASPEC::TestCase @metadata : AVD::Metadata::ClassMetadata(Entity) def initialize @metadata = AVD::Metadata::ClassMetadata(Entity).new end def test_add_constraint_array : Nil constraints = [CustomConstraint.new(""), CustomConstraint.new("")] @metadata.add_constraint constraints @metadata.constraints.should eq constraints constraints.each do |constraint| constraint.groups.should eq ["default", "Entity"] end end def test_add_property_constraints : Nil @metadata.add_property_constraints({ "id" => AVD::Constraints::NotBlank.new, "foo" => [CustomConstraint.new(""), AVD::Constraints::Valid.new], }) @metadata.constrained_properties.should eq ["id", "foo"] end def test_add_property_constraint_name_and_array : Nil @metadata.add_property_constraint( "name", [CustomConstraint.new(""), CustomConstraint.new("")] of AVD::Constraint ) @metadata.constrained_properties.should eq ["name"] end def test_add_property_constraint_name_and_single : Nil @metadata.add_property_constraint( "name", CustomConstraint.new "" ) @metadata.constrained_properties.should eq ["name"] end def test_add_property_constraint_property_metadata_and_single : Nil @metadata.add_property_constraint( AVD::Metadata::PropertyMetadata(Entity, Nil).new("name"), CustomConstraint.new "" ) @metadata.constrained_properties.should eq ["name"] end def test_add_constraint_single : Nil constraint = CustomConstraint.new "" @metadata.add_constraint constraint @metadata.constraints.should eq [constraint] constraint.groups.should eq ["default", "Entity"] end def test_group_sequence_default_group : Nil @metadata.group_sequence = ["Foo", @metadata.default_group] @metadata.group_sequence.should be_a AVD::Constraints::GroupSequence end def test_group_sequence_fails_if_missing_default_group : Nil expect_raises ArgumentError, "The group 'Entity' is missing from the group sequence." do @metadata.group_sequence = ["Foo", "Bar"] end end def test_group_sequence_fails_if_contains_default_group : Nil expect_raises ArgumentError, "The group 'default' is not allowed in group sequences." do @metadata.group_sequence = ["Foo", AVD::Constraint::DEFAULT_GROUP] end end def test_group_sequence_fails_if_is_provider : Nil metadata = AVD::Metadata::ClassMetadata(AVD::Spec::EntitySequenceProvider).new metadata.group_sequence_provider = true expect_raises ArgumentError, "Defining a static group sequence is not allowed with a group sequence provider." do metadata.group_sequence = ["Athena::Validator::Spec::EntitySequenceProvider", "Bar"] end end def test_group_sequence_provider_fails_if_is_provider : Nil metadata = AVD::Metadata::ClassMetadata(AVD::Spec::EntitySequenceProvider).new metadata.group_sequence = ["Athena::Validator::Spec::EntitySequenceProvider", "Bar"] expect_raises ArgumentError, "Defining a group sequence provider is not allowed with a static group sequence." do metadata.group_sequence_provider = true end end def test_has_property_metadata : Nil @metadata.add_property_constraint( AVD::Metadata::PropertyMetadata(Entity, Nil).new("name"), CustomConstraint.new "" ) @metadata.has_property_metadata?("name").should be_true @metadata.has_property_metadata?("age").should be_false end def test_property_metadata : Nil name_metadata = AVD::Metadata::PropertyMetadata(Entity, Nil).new "name" @metadata.add_property_constraint name_metadata, CustomConstraint.new "" @metadata.property_metadata("name").should eq [name_metadata] end end ================================================ FILE: src/components/validator/spec/property_path_spec.cr ================================================ require "./spec_helper" private PATHS = [ {"foo", "", "foo"}, # It returns the basePath if subPath is empty {"", "bar", "bar"}, # It returns the subPath if basePath is empty {"foo", "bar", "foo.bar"}, # It append the subPath to the basePath {"foo", "[bar]", "foo[bar]"}, # It does not include the dot separator if subPath uses the array notation {"0", "bar", "0.bar"}, # Leading zeros are kept ] describe AVD::PropertyPath do describe ".append" do PATHS.each do |(base, sub, expected)| it "generates the correct strings" do AVD::PropertyPath.append(base, sub).should eq expected end end end end ================================================ FILE: src/components/validator/spec/spec/compound_constraint_test_case_spec.cr ================================================ require "../spec_helper" private class DummyCompoundConstraint < AVD::Constraints::Compound def constraints : Type [ AVD::Constraints::NotBlank.new, AVD::Constraints::Length.new(...3), AVD::Constraints::Regex.new(/[a-z]+/), AVD::Constraints::Regex.new(/[0-9]+/), ] end end struct CompoundConstraintTestCaseTest < AVD::Spec::CompoundConstraintTestCase(String) protected def create_compound : AVD::Constraints::Compound DummyCompoundConstraint.new end def test_assert_no_violation : Nil self.validate_value "ab1" self.assert_no_violation self.assert_violation_count 0 end def test_assert_is_raised_by_component : Nil self.validate_value "" self.assert_violations_raised_by_compound AVD::Constraints::NotBlank.new self.assert_violation_count 1 end def test_multiple_assert_are_raised_by_compound : Nil self.validate_value "1234" self.assert_violations_raised_by_compound( AVD::Constraints::Length.new(...3), AVD::Constraints::Regex.new(/[a-z]+/), ) self.assert_violation_count 2 end def test_no_assert_raised_but_expected : Nil self.validate_value "azert" expect_raises ::Spec::AssertionFailed, "Expected violation(s) for constraint(s) 'Athena::Validator::Constraints::Length, Athena::Validator::Constraints::Regex' to be raised by compound." do self.assert_violations_raised_by_compound( AVD::Constraints::Length.new(..5), AVD::Constraints::Regex.new(/^[A-Z]+$/), ) end end def test_assert_raised_by_compound_is_not_exactly_the_same : Nil self.validate_value "123" expect_raises ::Spec::AssertionFailed, "Expected violation(s) for constraint(s) 'Athena::Validator::Constraints::Regex' to be raised by compound." do self.assert_violations_raised_by_compound( AVD::Constraints::Regex.new(/^[A-Z]+$/), ) end end def test_assert_raised_by_compound_but_got_none : Nil self.validate_value "123" expect_raises ::Spec::AssertionFailed, "Expected at least one violation for constraint(s): 'Athena::Validator::Constraints::Length', got none." do self.assert_violations_raised_by_compound( AVD::Constraints::Length.new(..5), ) end end end ================================================ FILE: src/components/validator/spec/spec_helper.cr ================================================ require "spec" require "../src/athena-validator" require "../src/spec" ASPEC.run_all class MockConstraint < AVD::Constraint def validated_by : AVD::ConstraintValidator.class MockConstraintValidator end end class MockConstraintValidator < AVD::ConstraintValidator; end class MockServiceConstraintValidator < AVD::ServiceConstraintValidator; end class CustomConstraint < AVD::Constraint @@error_names = { "abc123" => "FAKE_ERROR", } class Validator < Athena::Validator::ConstraintValidator def validate(value : _, constraint : CustomConstraint) : Nil end end end def get_violation(message : String, *, invalid_value : _ = nil, root : _ = nil, property_path : String = "property_path", code : String? = nil) : AVD::Violation::ConstraintViolation AVD::Violation::ConstraintViolation.new message, message, Hash(String, String).new, root, property_path, AVD::ValueContainer.new(invalid_value), code: code end ================================================ FILE: src/components/validator/spec/validatable_spec.cr ================================================ require "./spec_helper" private class ManualConstraints include AVD::Validatable def self.load_metadata(class_metadata : AVD::Metadata::ClassMetadata) : Nil class_metadata.add_property_constraint "name", AVD::Constraints::EqualTo.new("foo") end def initialize(@name : String); end end private abstract class Parent include AVD::Validatable end private class Child < Parent @[Assert::NotBlank] property name : String = "" end private class Obj include AVD::Validatable @[Assert::NotBlank] property name : String = "" end private class InstanceCallbackClass include AVD::Validatable @[Assert::Callback] def validate(context : AVD::ExecutionContextInterface, payload : Hash(String, String)?) : Nil end end private class ClassCallbackClass include AVD::Validatable @[Assert::Callback] def self.validate(value : AVD::Constraints::Callback::ValueContainer, context : AVD::ExecutionContextInterface, payload : Hash(String, String)?) : Nil end end private class ComparisonConstrained include AVD::Validatable @[Assert::LessThan(10)] getter age : Int32 = 0 end describe AVD::Validatable do describe ".load_metadata" do it "should manually add constraints to the metadata object" do ManualConstraints.validation_class_metadata.constrained_properties.should eq ["name"] end end describe ".validation_class_metadata" do it "is inherited when included in parent type" do Child.validation_class_metadata.constrained_properties.should eq ["name"] end it "is not defined for abstract types" do Parent.responds_to?(:validation_class_metadata).should be_false end it "is defined when included directly into non-abstract types" do Obj.validation_class_metadata.constrained_properties.should eq ["name"] end it "properly registers instance method callback constraints" do constraints = InstanceCallbackClass.validation_class_metadata.constraints constraints.size.should eq 1 constraints.first.should be_a AVD::Constraints::Callback end it "properly registers class method callback constraints" do constraints = ClassCallbackClass.validation_class_metadata.constraints constraints.size.should eq 1 constraints.first.should be_a AVD::Constraints::Callback end it "does not duplicate property metadata for generic module constraints" do ComparisonConstrained.validation_class_metadata.property_metadata("age").first.constraints.size.should eq 1 end end end ================================================ FILE: src/components/validator/spec/validator/recursive_validator_spec.cr ================================================ require "../spec_helper" struct RecursiveValidatorTest < AVD::Spec::ValidatorTestCase def create_validator(metadata_factory : AVD::Metadata::MetadataFactoryInterface) : AVD::Validator::ValidatorInterface AVD::Validator::RecursiveValidator.new metadata_factory: metadata_factory end def test_validate_valid_constraint_on_getter_returning_null : Nil metadata = AVD::Metadata::ClassMetadata(EntityParent).new metadata.add_getter_constraint "child", AVD::Constraints::Valid.new @metadata_factory.add_metadata EntityParent, metadata self.validate(EntityParent.new).should be_empty end def test_validate_not_nil_constraint_on_getter_returning_null : Nil metadata = AVD::Metadata::ClassMetadata(EntityParent).new metadata.add_getter_constraint "child", AVD::Constraints::NotNil.new @metadata_factory.add_metadata EntityParent, metadata self.validate(EntityParent.new).size.should eq 1 end def test_validate_all_constraint_validate_all_groups_for_nested_constraints : Nil @metadata.add_property_constraint "data_hash", AVD::Constraints::All.new([ AVD::Constraints::NotBlank.new(groups: "group1"), AVD::Constraints::Length.new(2.., groups: "group2"), ]) object = Entity.new object.data_hash = {"one" => "t", "two" => ""} violations = self.validate object, nil, ["group1", "group2"] violations.size.should eq 3 violations[0].constraint.should be_a AVD::Constraints::NotBlank violations[1].constraint.should be_a AVD::Constraints::Length violations[2].constraint.should be_a AVD::Constraints::Length end end ================================================ FILE: src/components/validator/spec/violation/constraint_violation_list_spec.cr ================================================ require "../spec_helper" describe AVD::Violation::ConstraintViolationList do it "without any violations" do AVD::Violation::ConstraintViolationList.new.size.should eq 0 end it "with violations" do violation = get_violation "error" list = AVD::Violation::ConstraintViolationList.new [violation] list.size.should eq 1 list.first.should eq violation end it "#find_by_code" do list = AVD::Violation::ConstraintViolationList.new [get_violation("one", code: "CODE"), get_violation("two", code: "CODE"), get_violation("three", code: "CODE2")] new_list = list.find_by_code "CODE" new_list.should be_a AVD::Violation::ConstraintViolationList new_list.size.should eq 2 end describe "#add" do it "adds the given violation" do violation = get_violation "error" list = AVD::Violation::ConstraintViolationList.new list.add violation list.size.should eq 1 list.first.should eq violation end it "adds another list" do other_list = AVD::Violation::ConstraintViolationList.new [get_violation("one"), get_violation("two"), get_violation("three")] list = AVD::Violation::ConstraintViolationList.new list.add other_list list.size.should eq 3 list[0].should eq other_list[0] list[1].should eq other_list[1] list[2].should eq other_list[2] end end it "#has?" do violation = get_violation "error" list = AVD::Violation::ConstraintViolationList.new list.has?(0).should be_false list.add violation list.has?(0).should be_true list.has?(1).should be_false end it "#set" do violation = get_violation "error" other_error = get_violation "other error" list = AVD::Violation::ConstraintViolationList.new [violation] list.first.should eq violation list.set 0, other_error list.first.should eq other_error end it "#remove" do violation = get_violation "error" list = AVD::Violation::ConstraintViolationList.new list.add violation list.size.should eq 1 list.remove 0 list.should be_empty end it "#to_s" do AVD::Violation::ConstraintViolationList.new([get_violation("Error 1", root: "Root", property_path: ""), get_violation("Error 2", root: "Root", property_path: "")]).to_s.should eq "Root:\n\tError 1\nRoot:\n\tError 2\n" end describe "#to_json" do it "serializes to an array of objects" do violations = AVD::Violation::ConstraintViolationList.new([get_violation("Error 1"), get_violation("Error 2", root: "Root")]) violations.to_json.should eq %([{"property":"property_path","message":"Error 1"},{"property":"property_path","message":"Error 2"}]) end end end ================================================ FILE: src/components/validator/spec/violation/constraint_violation_spec.cr ================================================ require "../spec_helper" record TestObj do include AVD::Validatable end describe AVD::Violation::ConstraintViolation do describe "#invalid_value" do it "returns the value" do get_violation("Message", invalid_value: 12.8).invalid_value.should eq 12.8 end end describe "#to_s" do it Indexable do get_violation("Array", root: "Root", property_path: "property.path").to_s.should eq "Root.property.path:\n\tArray\n" get_violation("Array", root: "Root", property_path: "[2].value").to_s.should eq "Root[2].value:\n\tArray\n" end it Enumerable do get_violation("Array", root: [1, 2, 3], property_path: "[1]").to_s.should eq "Object(Array(Int32))[1]:\n\tArray\n" end it Hash do get_violation("Some message", root: {"key" => "value"}, property_path: "key").to_s.should eq "Hash.key:\n\tSome message\n" get_violation("Some message", root: {"key" => "value"}, property_path: "[key]").to_s.should eq "Hash[key]:\n\tSome message\n" end it "code" do get_violation("Some message", property_path: "key", code: "CODE").to_s.should eq "key:\n\tSome message (code: CODE)\n" end it AVD::Validatable do get_violation("Some message", root: TestObj.new, property_path: "").to_s.should eq "Object(TestObj):\n\tSome message\n" end end describe "#to_json" do it "without a code" do get_violation("Message", invalid_value: 12.8).to_json.should eq %({"property":"property_path","message":"Message"}) end it "with a code" do get_violation("Message", invalid_value: 12.8, code: "CODE").to_json.should eq %({"property":"property_path","message":"Message","code":"CODE"}) end it "with a root value" do get_violation("Message", invalid_value: 12.8, code: "CODE", root: "Root").to_json.should eq %({"property":"property_path","message":"Message","code":"CODE"}) end end end ================================================ FILE: src/components/validator/src/athena-validator.cr ================================================ require "json" require "./constraint" require "./constraint_validator" require "./constraint_validator_factory" require "./constraint_validator_factory_interface" require "./constraint_validator_interface" require "./execution_context" require "./execution_context_interface" require "./property_path" require "./validatable" require "./constraints/abstract_comparison" require "./constraints/abstract_comparison_validator" require "./constraints/*" require "./exception/*" require "./metadata/*" require "./validator/*" require "./violation/*" # Convenience alias to make referencing `Athena::Validator` types easier. alias AVD = Athena::Validator # Used to apply constraints to instance variables and types via annotations. # # ``` # @[Assert::NotBlank] # property name : String # ``` # NOTE: Constraints, including custom ones, are automatically added to this namespace. alias Assert = AVD::Annotations module Athena; end # Provides a robust object/value validation framework. module Athena::Validator VERSION = "0.5.0" # :nodoc: # # Default namespace for constraint annotations. # # NOTE: Constraints, including custom ones, are automatically added to this namespace. module Annotations; end # Contains all of the built in `AVD::Constraint`s. # See each individual constraint for more information. # The `Assert` alias is used to apply these constraints via annotations. module Constraints; end # Both acts as a namespace for exceptions related to the `Athena::Validator` component, as well as a way to check for exceptions from the component. module Exception; end # Contains types used to store metadata associated with a given `AVD::Validatable` instance. # # Most likely you won't have to work any of these directly. # However if you are adding constraints manually to properties using the `self.load_metadata` method, # you should be familiar with `AVD::Metadata::ClassMetadata`. module Metadata; end # Contains types related to the validator itself. module Validator; end # Contains types related to constraint violations. module Violation; end # :nodoc: abstract struct Container abstract def type_name : String def inspect(io : IO) : Nil io << "#" end end # :nodoc: record ValueContainer(T) < Container, value : T do def value_type : T.class T end def type_name : String {{ T.stringify }} end def ==(other : AVD::Container) : Bool @value == other.value end end # Returns a new `AVD::Validator::ValidatorInterface`. # # ``` # validator = AVD.validator # # validator.validate "foo", AVD::Constraints::NotBlank.new # ``` def self.validator : AVD::Validator::ValidatorInterface AVD::Validator::RecursiveValidator.new end end ================================================ FILE: src/components/validator/src/constraint.cr ================================================ # `Athena::Validator` validates values/objects against a set of constraints, i.e. rules. # Each constraint makes an assertive statement that some condition is true. # Given a value, a constraint will tell you if that value adheres to the rules of the constraint. # An example of this could be asserting a value is not blank, or greater than or equal to another value. # # It's important to note a constraint does not implement the validation logic itself. # Instead, this is handled via an `AVD::ConstraintValidator` as defined via `#validated_by`. # Having this abstraction allows for better reusability and testability. # # `Athena::Validator` comes with a set of common constraints built in. # See the individual types within `AVD::Constraints` for more information. # # ## Usage # # A constraint can be instantiated and passed to a validator directly: # # ``` # # An array of constraints can also be passed. # AVD.validator.validate "", AVD::Constraints::NotBlank.new # ``` # # Constraint annotation(s) can also be applied to instance variables to assert the value of that property adheres to the constraint. # # ``` # class Example # include AVD::Validatable # # def initialize(@name : String); end # # # More than one constraint can be applied to a property. # @[Assert::NotBlank] # property name : String # end # # # Constraints are extracted from the annotations. # # An array can also be passed to validate against that list instead. # AVD.validator.validate Example.new("Jim") # ``` # # Constraints can also be added manually via code by defining an `self.load_metadata(metadata : AVD::Metadata::ClassMetadata) : Nil` # method and adding the constraints directly to the `AVD::Metadata::ClassMetadata` instance. # # ``` # # This class method is invoked when building the metadata associated with a type, # # and can be used to manually wire up the constraints. # def self.load_metadata(metadata : AVD::Metadata::ClassMetadata) : Nil # metadata.add_property_constraint "name", AVD::Constraints::NotBlank.new # end # ``` # # The metadata for each type is lazily loaded when an instance of that type is validated, and is only built once. # # ## Arguments # # While most constraints can be instantiated with an argless constructor,they do have a set of optional arguments. # * The `message` argument represents the message that should be used if the value is found to not be valid. # The message can also include placeholders, in the form of `{{ key }}`, that will be replaced when the message is rendered. # Most commonly this includes the invalid value itself, but some constraints have additional placeholders. # * The `payload` argument can be used to attach any domain specific data to the constraint; such as attaching a severity with each constraint # to have more serious violations be handled differently. See the [Payload][Athena::Validator::Constraint--payload] section. # * The `groups` argument can be used to run a subset of the defined constraints. More on this in the [Validation Groups][Athena::Validator::Constraint--validation-groups] section. # # For example: # # ``` # validator = AVD.validator # # # Instantiate a constraint with a custom message, using a placeholder. # violations = validator.validate -4, AVD::Constraints::PositiveOrZero.new message: "{{ value }} is not a valid age. A user cannot have a negative age." # # puts violations # => # # -4: # # -4 is not a valid age. A user cannot have a negative age. (code: e09e52d0-b549-4ba1-8b4e-420aad76f0de) # ``` # Customizing the message can be a good way for those consuming the errors to determine _WHY_ a given value is not valid. # # ### Default Argument # # The first argument of the constructor is known as the default argument. # This argument is special when using the annotation based approach in that it can be supplied as a positional argument within the annotation. # # For example the default argument for `AVD::Constraints::GreaterThan` is the value that the value being validated should be compared against. # # Thus: # # ``` # @[Assert::GreaterThan(0)] # property age : Int32 # ``` # # Is equivalent to: # # ``` # @[Assert::GreaterThan(value: 0)] # property age : Int32 # ``` # # NOTE: Only the first argument can be supplied positionally, all other arguments must be provided as named arguments within the annotation. # # ### Message Plurality # # `Athena::Validator` has very basic support for pluralizing constraint `#message`s via `AVD::Violation::ConstraintViolationInterface#plural`. # # For example the `#message` could have different versions based on the plurality of the violation. # Currently this only supports two contexts: singular (1/nil) and plural (2+). # # Multiple messages, separated by a `|`, can be included as part of an `AVD::Constraint` message. # For example from `AVD::Constraints::Size`: # # `min_message : String = "This value is too short. It should have {{ limit }} {{ type }} or more.|This value is too short. It should have {{ limit }} {{ type }}s or more."` # # If violations' `#plural` method returns `1` (or `nil`) the first message will be used. If `#plural` is `2` or more, the latter message will be used. # # TODO: Support more robust translations; like language or multiple pluralities. # # ### Payload # # The `payload` argument defined on every `AVD::Constraint` type can be used to store custom domain specific information with a constraint. # This data can later be retrieved off of an `AVD::Violation::ConstraintViolationInterface`. # An example use case for this could be mapping a "severity" to a CSS class based on how important each specific constraint is. # # ``` # class User # include AVD::Validatable # # def initialize(@email : String, @password : String); end # # @[Assert::NotBlank(payload: {"severity" => "error"})] # getter email : String # # @[Assert::NotBlank(payload: {"severity" => "warning"})] # getter password : String # end # # violations = AVD.validator.validate User.new "", "" # # # Use this when rendering HTML, or JSON to allow dynamically customizing the response object. # violations[0].constraint.payload # => {"severity" => "error"} # violations[1].constraint.payload # => {"severity" => "warning"} # ``` # # ## Validation Groups # # The `groups` argument defined on every `AVD::Constraint` type can be used to run a subset of validations. # # For example, say we only want to validate certain properties when the user is first created: # # ``` # class User # include AVD::Validatable # # def initialize(@email : String, @password : String, @city : String); end # # @[Assert::Email(groups: "create")] # getter email : String # # @[Assert::NotBlank(groups: "create")] # @[Assert::Size(7.., groups: "create")] # getter password : String # # @[Assert::Size(2..)] # getter city : String # end # # user = User.new "contact@athenaframework.org", "monkey123", "" # # # Validate the user object, but only for those in the "create" group, # # if no groups are supplied, then all constraints in the "default" group will be used. # violations = AVD.validator.validate user, groups: "create" # # # There are no violations since the city's size is not validated since it's not in the "create" group. # violations.empty? # => true # ``` # # Using this configuration, there are three groups at play within the `User` class: # 1. `default` - Contains constraints in the current type, and subtypes, that belong to no other group. I.e. `city`. # 1. `User` - In this example, equivalent to all constraints in the `default` group. See `AVD::Constraints::GroupSequence`, and the note below. # 1. `create` - A custom group that only contains the constraints explicitly associated with it. I.e. `email`, and `password`. # # NOTE: When validating _just_ the `User` object, the `default` group is equivalent to the `User` group. # However, if the `User` object has other embedded types using the `AVD::Constraints::Valid` constraint, then validating the `User` object with the `User` # group would only validate constraints that are explicitly in the `User` group within the embedded types. # # By default, all constraints are validated in a single "batch". I.e. all constraints within the provided group(s) are validated, without regard # to if the previous/next constraint is/was (in)valid. However, an `AVD::Constraints::GroupSequence` can be used to validate batches of constraints in steps. # I.e. validate the first "batch" of constraints, and only advance to the next batch if all constraints in that step are valid. # # NOTE: The payload is not used with the framework itself. # # ## Custom Constraints # # If the built in `AVD::Constraints` are not sufficient to handle validating a given value/object; custom ones can be defined. # Let's make a new constraint that asserts a string contains only alphanumeric characters. # # This is accomplished by first defining a new class within the `AVD::Constraints` namespace that inherits from `AVD::Constraint`. # Then define a `Validator` struct within our constraint that inherits from `AVD::ConstraintValidator` that actually implements the validation logic. # # ``` # class AVD::Constraints::AlphaNumeric < AVD::Constraint # # (Optional) A unique error code can also be defined to provide a machine readable identifier for a specific error. # NOT_ALPHANUMERIC_ERROR = "1a83a8bd-ff79-4d5c-96e7-86d0b25b8a09" # # # (Optional) Allows using the `.error_message(code : String) : String` method with this constraint. # @@error_names = { # NOT_ALPHANUMERIC_ERROR => "NOT_ALPHANUMERIC_ERROR", # } # # # Define an initializer with our default message, and any additional arguments specific to this constraint. # def initialize( # message : String = "This value should contain only alphanumeric characters.", # groups : Array(String) | String | Nil = nil, # payload : Hash(String, String)? = nil, # ) # super message, groups, payload # end # # # Define the validator within our constraint that'll contain our validation logic. # class Validator < AVD::ConstraintValidator # # Define our validate method that accepts the value to be validated, and the constraint. # # # # Overloads can be used to filter values of specific types. # def validate(value : _, constraint : AVD::Constraints::AlphaNumeric) : Nil # # Custom constraints should ignore nil and empty values to allow # # other constraints (NotBlank, NotNil, etc.) take care of that # return if value.nil? || value == "" # # # We'll cast the value to a string, # # alternatively we could just ignore non `String?` values. # value = value.to_s # # # If all the characters of this string are alphanumeric, then it is valid # return if value.each_char.all? &.alphanumeric? # # # Otherwise, it is invalid and we need to add a violation, # # see `AVD::ExecutionContextInterface` for additional information. # self.context.add_violation constraint.message, NOT_ALPHANUMERIC_ERROR, value # end # end # end # # puts AVD.validator.validate "$", AVD::Constraints::AlphaNumeric.new # => # # $: # # This value should contain only alphanumeric characters. (code: 1a83a8bd-ff79-4d5c-96e7-86d0b25b8a09) # ``` # # NOTE: The constraint _MUST_ be defined within the `AVD::Constraints` namespace for implementation reasons. This may change in the future. # # We are now able to use this constraint as we would one of the built in ones; # either by manually instantiating it, or applying an `@[Assert::AlphaNumeric]` annotation to a property. # # See `AVD::ConstraintValidatorInterface` for more information on custom validators. # # NOTE: The `AVD::Constraints::Compound` constraint can be used to create a constraint that consists of one or more other constraints. # abstract class Athena::Validator::Constraint # The group that `self` is a part of if no other group(s) are explicitly defined. DEFAULT_GROUP = "default" @@error_names = Hash(String, String).new # Returns the name of the provided *error_code*. def self.error_name(error_code : String) : String @@error_names[error_code]? || raise AVD::Exception::InvalidArgument.new "The error code '#{error_code}' does not exist for constraint of type '#{self}'." end # Returns the message that should be rendered if `self` is found to be invalid. # # NOTE: Some subtypes do not use this and instead define multiple message # properties in order to support more specific error messages. getter message : String # Returns any domain specific data associated with `self`. getter payload : Hash(String, String)? # This isn't set directly as a property such that we can somewhat tell if it's been customized or not. # E.g. so that the composite constraint knows if it needs to apply its groups to it or not @groups : Array(String)? = nil def initialize(@message : String, groups : Array(String) | String | Nil = nil, @payload : Hash(String, String)? = nil) unless groups.nil? @groups = case groups when Array then groups when String then [groups] end end end # Sets the validation groups `self` is a part of. def groups=(@groups : Array(String)) end # Returns the validation groups `self` is a part of. def groups : Array(String) @groups ||= [DEFAULT_GROUP] end # Adds the provided *group* to `#groups` if `self` is in the `AVD::Constraint::DEFAULT_GROUP`. def add_implicit_group(group : String) : Nil if self.groups.includes?(DEFAULT_GROUP) && !self.groups.includes?(group) self.groups << group end end # Returns the `AVD::ConstraintValidator.class` that should handle validating `self`. abstract def validated_by : AVD::ConstraintValidator.class macro inherited {% unless @type.abstract? %} # See `{{@type.id}}`. annotation ::Athena::Validator::Annotations::{{@type.name(generic_args: false).split("::").last.id}}; end # :inherit: def validated_by : AVD::ConstraintValidator.class Validator end # :nodoc: def ==(other : self) : Bool \{% if @type.class? %} return true if same?(other) \{% end %} \{% for field in @type.instance_vars %} return false unless @\{{field.id}} == other.@\{{field.id}} \{% end %} true end {% end %} end end ================================================ FILE: src/components/validator/src/constraint_validator.cr ================================================ require "./constraint_validator_interface" # Basic implementation of `AVD::ConstraintValidatorInterface`. abstract class Athena::Validator::ConstraintValidator include Athena::Validator::ConstraintValidatorInterface # :inherit: def context : AVD::ExecutionContextInterface @context.not_nil! end # :nodoc: def context=(@context : AVD::ExecutionContextInterface); end # :inherit: def validate(value : _, constraint : AVD::Constraint) : Nil # Noop if a given validator doesn't support a given type of value end # Can be used to raise an `AVD::Exception::UnexpectedValueError` # in case `self` is only able to validate values of the *supported_types*. # # ``` # # Define a validate method to catch values of other types. # # Overloads above would handle the valid types. # def validate(value : _, constraint : AVD::Constraints::MyConstraint) : Nil # self.raise_invalid_type value, "Int | Float" # end # ``` # # This would result in a violation with the message `This value should be a valid: Int | Float` # being added to the current `#context`. def raise_invalid_type(value : _, supported_types : String) : NoReturn raise AVD::Exception::UnexpectedValueError.new value, supported_types end end # Extension of `AVD::ConstraintValidator` used to denote a service validator # that can be used with [Athena Dependency Injection](https://github.com/athena-framework/dependency-injection). abstract class Athena::Validator::ServiceConstraintValidator < Athena::Validator::ConstraintValidator macro inherited def self.new : NoReturn # Validators of this type will be injected via DI and not directly instantiated within the factory. raise "" end end end # Compiler doesn't like there not being any instances of this private class FakeConstraintValidator < Athena::Validator::ServiceConstraintValidator; end ================================================ FILE: src/components/validator/src/constraint_validator_factory.cr ================================================ require "./constraint_validator_factory_interface" # Basic implementation of `AVD::ConstraintValidatorFactoryInterface`. struct Athena::Validator::ConstraintValidatorFactory include Athena::Validator::ConstraintValidatorFactoryInterface @validators : Hash(AVD::ConstraintValidator.class, AVD::ConstraintValidator) = Hash(AVD::ConstraintValidator.class, AVD::ConstraintValidator).new # :nodoc: # # Overload to support DI. def initialize(constraint_validators : Array(AVD::ServiceConstraintValidator) = [] of AVD::ServiceConstraintValidator) constraint_validators.each do |validator| @validators[validator.class] = validator end end # Returns an `AVD::ConstraintValidator` based on the provided *validator_class*. # # NOTE: This overloaded is intended to be used for service based validators that are already # instantiated and were provided via DI. def validator(for validator_class : AVD::ServiceConstraintValidator.class) : AVD::ConstraintValidator @validators[validator_class] end # Returns an `AVD::ConstraintValidator` based on the provided *validator_class*. def validator(for validator_class : AVD::ConstraintValidator.class) : AVD::ConstraintValidator if validator = @validators[validator_class]? return validator end @validators[validator_class] = validator_class.new end end ================================================ FILE: src/components/validator/src/constraint_validator_factory_interface.cr ================================================ # Provides validator instances based on a validator class, caching the instance. # # `AVD::ServiceConstraintValidator`s are instantiated externally and injected into the factory. module Athena::Validator::ConstraintValidatorFactoryInterface # Returns an `AVD::ConstraintValidatorInterface` instance based on the provided *validator_class*. abstract def validator(for validator_class : AVD::ConstraintValidator.class) : AVD::ConstraintValidatorInterface end ================================================ FILE: src/components/validator/src/constraint_validator_interface.cr ================================================ # A constraint validator is responsible for implementing the actual validation logic for a given `AVD::Constraint`. # # Constraint validators should inherit from this type and implement a `#validate` method. # Most commonly the validator type will be defined within the namespace of the related `AVD::Constraint` itself. # # The `#validate` method itself does not return anything. # Violations are added to the current `#context`, either as a single error message, or augmented with additional metadata about the failure. # See `AVD::ExecutionContextInterface` for more information on how violations can be added. # # ### Example # # ``` # class AVD::Constraints::MyConstraint < AVD::Constraint # # Initializer/etc for the constraint # # class Validator < AVD::ConstraintValidator # # Define a validate method that handles values of any type, and our `MyConstraint` constraint. # def validate(value : _, constraint : AVD::Constraints::MyConstraint) : Nil # # Implement logic to determine if the value is valid. # # Violations should be added to the current `#context`, # # See `AVD::ExecutionContextInterface` for more information. # end # end # end # ``` # # Overloads of the `#validate` method can also be used to handle validating values of different types independently. # If the value cannot be handled by any of `self`'s validators, it is handled via `AVD::ConstraintValidator#validate` # and is essentially a noop. # # If a `AVD::Constraint` can only support values of certain types, `AVD::ConstraintValidator#raise_invalid_type` # in a catchall overload can be used to add an invalid type `AVD::Violation::ConstraintViolationInterface`. # # ``` # class Validator < AVD::ConstraintValidator # def validate(value : Number, constraint : AVD::Constraints::MyConstraint) : Nil # # Handle validating `Number` values # end # # def validate(value : Time, constraint : AVD::Constraints::MyConstraint) : Nil # # Handle validating `Time` values # end # # def validate(value : _, constraint : AVD::Constraints::MyConstraint) : Nil # # Add an invalid type violation for values of all other types. # self.raise_invalid_type value, "Number | Time" # end # end # ``` # # NOTE: Normally custom validators should not handle `nil` or `blank` values as they are handled via other constraints. module Athena::Validator::ConstraintValidatorInterface # Validate the provided *value* against the provided *constraint*. # # Violations should be added to the current `#context`. abstract def validate(value : _, constraint : AVD::Constraint) : Nil # Returns the a reference to the `AVD::ExecutionContextInterface` # to which violations within `self` should be added. # # See the type for more information. abstract def context : AVD::ExecutionContextInterface # Internal # :nodoc: abstract def context=(context : AVD::ExecutionContextInterface) end ================================================ FILE: src/components/validator/src/constraints/abstract_comparison.cr ================================================ # Defines common logic for comparison based constraints, such as `AVD::Constraints::GreaterThan`, or `AVD::Constraints::EqualTo`. module Athena::Validator::Constraints::AbstractComparison(ValueType) # Returns the expected value. getter value : ValueType # Returns the type of the expected value. getter value_type : ValueType.class = ValueType def initialize( @value : ValueType, message : String = default_error_message, groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end # Returns the `AVD::Constraint#message` for this constraint. abstract def default_error_message : String end ================================================ FILE: src/components/validator/src/constraints/abstract_comparison_validator.cr ================================================ # Defines common logic for comparison based constraint validators. abstract class Athena::Validator::Constraints::ComparisonValidator < Athena::Validator::ConstraintValidator # Returns `true` if the provided *actual* and *expected* values are compatible, otherwise `false`. abstract def compare_values(actual : _, expected : _) : Bool # Returns the expected error code for `self`. abstract def error_code : String # :inherit: def validate(value : _, constraint : AVD::Constraints::AbstractComparison) : Nil return if value.nil? compared_value = constraint.value return if self.compare_values value, compared_value self .context .build_violation(constraint.message, self.error_code) .set_parameters({"{{ value }}" => value.to_s, "{{ compared_value }}" => compared_value.to_s, "{{ compared_value_type }}" => constraint.value_type.to_s}) .add end end ================================================ FILE: src/components/validator/src/constraints/all.cr ================================================ require "./composite" # Validates each element of an `Iterable` is valid based on a collection of constraints. # # # Configuration # # ## Required Arguments # # ### constraints # # **Type:** `Array(AVD::Constraint) | AVD::Constraint` # # The `AVD::Constraint`(s) that you want to apply to each element of the underlying iterable. # # ## Optional Arguments # # NOTE: This constraint does not support a `message` argument. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. # # # Usage # # ``` # class Example # include AVD::Validatable # # def initialize(@strings : Array(String)); end # # # Assert each string is not blank and is at least 5 characters long. # @[Assert::All([ # @[Assert::NotBlank], # @[Assert::Size(5..)], # ])] # getter strings : Array(String) # end # ``` # # NOTE: The annotation approach only supports two levels of nested annotations. # Manually wire up the constraint via code if you require more than that. class Athena::Validator::Constraints::All < Athena::Validator::Constraints::Composite def initialize( constraints : AVD::Constraints::Composite::Type, groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super constraints, "", groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : Hash?, constraint : AVD::Constraints::All) : Nil return if value.nil? self.with_validator do |validator| value.each do |k, v| validator.at_path("[#{k}]").validate(v, constraint.constraints.values) end end end # :inherit: def validate(value : Indexable?, constraint : AVD::Constraints::All) : Nil return if value.nil? self.with_validator do |validator| value.each_with_index do |item, idx| validator.at_path("[#{idx}]").validate(item, constraint.constraints.values) end end end # :inherit: def validate(value : _, constraint : AVD::Constraints::All) : NoReturn self.raise_invalid_type value, "Hash | Indexable" end private def with_validator(& : AVD::Validator::ContextualValidatorInterface ->) : Nil yield self.context.validator.in_context self.context end end end ================================================ FILE: src/components/validator/src/constraints/at_least_one_of.cr ================================================ require "./composite" # Validates that a value satisfies at least one of the provided constraints. # Validation stops as soon as one constraint is satisfied. # # # Configuration # # ## Required Arguments # # ### constraints # # **Type:** `Array(AVD::Constraint) | AVD::Constraint` # # The `AVD::Constraint`(s) from which at least one of has to be satisfied in order for the validation to succeed. # # ## Optional Arguments # # ### include_internal_messages # # **Type:** `Bool` **Default:** `true` # # If the validation failed message should include the list of messages for the internal constraints. # See the [message](#message) argument for an example. # # ### message_collection # # **Type:** `String` **Default:** `Each element of this collection should satisfy its own set of constraints.` # # The message that will be shown if validation fails and the internal constraint is an `AVD::Constraints::All`. # See the [message](#message) argument for an example. # # ### message # # **Type:** `String` **Default:** `This value should satisfy at least one of the following constraints:` # # The intro that will be shown if validation fails. # By default, it'll be followed by the list of messages from the internal [constraints](#constraints); # configurable via the [include_internal_messages](#include_internal_messages) argument. # # For example, if the `grades` property in the example below fails to validate, the message will be: # # > This value should satisfy at least one of the following constraints: [1] This value is too short. It should have 3 items or more. [2] Each element of this collection should satisfy its own set of constraints. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. # # # Usage # # ``` # class Example # include AVD::Validatable # # def initialize(@password : String, @grades : Array(Int32)); end # # # Asserts the password contains an `#` or is at least 10 characters long. # @[Assert::AtLeastOneOf([ # @[Assert::Regex(/#/)], # @[Assert::Size(10..)], # ])] # getter password : String # # # Asserts the `grades` array contains at least 3 elements or # # that each element is greater than or equal to 5. # @[Assert::AtLeastOneOf([ # @[Assert::Size(3..)], # @[Assert::All([ # @[Assert::GreaterThanOrEqual(5)], # ])], # ])] # getter grades : Array(Int32) # end # ``` # # NOTE: The annotation approach only supports two levels of nested annotations. # Manually wire up the constraint via code if you require more than that. class Athena::Validator::Constraints::AtLeastOneOf < Athena::Validator::Constraints::Composite DEFAULT_ERROR_MESSAGE = "This value should satisfy at least one of the following constraints:" AT_LEAST_ONE_OF_ERROR = "811994eb-b634-42f5-ae98-13eec66481b6" @@error_names = { AT_LEAST_ONE_OF_ERROR => "AT_LEAST_ONE_OF_ERROR", } getter? include_internal_messages : Bool getter message_collection : String def initialize( constraints : AVD::Constraints::Composite::Type, @include_internal_messages : Bool = true, @message_collection : String = "Each element of this collection should satisfy its own set of constraints.", message : String = "This value should satisfy at least one of the following constraints:", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super constraints, message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::AtLeastOneOf) : Nil messages = [constraint.message] validator = self.context.validator constraint.constraints.each do |idx, item| violations = validator.validate value, [item] return if violations.empty? if constraint.include_internal_messages? messages << String.build do |str| str << " [#{idx.to_i + 1}] " str << if item.is_a? AVD::Constraints::All constraint.message_collection else violations.first.message end end end end self.context.add_violation messages.join, AT_LEAST_ONE_OF_ERROR end end end ================================================ FILE: src/components/validator/src/constraints/blank.cr ================================================ # Validates that a value is blank; meaning equal to an empty string or `nil`. # # ``` # class Profile # include AVD::Validatable # # def initialize(@username : String); end # # @[Assert::Blank] # property username : String # end # ``` # # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be blank.` # # The message that will be shown if the value is not blank. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Blank < Athena::Validator::Constraint NOT_BLANK_ERROR = "c815f901-c581-4fb7-a85d-b8c5bc757959" @@error_names = { NOT_BLANK_ERROR => "NOT_BLANK_ERROR", } def initialize( message : String = "This value should be blank.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::Blank) : Nil return if value.nil? return if value.responds_to?(:blank?) && value.blank? self.context.add_violation constraint.message, NOT_BLANK_ERROR, value end end end ================================================ FILE: src/components/validator/src/constraints/callback.cr ================================================ # Allows creating totally custom validation rules, assigning any violations to specific fields on your object. # This process is achieved via using one or more _callback_ methods which will be invoked during the validation process. # # NOTE: The callback method itself does _fail_ or return any value. # Instead it should directly add violations to the `AVD::ExecutionContextInterface` argument. # # # Configuration # # ## Required Arguments # # ### callback # # **Type:** `AVD::Constraints::Callback::CallbackProc?` **Default:** `nil` # # The proc that should be invoked as the callback for this constraint. # # NOTE: If this argument is not supplied, the [callback_name](#callback_name) argument must be. # # ### callback_name # # **Type:** `String?` **Default:** `nil` # # The name of the method that should be invoked as the callback for this constraint. # # NOTE: If this argument is not supplied, the [callback](#callback) argument must be. # # ## Optional Arguments # # NOTE: This constraint does not support a `message` argument. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. # # # Usage # # The callback constraint supports two callback methods when validating objects, and one callback method when using the constraint directly. # # ## Instance Methods # # To define an instance callback method, apply the `@[Assert::Callback]` method to a public instance method defined within an object. # This method should accept two arguments: the `AVD::ExecutionContextInterface` to which violations should be added, # and the `AVD::Constraint@payload` from the related constraint. # # More than one callback method can exist on a type, and the method name does not have to be `validate`. # # ``` # class Example # include AVD::Validatable # # SPAM_DOMAINS = ["fake.com", "spam.net"] # # def initialize(@domain_name : String); end # # @[Assert::Callback] # def validate(context : AVD::ExecutionContextInterface, payload : Hash(String, String)?) : Nil # # Validate that the `domain_name` is not spammy. # return unless SPAM_DOMAINS.includes? @domain_name # # context # .build_violation("This domain name is not legit!") # .at_path("domain_name") # .add # end # end # ``` # # ## Class Methods # # The callback method can also be defined as a class method. # Since class methods do not have access to the related object instance, it is passed in as an argument. # # That argument is typed as `AVD::Constraints::Callback::Value` instance which exposes a `AVD::Constraints::Callback::Value#get` # method that can be used as an easier syntax than `.as`. # # ``` # class Example # include AVD::Validatable # # SPAM_DOMAINS = ["fake.com", "spam.net"] # # @[Assert::Callback] # def self.validate(value : AVD::Constraints::Callback::ValueContainer, context : AVD::ExecutionContextInterface, payload : Hash(String, String)?) : Nil # # Get the object from the value, typed as our `Example` class. # object = value.get self # # # Validate that the `domain_name` is not spammy. # return unless SPAM_DOMAINS.includes? object.domain_name # # context # .build_violation("This domain name is not legit!") # .at_path("domain_name") # .add # end # # def initialize(@domain_name : String); end # # getter domain_name : String # end # ``` # # ## Procs/Blocks # # When working with constraints in a non object context, a callback passed in as a proc/block. # `AVD::Constraints::Callback::CallbackProc` alias can be used to more easily create a callback proc. # `AVD::Constraints::Callback.with_callback` can be used to create a callback constraint, using the block as the callback proc. # See the related types for more information. # # Proc/block based callbacks operate similarly to [Class Methods][Athena::Validator::Constraints::Callback--class-methods] in that they receive the value as an argument. class Athena::Validator::Constraints::Callback < Athena::Validator::Constraint # :nodoc: abstract struct ValueContainer abstract def type_name : String def inspect(io : IO) : Nil io << "#" end end # Wrapper type to allow passing arbitrarily typed values as arguments in the `AVD::Constraints::Callback::CallbackProc`. record Value(T) < ValueContainer, value : T do forward_missing_to @value # :inherit: def type_name : String {{ T.stringify }} end # Returns the value as `T`. # # If used inside a `AVD::Constraints::Callback@class-method`. # # ``` # # Get the wrapped value as the type of the current class. # object = value.get self # ``` # # If used inside a `AVD::Constraints::Callback@procsblocks`. # ``` # # Get the wrapped value as the expected type. # value = value.get Int32 # # # Alternatively, can use normal Crystal semantics for narrowing the type. # value = value.value # # case value # when Int32 then "value is Int32" # when String then "value is String" # end def get(as _t : T.class) : T forall T @value.as?(T).not_nil! end def ==(other) : Bool @value == other end end # Convenience method for creating a `AVD::Constraints::Callback` with # the given *&block* as the callback. # # ``` # # Instantiate a callback constraint, using the block as the callback # constraint = AVD::Constraints::Callback.with_callback do |value, context, payload| # next if (value = value.get(Int32)).even? # # context.add_violation "This value should be even." # end # ``` def self.with_callback(**args, &block : AVD::Constraints::Callback::ValueContainer, AVD::ExecutionContextInterface, Hash(String, String)? ->) : AVD::Constraints::Callback new **args, callback: block end # Convenience alias to make creating `AVD::Constraints::Callback` procs easier. # # ``` # # Create a proc to handle the validation # callback = AVD::Constraints::Callback::CallbackProc.new do |value, context, payload| # return if (value = value.get(Int32)).even? # # context.add_violation "This value should be even." # end # # # Instantiate a callback constraint with this proc # constraint = AVD::Constraints::Callback.new callback: callback # ``` alias CallbackProc = Proc(AVD::Constraints::Callback::ValueContainer, AVD::ExecutionContextInterface, Hash(String, String)?, Nil) # Returns the name of the callback method this constraint should invoke. getter callback_name : String? # Returns the proc that this constraint should invoke. getter callback : AVD::Constraints::Callback::CallbackProc? def initialize( @callback : AVD::Constraints::Callback::CallbackProc? = nil, @callback_name : String? = nil, groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) raise AVD::Exception::Logic.new "either `callback` or `callback_name` must be provided." if @callback.nil? && @callback_name.nil? super "", groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::Callback) : Nil if value.is_a?(AVD::Validatable) && (name = constraint.callback_name) && (metadata = self.context.metadata) && (metadata.is_a?(AVD::Metadata::ClassMetadata)) metadata.invoke_callback name, value, self.context, constraint.payload elsif callback = constraint.callback callback.call Value.new(value), self.context, constraint.payload end end end end ================================================ FILE: src/components/validator/src/constraints/choice.cr ================================================ # Validates that a value is one of a given set of valid choices; # can also be used to validate that each item in a collection is one of those valid values. # # ``` # class User # include AVD::Validatable # # def initialize(@role : String); end # # @[Assert::Choice(["member", "moderator", "admin"])] # property role : String # end # ``` # # # Configuration # # ## Required Arguments # # ### choices # # **Type:** `Array(String | Number::Primitive | Symbol)` # # The choices that are considered valid. # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value is not a valid choice.` # # The message that will be shown if the value is not a valid choice and [multiple](#multiple) is `false`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ choices }}` - The available choices. # # ### multiple_message # # **Type:** `String` **Default:** `One or more of the given values is invalid.` # # The message that will be shown if one of the values is not a valid choice and [multiple](#multiple) is `true`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ choices }}` - The available choices. # # ### min_message # # **Type:** `String` **Default:** `You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices.` # # The message that will be shown if too few choices are chosen as per the [range](#range) option. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ choices }}` - The available choices. # * `{{ limit }}` - If [multiple](#multiple) is true, enforces that at most this many values may be selected in order to be valid. # # ### max_message # # **Type:** `String` **Default:** `You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices.` # # The message that will be shown if too many choices are chosen as per the [range](#range) option. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ choices }}` - The available choices. # * `{{ limit }}` - If [multiple](#multiple) is true, enforces that no more than this many values may be selected in order to be valid. # # ### range # # **Type:** `::Range?` **Default:** `nil` # # If [multiple](#multiple) is true, is used to define the "range" of how many choices must be valid for the value to be considered valid. # For example, if set to `(3..)`, but there are only 2 valid items in the input enumerable then validation will fail. # # Beginless/endless ranges can be used to define only a lower/upper bound. # # ### multiple # # **Type:** `Bool` **Default:** `false` # # If `true`, the input value is expected to be an `Enumerable` instead of a single scalar value. # The constraint will check each item in the enumerable is valid choice. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Choice < Athena::Validator::Constraint NO_SUCH_CHOICE_ERROR = "c7398ea5-e787-4ee9-9fca-5f2c130614d6" TOO_FEW_ERROR = "3573357d-c9a8-4633-a742-c001086fd5aa" TOO_MANY_ERROR = "91d0d22b-a693-4b9c-8b41-bc6392cf89f4" @@error_names = { NO_SUCH_CHOICE_ERROR => "NO_SUCH_CHOICE_ERROR", TOO_FEW_ERROR => "TOO_FEW_ERROR", TOO_MANY_ERROR => "TOO_MANY_ERROR", } getter choices : Array(String | Number::Primitive | Symbol) getter multiple_message : String getter min_message : String getter max_message : String getter min : Number::Primitive? getter max : Number::Primitive? getter? multiple : Bool def self.new( choices : Array(String | Number::Primitive | Symbol), message : String = "This value is not a valid choice.", multiple_message : String = "One or more of the given values is invalid.", min_message : String = "You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices.", max_message : String = "You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices.", multiple : Bool = false, range : ::Range? = nil, groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) new choices.map(&.as(String | Number::Primitive | Symbol)), message, multiple_message, min_message, max_message, multiple, range.try(&.begin), range.try(&.end), groups, payload end private def initialize( @choices : Array(String | Number::Primitive | Symbol), message : String, @multiple_message : String, @min_message : String, @max_message : String, @multiple : Bool, @min : Number::Primitive?, @max : Number::Primitive?, groups : Array(String) | String | Nil, payload : Hash(String, String)?, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : Enumerable?, constraint : AVD::Constraints::Choice) : Nil return if value.nil? self.raise_invalid_type(value, "Enumerable") unless constraint.multiple? choices = constraint.choices value.each do |v| unless choices.includes? v self .context .build_violation(constraint.multiple_message, NO_SUCH_CHOICE_ERROR, v) .add_parameter("{{ choices }}", choices) .invalid_value(v) .add return end end size = value.size if (limit = constraint.min) && (size < limit) self .context .build_violation(constraint.min_message, TOO_FEW_ERROR, value) .add_parameter("{{ limit }}", limit) .add_parameter("{{ choices }}", choices) .plural(limit.to_i) .invalid_value(value) .add return end if (limit = constraint.max) && (size > limit) self .context .build_violation(constraint.max_message, TOO_MANY_ERROR, value) .add_parameter("{{ limit }}", limit) .add_parameter("{{ choices }}", choices) .plural(limit.to_i) .invalid_value(value) .add return end end # :inherit: def validate(value : _, constraint : AVD::Constraints::Choice) : Nil return if value.nil? self.raise_invalid_type(value, "Enumerable") if constraint.multiple? && !value.is_a?(Enumerable) return if constraint.choices.includes? value self .context .build_violation(constraint.message, NO_SUCH_CHOICE_ERROR, value) .add_parameter("{{ choices }}", constraint.choices) .add end end end ================================================ FILE: src/components/validator/src/constraints/collection.cr ================================================ # Can be used with any `Enumerable({K, V})` to validate each key in a different way. # For example validating the `email` key via `AVD::Constraints::Email`, and the `inventory` key with the `AVD::Constraints::Range` constraint. # The collection constraint can also ensure that certain collection keys are present and that extra keys are not present. # # TODO: Update it to be `Mappable` when/if https://github.com/crystal-lang/crystal/issues/10886 is implemented. # # # Usage # # ``` # data = { # "email" => "...", # "email_signature" => "...", # } # ``` # # For example, say you want to ensure the *email* field is a valid email, # and that their *email_signature* is not blank nor over 100 characters long; # without creating a dedicated class to represent the hash. # # ``` # constraint = AVD::Constraints::Collection.new({ # "email" => AVD::Constraints::Email.new, # "email_signature" => [ # AVD::Constraints::NotBlank.new, # AVD::Constraints::Size.new(..100, max_message: "Your signature is too long"), # ], # }) # # validator.validate data, constraint # ``` # # The collection constraint expects a hash representing the keys in the collection, with the value being which constraint(s) should be executed against its value. # From there we can go ahead and validate our data hash against the constraint. # # ## Presence and Absence of Fields # # This constraint also will return validation errors if any keys of a collection are missing, or if there are any unrecognized keys in the collection. # This can be customized via the [allow_extra_fields](#allow_extra_fields) and [allow_missing_fields](#allow_missing_fields) configuration options respectively. # # If the latter was set to `true`, then either *email* or *email_signature* could be missing from the data hash, and no validation errors would occur. # # ## Required and Optional Constraints # # Each field in the collection is assumed to be required by default. # While you could make everything optional via the setting [allow_missing_fields](#allow_missing_fields) to `true`, # this is less than ideal in some cases when you only want to affect a single key, or a subset of keys. # # In this case, a single constraint, or array of constraints, can be wrapped via the `AVD::Constraints::Optional` or `AVD::Constraints::Required` constraints. # For example, if you wanted to require that the *personal_email* field is not blank and is a valid email, # but also have an optional *alternate_email* field that must be a valid email if supplied, you could set things up like: # # ``` # constraint = AVD::Constraints::Collection.new({ # "personal_email" => AVD::Constraints::Required.new([ # AVD::Constraints::NotBlank.new, # AVD::Constraints::Email.new, # ]), # "alternate_email" => AVD::Constraints::Optional.new([ # AVD::Constraints::Email.new, # ] of AVD::Constraint), # }) # ``` # # In this way, even if [allow_missing_fields](#allow_missing_fields) is `true`, you would be able to omit *alternate_email* since it is optional. # However, since *personal_email* is required, the not blank assertion will still be applied and a violation will occur if it is missing. # # ## Groups # # Any groups defined in nested constraints are automatically added to the collection constraint itself such that it can be traversed for all nested groups. # # ``` # constraint = AVD::Constraints::Collection.new({ # "name" => AVD::Constraints::NotBlank.new(groups: "basic"), # "email" => AVD::Constraints::NotBlank.new(groups: "contact"), # }) # # constraint.groups # => ["basic", "contact"] # ``` # # TIP: The collection constraint can be used to validate form data via a [URI::Param](https://crystal-lang.org/api/URI/Params.html) instance. # # # Configuration # # ## Required Arguments # # ### fields # # **Type:** `Hash(String, AVD::Constraint | Array(AVD::Constraint))` # # A hash defining the keys in the collection, and for which constraint(s) should be executed against them. # # ## Optional Arguments # # ### allow_extra_fields # # **Type:** `Bool` **Default:** `false` # # If extra fields in the collection other than those defined within [fields](#fields) are allowed. By default extra fields will result in a validation error. # # ### allow_missing_fields # # **Type:** `Bool` **Default:** `false` # # If the fields defined within [fields](#fields) are allowed to be missing. By default a validation error will be returned if one or more field is missing. # # ### extra_fields_message # # **Type:** `String` **Default:** `This field was not expected.` # # The message that will be shown if [allow_extra_fields](#allow_extra_fields) is `false` and a field in the collection was not defined within `#fields`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ field }}` - The name of the extra field. # # ### missing_fields_message # # **Type:** `String` **Default:** `This field is missing.` # # The message that will be shown if [allow_missing_fields](#allow_missing_fields) is `false` and a field defined within `#fields` is missing from the collection. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ field }}` - The name of the missing field. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Collection < Athena::Validator::Constraints::Composite MISSING_FIELD_ERROR = "af103ee5-3bcb-448e-98ad-b4ef76c05060" NO_SUCH_FIELD_ERROR = "70e60467-4078-4f92-acf9-d1e6683d0922" @@error_names = { MISSING_FIELD_ERROR => "MISSING_FIELD_ERROR", NO_SUCH_FIELD_ERROR => "NO_SUCH_FIELD_ERROR", } getter? allow_extra_fields : Bool getter? allow_missing_fields : Bool getter extra_fields_message : String getter missing_fields_message : String def initialize( fields : Hash(String, AVD::Constraint | Array(AVD::Constraint)), @allow_extra_fields : Bool = false, @allow_missing_fields : Bool = false, @extra_fields_message : String = "This field was not expected.", @missing_fields_message : String = "This field is missing.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) constraints = Hash(String, AVD::Constraint).new fields.each do |key, value| constraints[key] = !value.is_a?(AVD::Constraints::Optional) && !value.is_a?(AVD::Constraints::Required) ? AVD::Constraints::Required.new(value) : value end super constraints, "", groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: # # TODO: Support https://github.com/crystal-lang/crystal/issues/10886 when/if implemented. def validate(value : Enumerable({K, V})?, constraint : AVD::Constraints::Collection) : Nil forall K, V return if value.nil? context = self.context constraint.constraints.each do |field, field_constraint| field_constraint = field_constraint.as AVD::Constraints::Existence if value.has_key? field if field_constraint.constraints.size > 0 context .validator .in_context(context) .at_path("[#{field}]") .validate(value[field], field_constraint.constraints.values) end elsif !field_constraint.is_a?(AVD::Constraints::Optional) && !constraint.allow_missing_fields? context .build_violation(constraint.missing_fields_message, MISSING_FIELD_ERROR) .at_path("[#{field}]") .add_parameter("{{ field }}", field) .invalid_value(nil) .add end end unless constraint.allow_extra_fields? value.each do |field, field_value| unless constraint.constraints.has_key? field context .build_violation(constraint.extra_fields_message, NO_SUCH_FIELD_ERROR) .at_path("[#{field}]") .add_parameter("{{ field }}", field) .invalid_value(field_value) .add end end end end # :inherit: def validate(actual : _, expected : _) : NoReturn self.raise_invalid_type actual, "Enumerable({K, V})" end end end ================================================ FILE: src/components/validator/src/constraints/composite.cr ================================================ # A constraint composed of other constraints. # handles normalizing the groups of the nested constraints, via the following algorithm: # # * If groups are passed explicitly to the composite constraint, but # not to the nested constraints, the options of the composite # constraint are copied to the nested constraints # * If groups are passed explicitly to the nested constraints, but not # to the composite constraint, the groups of all nested constraints # are merged and used as groups for the composite constraint # * If groups are passed explicitly to both the composite and its nested # constraints, the groups of the nested constraints must be a subset # of the groups of the composite constraint. # # NOTE: You most likely want to use `AVD::Constraints::Compound` instead of this type. abstract class Athena::Validator::Constraints::Composite < Athena::Validator::Constraint alias Type = Array(AVD::Constraint) | AVD::Constraint | Enumerable({String | Int32, AVD::Constraint}) getter constraints : Enumerable({String | Int32, AVD::Constraint}) def initialize( constraints : AVD::Constraints::Composite::Type, message : String, groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload constraints = case constraints when AVD::Constraint then {0 => constraints} of String | Int32 => AVD::Constraint when Array hash = Hash(String | Int32, AVD::Constraint).new initial_capacity: constraints.size constraints.each_with_index do |v, k| hash[k] = v end hash else constraints.transform_keys(&.as(String | Int32)) end constraints.each_value do |c| raise AVD::Exception::Logic.new "The '#{AVD::Constraints::Valid}' constraint cannot be nested inside a '#{self.class}' constraint." if c.is_a? AVD::Constraints::Valid end if groups.nil? merged_groups = Hash(String, Bool).new constraints.each_value do |constraint| constraint.groups.each do |group| merged_groups[group] = true end end @groups = merged_groups.empty? ? [AVD::Constraint::DEFAULT_GROUP] : merged_groups.keys @constraints = constraints return end constraints.each_value do |constraint| if !constraint.@groups.nil? unless (excess_groups = (constraint.groups - self.groups)).empty? raise AVD::Exception::Logic.new "The group(s) '#{excess_groups.join ", "}' passed to the constraint '#{constraint.class}' should also be passed to its containing constraint '#{self.class}'." end else constraint.groups = self.groups end end @constraints = constraints end def add_implicit_group(group : String) : Nil super group @constraints.each_value &.add_implicit_group(group) end end ================================================ FILE: src/components/validator/src/constraints/compound.cr ================================================ # Allows creating a custom set of reusable constraints, representing rules to use consistently across your application. # # NOTE: See the [custom constraint][Athena::Validator::Constraint--custom-constraints] documentation for information on defining custom constraints. # # # Configuration # # ## Optional Arguments # # NOTE: This constraint does not support a `message` argument. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. # # # Usage # # This constraint is not used directly on its own; # instead it's used to create another constraint. # # ``` # # Define a compound constraint to centralize the logic to validate a password. # # # # NOTE: The constraint _MUST_ be defined within the `AVD::Constraints` namespace for implementation reasons. This may change in the future. # class AVD::Constraints::ValidPassword < AVD::Constraints::Compound # # Define a method that returns an array of the constraints we want to be a part of `self`. # def constraints : Type # [ # AVD::Constraints::NotBlank.new, # Not empty/null # AVD::Constraints::Size.new(12..), # At least 12 characters longs # AVD::Constraints::Regex.new(/^\d.*/), # Must start with a number # ] # end # end # ``` # # We can then use this constraint as we would any other. # # Either as an annotation # # ``` # @[Assert::ValidPassword] # getter password : String # ``` # or directly. # # ``` # constraint = AVD::Constraints::ValidPassword.new # ``` abstract class Athena::Validator::Constraints::Compound < Athena::Validator::Constraints::Composite def initialize( groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super self.constraints, "", groups, payload end def validated_by : AVD::ConstraintValidator.class AVD::Constraints::Compound::Validator end abstract def constraints : AVD::Constraints::Composite::Type class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::Compound) : Nil context = self.context validator = context.validator.in_context context validator.validate value, constraint.@constraints.values end end end ================================================ FILE: src/components/validator/src/constraints/count.cr ================================================ # Validates that the `#size` of an `Indexable` value is between some minimum and maximum. # # ``` # class User # include AVD::Validatable # # def initialize(@emails : Array(String)); end # # @[Assert::Count(1..5)] # property emails : Array(String) # end # ``` # # # Configuration # # ## Required Arguments # # ### range # # **Type:** `::Range` # # The `::Range` that defines the minimum and maximum values, if any. # An endless range can be used to only have a minimum or maximum. # # ## Optional Arguments # # NOTE: This constraint does not support a `message` argument. # # ### exact_message # # **Type:** `String` **Default:** `This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements.` # # The message that will be shown if min and max values are equal and the underlying collection’s count is not exactly this value. # The message is pluralized depending on how many elements the underlying value has. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ count }}` - The current collection count # * `{{ limit }}` - The exact expected collection count # # ### min_message # # **Type:** `String` **Default:** `This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more.` # # The message that will be shown if the underlying collection’s count is less than the min. # The message is pluralized depending on how many elements the underlying value has. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ count }}` - The current collection count # * `{{ limit }}` - The lower limit # # ### max_message # # **Type:** `String` **Default:** `This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less.` # # The message that will be shown if the underlying collection’s count is greater than the max. # The message is pluralized depending on how many elements the underlying value has. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ count }}` - The current collection count # * `{{ limit }}` - The upper limit # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Count < Athena::Validator::Constraint TOO_FEW_ERROR = "07f04a04-c346-4983-9868-602c62a5d0c1" TOO_MANY_ERROR = "c35d873a-8095-4710-88d0-de68bce36055" NOT_EQUAL_COUNT_ERROR = "ee29a9b5-924b-42dd-a810-044c86803244" @@error_names = { TOO_FEW_ERROR => "TOO_FEW_ERROR", TOO_MANY_ERROR => "TOO_MANY_ERROR", NOT_EQUAL_COUNT_ERROR => "NOT_EQUAL_COUNT_ERROR", } getter min : Int32? getter max : Int32? getter min_message : String getter max_message : String getter exact_message : String def self.new( range : ::Range, min_message : String = "This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more.", max_message : String = "This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less.", exact_message : String = "This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) new range.begin, range.end, min_message, max_message, exact_message, groups, payload end private def initialize( @min : Int32?, @max : Int32?, @min_message : String = "This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more.", @max_message : String = "This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less.", @exact_message : String = "This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super "", groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : Indexable, constraint : AVD::Constraints::Count) : Nil return if value.nil? count = value.size min = constraint.min max = constraint.max if max && count > max exactly_option_enabled = min == max self .context .build_violation( exactly_option_enabled ? constraint.exact_message : constraint.max_message, exactly_option_enabled ? NOT_EQUAL_COUNT_ERROR : TOO_MANY_ERROR, value ) .add_parameter("{{ count }}", count) .add_parameter("{{ limit }}", max) .invalid_value(value) .plural(max) .add end if min && count < min exactly_option_enabled = min == max self .context .build_violation( exactly_option_enabled ? constraint.exact_message : constraint.min_message, exactly_option_enabled ? NOT_EQUAL_COUNT_ERROR : TOO_FEW_ERROR, value ) .add_parameter("{{ count }}", count) .add_parameter("{{ limit }}", min) .invalid_value(value) .plural(min) .add end end end end ================================================ FILE: src/components/validator/src/constraints/email.cr ================================================ # Validates that a value is a valid email address. # The underlying value is converted to a string via `#to_s` before being validated. # # NOTE: As with most other constraints, `nil` and empty strings are considered valid values, in order to allow the value to be optional. # If the value is required, consider combining this constraint with `AVD::Constraints::NotBlank`. # # ``` # class User # include AVD::Validatable # # def initialize(@email : String); end # # @[Assert::Email] # property email : String # end # ``` # # # Configuration # # ## Optional Arguments # # ### mode # # **Type:** `AVD::Constraints::Email::Mode` **Default:** `AVD::Constraints::Email::Mode::HTML5` # # Defines the pattern that should be used to validate the email address. # # ### message # # **Type:** `String` **Default:** `This value is not a valid email address.` # # The message that will be shown if the value is not a valid email address. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Email < Athena::Validator::Constraint # Determines _how_ the email address should be validated. enum Mode # Validates the email against the [HTML5 input pattern](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address), but requires a [TLD](https://en.wikipedia.org/wiki/Top-level_domain) to be present. HTML5 # Same as `HTML5`, but follows the pattern exactly, allowing there to be no [TLD](https://en.wikipedia.org/wiki/Top-level_domain). HTML5_ALLOW_NO_TLD # TODO: Implement this mode. # STRICT # Returns the `::Regex` pattern for `self`. def pattern : ::Regex case self in .html5? then /^[a-zA-Z0-9.!\#$\%&\'*+\\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/ in .html5_allow_no_tld? then /^[a-zA-Z0-9.!\#$\%&\'*+\\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ end end end INVALID_FORMAT_ERROR = "ad9d877d-9ad1-4dd7-b77b-e419934e5910" @@error_names = { INVALID_FORMAT_ERROR => "INVALID_FORMAT_ERROR", } getter mode : AVD::Constraints::Email::Mode def initialize( @mode : AVD::Constraints::Email::Mode = :html5, message : String = "This value is not a valid email address.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::Email) : Nil value = value.to_s return if value.nil? || value.empty? return if value.matches? constraint.mode.pattern self.context.add_violation constraint.message, INVALID_FORMAT_ERROR, value end end end ================================================ FILE: src/components/validator/src/constraints/equal_to.cr ================================================ # Validates that a value is equal to another. # # ``` # class Project # include AVD::Validatable # # def initialize(@name : String); end # # @[Assert::EqualTo("Athena")] # property name : String # end # ``` # # # Configuration # # ## Required Arguments # # ### value # # Defines the value that the value being validated should be compared to. # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be equal to {{ compared_value }}.` # # The message that will be shown if the value is not equal to the comparison value. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ compared_value }}` - The expected value. # * `{{ compared_value_type }}` - The type of the expected value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::EqualTo(ValueType) < Athena::Validator::Constraint include Athena::Validator::Constraints::AbstractComparison(ValueType) NOT_EQUAL_ERROR = "47d83d11-15d5-4267-b469-1444f80fd169" @@error_names = { NOT_EQUAL_ERROR => "NOT_EQUAL_ERROR", } # :inherit: def default_error_message : String "This value should be equal to {{ compared_value }}." end class Validator < Athena::Validator::Constraints::ComparisonValidator # :inherit: def compare_values(actual : _, expected : _) : Bool actual == expected end # :inherit: def error_code : String NOT_EQUAL_ERROR end end end ================================================ FILE: src/components/validator/src/constraints/existence.cr ================================================ # See [AVD::Constraints::Collection][Athena::Validator::Constraints::Collection--required-and-optional-constraints] for more information. abstract class Athena::Validator::Constraints::Existence < Athena::Validator::Constraints::Composite def initialize( constraints : Array(AVD::Constraint) | AVD::Constraint = [] of AVD::Constraint, groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super constraints, "", groups, payload end end ================================================ FILE: src/components/validator/src/constraints/file.cr ================================================ require "athena-mime" require "athena-http" # Validates that a value is a valid file. # If the underlying value is a [::File](https://crystal-lang.org/api/File.html), then its path is used as the value. # Otherwise the value is converted to a string via `#to_s` before being validated, which is assumed to be a path to a file. # # NOTE: As with most other constraints, `nil` and empty strings are considered valid values, in order to allow the value to be optional. # If the value is required, consider combining this constraint with `AVD::Constraints::NotBlank`. # # ``` # class Profile # include AVD::Validatable # # def initialize(@resume : ::File); end # # @[Assert::File] # property resume : ::File # end # ``` # # # Configuration # # ## Optional Arguments # # ### max_size # # **Type:** `Int | String | Nil` **Default:** `nil` # # Defines that maximum size the file must be in order to be considered valid. # The value may be an integer representing the size in bytes, or a format string in one of the following formats: # # | Suffix | Unit Name | Value | Example | # | :----- | :-------- | :-------------- | :------ | # | (none) | byte | 1 byte | `4096` | # | `k` | kilobyte | 1,000 bytes | `"200k"` | # | `M` | megabyte | 1,000,000 bytes | `"2M"` | # | `Ki` | kibibyte | 1,024 bytes | `"32Ki"` | # | `Mi` | mebibyte | 1,048,576 bytes | `"8Mi"` | # # ### mime_types # # **Type:** `Enumerable(String)?` **Default:** `nil` # # If set, allows checking that the MIME type of the file is one of an allowed set of types. # This value is ignored if the MIME type of the file could not be determined. # # ### binary_format # # **Type:** `Bool?` **Default:** `nil` # # When `true`, the sizes will be displayed in messages with binary-prefixed units (KiB, MiB). # When `false`, the sizes will be displayed with SI-prefixed units (kB, MB). # When `nil`, then the binaryFormat will be guessed from the value defined in the [max_size](#max_size) option. # # ### max_size_message # # **Type:** `String` **Default:** `The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}.` # # The message that will be shown if the file is greater than the [max_size](#max_size). # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ file }}` - Absolute path to the invalid file. # * `{{ limit }}` - Maximum file size allowed. # * `{{ name }}` - Basename of the invalid file. # * `{{ size }}` - The size of the invalid file. # * `{{ suffix }}` - Suffix for the used file size unit. # # ### not_found_message # # **Type:** `String` **Default:** `The file could not be found.` # # The message that will be shown if no file could be found at the given path. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ file }}` - Absolute path to the invalid file. # # ### empty_message # # **Type:** `String` **Default:** `An empty file is not allowed.` # # The message that will be shown if the file is empty. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ file }}` - Absolute path to the invalid file. # * `{{ name }}` - Basename of the invalid file. # # ### not_readable_message # # **Type:** `String` **Default:** `The file is not readable.` # # The message that will be shown if the file is not readable. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ file }}` - Absolute path to the invalid file. # * `{{ name }}` - Basename of the invalid file. # # ### mime_type_message # # **Type:** `String` **Default:** `The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}.` # # The message that will be shown if the MIME type of the file is not one of the valid [mime_types](#mime_types). # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ file }}` - Absolute path to the invalid file. # * `{{ name }}` - Basename of the invalid file. # * `{{ type }}` - The MIME type of the invalid file. # * `{{ types }}` - The list of allowed MIME types. # # ### upload_file_size_message # # **Type:** `String` **Default:** `The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}.` # # The message that will be shown if the uploaded file is larger than the configured [max allowed size](/Framework/Bundle/Schema/FileUploads/#Athena::Framework::Bundle::Schema::FileUploads#max_file_size). # See the [Getting Started](/getting_started/routing/#file-uploads) docs for more information. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ limit }}` - The maximum file size allowed. # * `{{ suffix }}` - Suffix for the used file size unit. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::File < Athena::Validator::Constraint NOT_FOUND_ERROR = "b6ae563c-4aec-4dfa-b268-2bb282912ed8" NOT_READABLE_ERROR = "e9f18a3d-f968-469f-868e-2331c8c982c2" EMPTY_ERROR = "de1a4b3c-a69f-46bd-b017-4a60361a1765" TOO_LARGE_ERROR = "4ce61d7c-43a0-44c2-bfe0-a59072b6cd17" INVALID_MIME_TYPE_ERROR = "96c8591c-e990-48f6-b82b-75c878ae9fd9" UPLOAD_FILE_SIZE_ERROR = "6b06e7c7-2f21-46ef-b6ec-1dac08a1af7e" private KB_BYTES = 1_000 private MB_BYTES = 1_000_000 private KIB_BYTES = 1_024 private MIB_BYTES = 1_048_576 private SUFFICES = { 1 => "bytes", KB_BYTES => "kB", MB_BYTES => "MB", KIB_BYTES => "KiB", MIB_BYTES => "MiB", } @@error_names = { NOT_FOUND_ERROR => "NOT_FOUND_ERROR", NOT_READABLE_ERROR => "NOT_READABLE_ERROR", EMPTY_ERROR => "EMPTY_ERROR", TOO_LARGE_ERROR => "TOO_LARGE_ERROR", INVALID_MIME_TYPE_ERROR => "INVALID_MIME_TYPE_ERROR", UPLOAD_FILE_SIZE_ERROR => "UPLOAD_FILE_SIZE_ERROR", } getter not_found_message : String getter not_readable_message : String getter empty_message : String getter max_size_message : String getter mime_type_message : String getter upload_file_size_message : String getter max_size : Int64? getter mime_types : Set(String)? getter! binary_format : Bool? def initialize( max_size : Int | String | Nil = nil, @binary_format : Bool? = nil, mime_types : Enumerable(String)? = nil, @not_found_message : String = "The file could not be found.", @not_readable_message : String = "The file is not readable.", @empty_message : String = "An empty file is not allowed.", @max_size_message : String = "The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}.", @mime_type_message : String = "The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}.", @upload_file_size_message : String = "The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super "", groups, payload mime_types.try do |types| @mime_types = types.to_set end max_size.try do |bytes| @max_size = self.normalize_binary_format bytes end end private def normalize_binary_format(max_size : Int) : Int64 @binary_format = @binary_format.nil? ? false : @binary_format max_size.to_i64 end private def normalize_binary_format(max_size : String) : Int64 if number = max_size.to_i64? return self.normalize_binary_format number end factors = { "k" => 1_000, "ki" => 1 << 10, "m" => 1000 * 1000, "mi" => 1 << 20, "g" => 1000 * 1000 * 1000, "gi" => 1 << 30, } if match = max_size.match /^(\d++)(#{factors.each_key.join('|')})$/i unit = match[2].downcase @binary_format = @binary_format.nil? ? 2 == unit.size : @binary_format return match[1].to_i64 * factors[unit].to_i64 end raise AVD::Exception::InvalidArgument.new "'#{max_size}' is not a valid maximum size." end class Validator < Athena::Validator::ConstraintValidator # :inherit: # # ameba:disable Metrics/CyclomaticComplexity def validate(value : _, constraint : AVD::Constraints::File) : Nil return if value.nil? || value == "" if value.is_a?(Athena::HTTP::UploadedFile) && !value.valid? case value.status when .size_limit_exceeded? max_allowed_file_size = Athena::HTTP::UploadedFile.max_file_size if (constraint_max_size = constraint.max_size) && (constraint_max_size < max_allowed_file_size) limit_in_bytes = constraint_max_size binary_format = constraint.binary_format else limit_in_bytes = max_allowed_file_size binary_format = (bf = constraint.binary_format?).nil? ? true : bf end _, limit_as_string, suffix = self.factorize_sizes 0, limit_in_bytes, binary_format self .context .build_violation(constraint.upload_file_size_message, UPLOAD_FILE_SIZE_ERROR) .add_parameter("{{ limit }}", limit_as_string) .add_parameter("{{ suffix }}", suffix) .add return end end path = case value when Path then value when ::File, Athena::HTTP::AbstractFile then value.path else value.to_s end unless ::File.file? path self .context .build_violation(constraint.not_found_message, NOT_FOUND_ERROR) .add_parameter("{{ file }}", path) .add return end unless ::File::Info.readable? path self .context .build_violation(constraint.not_readable_message, NOT_READABLE_ERROR) .add_parameter("{{ file }}", path) .add return end size_in_bytes = ::File.size path base_name = value.is_a?(Athena::HTTP::UploadedFile) ? value.client_original_name : ::File.basename path if size_in_bytes.zero? self .context .build_violation(constraint.empty_message, EMPTY_ERROR) .add_parameter("{{ file }}", path) .add_parameter("{{ name }}", base_name) .add return end if (max_size_in_bytes = constraint.max_size) && size_in_bytes > max_size_in_bytes size_as_string, limit_as_string, suffix = self.factorize_sizes size_in_bytes, max_size_in_bytes, constraint.binary_format self .context .build_violation(constraint.max_size_message, TOO_LARGE_ERROR) .add_parameter("{{ file }}", path) .add_parameter("{{ size }}", size_as_string) .add_parameter("{{ limit }}", limit_as_string) .add_parameter("{{ suffix }}", suffix) .add_parameter("{{ name }}", base_name) .add return end if mime_types = constraint.mime_types mime = if value.is_a? Athena::HTTP::AbstractFile value.mime_type else AMIME::Types.default.guess_mime_type path end if mime mime_types.each do |mime_type| return if mime == mime_type t, matched, _ = mime_type.partition "/*" unless matched.blank? t2, _, _ = mime.partition "/" return if t2 == t end end end self .context .build_violation(constraint.mime_type_message, INVALID_MIME_TYPE_ERROR) .add_parameter("{{ file }}", path) .add_parameter("{{ type }}", mime) .add_parameter("{{ types }}", mime_types) .add_parameter("{{ name }}", base_name) .add end end private def more_decimals_than(double : String, number_of_decimals : Int) : Bool double.size > double.to_f.round(2).to_s.size end # TODO: Can we use `#humaize_bytes` for this? def factorize_sizes(size : Int, limit : Int, binary_format : Bool) : Tuple(String, String, String) coef, coef_factor = binary_format ? {MIB_BYTES, KIB_BYTES} : {MB_BYTES, KB_BYTES} # If limit < coef, limit_as_string could be < 1 with less than 3 decimals. # In this case, we would end up displaying an allowed size < 1 (eg: 0.1 MB). # It looks better to keep on factorizing (to display 100 kB for example). while limit < coef coef /= coef_factor end limit_as_string = (limit / coef).to_s while self.more_decimals_than limit_as_string, 2 coef /= coef_factor limit_as_string = (limit / coef).to_s end size_as_string = (size / coef).round(2).to_s while size_as_string == limit_as_string coef /= coef_factor limit_as_string = (limit / coef).to_s size_as_string = (size / coef).round(2).to_s end {size_as_string, limit_as_string, SUFFICES[coef]} end end end ================================================ FILE: src/components/validator/src/constraints/greater_than.cr ================================================ # Validates that a value is greater than another. # # ``` # class Person # include AVD::Validatable # # def initialize(@age : Int64); end # # @[Assert::GreaterThan(18)] # property age : Int64 # end # ``` # # # Configuration # # ## Required Arguments # # ### value # # **Type:** `Number | String | Time` # # Defines the value that the value being validated should be compared to. # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be greater than {{ compared_value }}.` # # The message that will be shown if the value is not greater than the comparison value. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ compared_value }}` - The expected value. # * `{{ compared_value_type }}` - The type of the expected value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::GreaterThan(ValueType) < Athena::Validator::Constraint include Athena::Validator::Constraints::AbstractComparison(ValueType) TOO_LOW_ERROR = "a221096d-d125-44e8-a865-4270379ac11a" @@error_names = { TOO_LOW_ERROR => "TOO_LOW_ERROR", } def default_error_message : String "This value should be greater than {{ compared_value }}." end class Validator < Athena::Validator::Constraints::ComparisonValidator def compare_values(actual : Number, expected : Number) : Bool actual > expected end def compare_values(actual : String, expected : String) : Bool actual > expected end def compare_values(actual : Time, expected : Time) : Bool actual > expected end # :inherit: def compare_values(actual : _, expected : _) : NoReturn # TODO: Support checking if arbitrarily typed values are actually comparable once `#responds_to?` supports it. self.raise_invalid_type actual, "Number | String | Time" end # :inherit: def error_code : String TOO_LOW_ERROR end end end ================================================ FILE: src/components/validator/src/constraints/greater_than_or_equal.cr ================================================ # Validates that a value is greater than or equal to another. # # ``` # class Person # include AVD::Validatable # # def initialize(@age : Int64); end # # @[Assert::GreaterThanOrEqual(18)] # property age : Int64 # end # ``` # # # Configuration # # ## Required Arguments # # ### value # # **Type:** `Number | String | Time` # # Defines the value that the value being validated should be compared to. # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be greater than or equal to {{ compared_value }}.` # # The message that will be shown if the value is not greater than or equal to the comparison value. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ compared_value }}` - The expected value. # * `{{ compared_value_type }}` - The type of the expected value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::GreaterThanOrEqual(ValueType) < Athena::Validator::Constraint include Athena::Validator::Constraints::AbstractComparison(ValueType) TOO_LOW_ERROR = "e09e52d0-b549-4ba1-8b4e-420aad76f0de" @@error_names = { TOO_LOW_ERROR => "TOO_LOW_ERROR", } def default_error_message : String "This value should be greater than or equal to {{ compared_value }}." end class Validator < Athena::Validator::Constraints::ComparisonValidator def compare_values(actual : Number, expected : Number) : Bool actual >= expected end def compare_values(actual : String, expected : String) : Bool actual >= expected end def compare_values(actual : Time, expected : Time) : Bool actual >= expected end # :inherit: def compare_values(actual : _, expected : _) : NoReturn # TODO: Support checking if arbitrarily typed values are actually comparable once `#responds_to?` supports it. self.raise_invalid_type actual, "Number | String | Time" end # :inherit: def error_code : String TOO_LOW_ERROR end end end ================================================ FILE: src/components/validator/src/constraints/group_sequence.cr ================================================ # :nodoc: annotation Athena::Validator::Annotations::GroupSequence; end # Allows validating your `AVD::Constraint@validation-groups` in steps. # I.e. only continue to the next group if all constraints in the first group are valid. # # ``` # @[Assert::GroupSequence("User", "strict")] # class User # include AVD::Validatable # # @[Assert::NotBlank] # property name : String # # @[Assert::NotBlank] # property password : String # # def initialize(@name : String, @password : String); end # # @[Assert::IsTrue(message: "Your password cannot be the same as your name.", groups: "strict")] # def is_safe_password? : Bool # @name != @password # end # end # ``` # # In this case, it'll validate the `name` and `password` properties are not blank before validating they are not the same. # If either property is blank, the `is_safe_password?` validation will be skipped. # # NOTE: The `default` group is not allowed as part of a group sequence. # # NOTE: Calling `validate` with a group in the sequence, such as `strict`, will # cause violations to _ONLY_ use that group and not all groups within the sequence. # This is because the group sequence is now referred to as the `default` group. # # See `AVD::Constraints::GroupSequence::Provider` for a way to dynamically determine the sequence an object should use. struct Athena::Validator::Constraints::GroupSequence getter groups : Array(String | Array(String)) def self.new(groups : Array(String)) new groups.map &.as(String | Array(String)) end def initialize(@groups : Array(String | Array(String))); end # `AVD::Constraints::GroupSequence`s can be a good way to create efficient validations. # However, since the sequence is static, it is not a very flexible solution. # # Group sequence providers allow the sequence to be dynamically determined at runtime. # This allows running specific validations only when the object is in a specific state, # such as validating a "registered" user differently than a non-registered user. # # ``` # class User # include AVD::Validatable # # # Include the interface that informs the validator this object will provide its sequence. # include AVD::Constraints::GroupSequence::Provider # # @[Assert::NotBlank] # property name : String # # # Only validate the `email` property if the `#group_sequence` method includes "registered" # # Which can be determined using the current state of the object. # @[Assert::Email(groups: "registered")] # @[Assert::NotBlank(groups: "registered")] # property email : String? # # def initialize(@name : String, @email : String); end # # # Define a method that returns the sequence. # def group_sequence : Array(String | Array(String)) | AVD::Constraints::GroupSequence # # When returning a 1D array, if there is a vaiolation in any group # # the rest of the groups are not validated. E.g. if `User` fails, # # `registered` and `api` are not validated: # return ["User", "registered", "api"] # # # When returning a nested array, all groups included in each array are validated. # # E.g. if `User` fails, `Premium` is also validated (and you'll get its violations), # # but `api` will not be validated # return [["User", "registered"], "api"] # end # end # ``` # # See `AVD::Constraints::Sequentially` for a more straightforward method of applying constraints sequentially on a single property. module Provider abstract def group_sequence : Array(String | Array(String)) | AVD::Constraints::GroupSequence end end ================================================ FILE: src/components/validator/src/constraints/image.cr ================================================ require "athena-image_size" # An extension of `AVD::Constraints::File` whose `AVD::Constraints::File#mime_types` and `AVD::Constraints::File#mime_type_message` are setup to specifically handle image files. # This constraint also provides the ability to validate against various image specific parameters. # # See `AVD::Constraints::File` for common documentation. # # ``` # class Profile # include AVD::Validatable # # def initialize(@avatar : ::File); end # # @[Assert::Image] # property avatar : ::File # end # ``` # # # Configuration # # ## Optional Arguments # # ### mime_types # # **Type:** `Enumerable(String)?` **Default:** `{"image/*"}` # # Requires the file to have a valid image MIME type. # See [IANA website](https://www.iana.org/assignments/media-types/media-types.xhtml) for the full listing. # # ### mime_type_message # # **Type:** `String` **Default:** `This file is not a valid image.` # # The message that will be shown if the file is not an image. # # ### min_height # # **Type:** `Int32` **Default:** `nil` # # If set, the image's height in pixels must be greater than or equal to this value. # # ### min_height_message # # **Type:** `String` **Default:** `The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px.` # # The message that will be shown if the height of the image is less than `#min_height`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ height }}` - The current (invalid) height. # * `{{ min_height }}` - The minimum required height. # # ### max_height # # **Type:** `Int32` **Default:** `nil` # # If set, the image's height in pixels must be less than or equal to this value. # # ### max_height_message # # **Type:** `String` **Default:** `The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px.` # # The message that will be shown if the height of the image exceeds `#max_height`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ height }}` - The current (invalid) height. # * `{{ max_height }}` - The maximum allowed height. # # ### min_width # # **Type:** `Int32` **Default:** `nil` # # If set, the image's width in pixels must be greater than or equal to this value. # # ### min_width_message # # **Type:** `String` **Default:** `The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px.` # # The message that will be shown if the width of the image is less than `#min_width`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ width }}` - The current (invalid) width. # * `{{ min_width }}` - The minimum required width. # # ### max_width # # **Type:** `Int32` **Default:** `nil` # # If set, the image's width in pixels must be less than or equal to this value. # # ### max_width_message # # **Type:** `String` **Default:** `The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px.` # # The message that will be shown if the width of the image exceeds `#max_width`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ width }}` - The current (invalid) width. # * `{{ max_width }}` - The maximum allowed width. # # ### size_not_detected_message # # **Type:** `String` **Default:** `The size of the image could not be detected.` # # The message that will be shown if the size of the image is unable to be determined. # Will only occur if at least one of the size related options has been set. # # ### min_ratio # # **Type:** `Float64` **Default:** `nil` # # If set, the image's aspect ratio (`width / height`) must be greater than or equal to this value. # # ### min_ratio_message # # **Type:** `String` **Default:** `The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}.` # # The message that will be shown if the aspect ratio of the image is less than `#min_ratio`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ ratio }}` - The current (invalid) ratio. # * `{{ min_ratio }}` - The minimum required ratio. # # ### max_ratio # # **Type:** `Float64` **Default:** `nil` # # If set, the image's aspect ratio (`width / height`) must be less than or equal to this value. # # ### max_ratio_message # # **Type:** `String` **Default:** `The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}.` # # The message that will be shown if the aspect ratio of the image exceeds `#max_ratio`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ ratio }}` - The current (invalid) ratio. # * `{{ max_ratio }}` - The maximum allowed ratio. # # ### min_pixels # # **Type:** `Float64` **Default:** `nil` # # If set, the amount of pixels of the image file must be greater than or equal to this value. # # ### min_pixels_message # # **Type:** `String` **Default:** `The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels.` # # The message that will be shown if the amount of pixels of the image is less than `#min_pixels`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ height }}` - The image's height. # * `{{ width }}` - The image's width. # * `{{ pixels }}` - The image's pixels. # * `{{ min_pixels }}` - The minimum required pixels. # # ### max_pixels # # **Type:** `Float64` **Default:** `nil` # # If set, the amount of pixels of the image file must be less than or equal to this value. # # ### max_pixels_message # # **Type:** `String` **Default:** `The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels.` # # The message that will be shown if the amount of pixels of the image is greater than `#max_pixels`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ height }}` - The image's height. # * `{{ width }}` - The image's width. # * `{{ pixels }}` - The image's pixels. # * `{{ max_pixels }}` - The maximum allowed pixels. # # ### allow_landscape # # **Type:** `Bool` **Default:** `true` # # If `false`, the image cannot be landscape oriented. # # ### allow_landscape_message # # **Type:** `String` **Default:** `The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed.` # # The message that will be shown if the `#allow_landscape` is `false` and the image is landscape oriented. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ height }}` - The image's height. # * `{{ width }}` - The image's width. # # ### allow_portrait # # **Type:** `Bool` **Default:** `true` # # If `false`, the image cannot be portrait oriented. # # ### allow_portrait_message # # **Type:** `String` **Default:** `The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed.` # # The message that will be shown if the `#allow_portrait` is `false` and the image is portrait oriented. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ height }}` - The image's height. # * `{{ width }}` - The image's width. # # ### allow_square # # **Type:** `Bool` **Default:** `true` # # If `false`, the image cannot be a square. # If you want to force the image to be a square, keep this as is and set `#allow_landscape` and `#allow_portrait` to `false`. # # ### allow_square_message # # **Type:** `String` **Default:** `The image is square ({{ width }}x{{ height }}px). Square images are not allowed.` # # The message that will be shown if the `#allow_square` is `false` and the image is square. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ height }}` - The image's height. # * `{{ width }}` - The image's width. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Image < Athena::Validator::Constraints::File SIZE_NOT_DETECTED_ERROR = "6d55c3f4-e58e-4fe3-91ee-74b492199956" TOO_WIDE_ERROR = "7f87163d-878f-47f5-99ba-a8eb723a1ab2" TOO_NARROW_ERROR = "9afbd561-4f90-4a27-be62-1780fc43604a" TOO_HIGH_ERROR = "7efae81c-4877-47ba-aa65-d01ccb0d4645" TOO_LOW_ERROR = "aef0cb6a-c07f-4894-bc08-1781420d7b4c" TOO_FEW_PIXEL_ERROR = "1b06b97d-ae48-474e-978f-038a74854c43" TOO_MANY_PIXEL_ERROR = "ee0804e8-44db-4eac-9775-be91aaf72ce1" RATIO_TOO_BIG_ERROR = "70cafca6-168f-41c9-8c8c-4e47a52be643" RATIO_TOO_SMALL_ERROR = "59b8c6ef-bcf2-4ceb-afff-4642ed92f12e" SQUARE_NOT_ALLOWED_ERROR = "5d41425b-facb-47f7-a55a-de9fbe45cb46" LANDSCAPE_NOT_ALLOWED_ERROR = "6f895685-7cf2-4d65-b3da-9029c5581d88" PORTRAIT_NOT_ALLOWED_ERROR = "65608156-77da-4c79-a88c-02ef6d18c782" CORRUPTED_IMAGE_ERROR = "5d4163f3-648f-4e39-87fd-cc5ea7aad2d1" @@error_names = { AVD::Constraints::File::NOT_FOUND_ERROR => "NOT_FOUND_ERROR", AVD::Constraints::File::NOT_READABLE_ERROR => "NOT_READABLE_ERROR", AVD::Constraints::File::EMPTY_ERROR => "EMPTY_ERROR", AVD::Constraints::File::TOO_LARGE_ERROR => "TOO_LARGE_ERROR", AVD::Constraints::File::INVALID_MIME_TYPE_ERROR => "INVALID_MIME_TYPE_ERROR", SIZE_NOT_DETECTED_ERROR => "SIZE_NOT_DETECTED_ERROR", TOO_WIDE_ERROR => "TOO_WIDE_ERROR", TOO_NARROW_ERROR => "TOO_NARROW_ERROR", TOO_HIGH_ERROR => "TOO_HIGH_ERROR", TOO_LOW_ERROR => "TOO_LOW_ERROR", TOO_FEW_PIXEL_ERROR => "TOO_FEW_PIXEL_ERROR", TOO_MANY_PIXEL_ERROR => "TOO_MANY_PIXEL_ERROR", RATIO_TOO_BIG_ERROR => "RATIO_TOO_BIG_ERROR", RATIO_TOO_SMALL_ERROR => "RATIO_TOO_SMALL_ERROR", SQUARE_NOT_ALLOWED_ERROR => "SQUARE_NOT_ALLOWED_ERROR", LANDSCAPE_NOT_ALLOWED_ERROR => "LANDSCAPE_NOT_ALLOWED_ERROR", PORTRAIT_NOT_ALLOWED_ERROR => "PORTRAIT_NOT_ALLOWED_ERROR", CORRUPTED_IMAGE_ERROR => "CORRUPTED_IMAGE_ERROR", } getter min_width : Int32? getter max_width : Int32? getter min_height : Int32? getter max_height : Int32? getter min_ratio : Float64? getter max_ratio : Float64? getter min_pixels : Float64? getter max_pixels : Float64? getter? allow_square : Bool getter? allow_landscape : Bool getter? allow_portrait : Bool getter size_not_detected_message : String getter min_width_message : String getter max_width_message : String getter min_height_message : String getter max_height_message : String getter min_pixels_message : String getter max_pixels_message : String getter min_ratio_message : String getter max_ratio_message : String getter allow_square_message : String getter allow_landscape_message : String getter allow_portrait_message : String def initialize( @min_width : Int32? = nil, @max_width : Int32? = nil, @min_height : Int32? = nil, @max_height : Int32? = nil, @min_ratio : Float64? = nil, @max_ratio : Float64? = nil, @min_pixels : Float64? = nil, @max_pixels : Float64? = nil, @allow_square : Bool = true, @allow_landscape : Bool = true, @allow_portrait : Bool = true, @size_not_detected_message : String = "The size of the image could not be detected.", @min_width_message : String = "The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px.", @max_width_message : String = "The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px.", @min_height_message : String = "The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px.", @max_height_message : String = "The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px.", @min_pixels_message : String = "The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels.", @max_pixels_message : String = "The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels.", @min_ratio_message : String = "The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}.", @max_ratio_message : String = "The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}.", @allow_square_message : String = "The image is square ({{ width }}x{{ height }}px). Square images are not allowed.", @allow_landscape_message : String = "The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed.", @allow_portrait_message : String = "The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed.", max_size : Int | String | Nil = nil, binary_format : Bool? = nil, mime_types : Enumerable(String)? = {"image/*"}, not_found_message : String = "The file could not be found.", not_readable_message : String = "The file is not readable.", empty_message : String = "An empty file is not allowed.", max_size_message : String = "The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}.", mime_type_message : String = "This file is not a valid image.", upload_file_size_message : String = "The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super max_size, binary_format, mime_types, not_found_message, not_readable_message, empty_message, max_size_message, mime_type_message, upload_file_size_message, groups, payload # Use the default message `File` uses when only specific mime types are shown such that it renders the valid types. # OPTIMIZE: Figure out a better way to know if the message has been customized. if !mime_types.includes?("image/*") && mime_type_message == "This file is not a valid image." @mime_type_message = "The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}." end end class Validator < Athena::Validator::Constraints::File::Validator # :inherit: def validate(value : _, constraint : AVD::Constraints::Image) : Nil violations = self.context.violations.size super failed = self.context.violations.size != violations return if failed || value.nil? || value == "" # Return early is no extra validation is being applied. return if { constraint.min_width, constraint.max_width, constraint.min_height, constraint.max_height, constraint.min_pixels, constraint.max_pixels, constraint.min_ratio, constraint.max_ratio, !constraint.allow_square?, !constraint.allow_landscape?, !constraint.allow_portrait?, }.none? path = case value when Path then value when ::File then value.path else value.to_s end image_size = AIS::Image.from_file_path? path if image_size.nil? || image_size.width.zero? || image_size.height.zero? self .context .add_violation(constraint.size_not_detected_message, SIZE_NOT_DETECTED_ERROR) return end self.validate_size image_size, constraint self.validate_pixels image_size, constraint self.validate_ratios image_size, constraint self.validate_shape image_size, constraint # TODO: Somehow check if image is actually valid? end private def validate_size(image_size : AIS::Image, constraint : AVD::Constraints::Image) : Nil if (min_width = constraint.min_width) && (image_size.width < min_width) self .context .build_violation(constraint.min_width_message, TOO_NARROW_ERROR) .add_parameter("{{ width }}", image_size.width) .add_parameter("{{ min_width }}", constraint.min_width) .add end if (max_width = constraint.max_width) && (image_size.width > max_width) self .context .build_violation(constraint.max_width_message, TOO_WIDE_ERROR) .add_parameter("{{ width }}", image_size.width) .add_parameter("{{ max_width }}", constraint.max_width) .add end if (min_height = constraint.min_height) && (image_size.height < min_height) self .context .build_violation(constraint.min_height_message, TOO_LOW_ERROR) .add_parameter("{{ height }}", image_size.height) .add_parameter("{{ min_height }}", constraint.min_height) .add end if (max_height = constraint.max_height) && (image_size.height > max_height) self .context .build_violation(constraint.max_height_message, TOO_HIGH_ERROR) .add_parameter("{{ height }}", image_size.height) .add_parameter("{{ max_height }}", constraint.max_height) .add end end private def validate_pixels(image_size : AIS::Image, constraint : AVD::Constraints::Image) : Nil pixels = image_size.width * image_size.height if (min_pixels = constraint.min_pixels) && (pixels < min_pixels) self .context .build_violation(constraint.min_pixels_message, TOO_FEW_PIXEL_ERROR) .add_parameter("{{ pixels }}", pixels) .add_parameter("{{ min_pixels }}", min_pixels) .add_parameter("{{ width }}", image_size.width) .add_parameter("{{ height }}", image_size.height) .add end if (max_pixels = constraint.max_pixels) && (pixels > max_pixels) self .context .build_violation(constraint.max_pixels_message, TOO_MANY_PIXEL_ERROR) .add_parameter("{{ pixels }}", pixels) .add_parameter("{{ max_pixels }}", max_pixels) .add_parameter("{{ width }}", image_size.width) .add_parameter("{{ height }}", image_size.height) .add end end private def validate_ratios(image_size : AIS::Image, constraint : AVD::Constraints::Image) : Nil ratio = (image_size.width / image_size.height).round 2, mode: :ties_away if (min_ratio = constraint.min_ratio) && (ratio < min_ratio.round(2, mode: :ties_away)) self .context .build_violation(constraint.min_ratio_message, RATIO_TOO_SMALL_ERROR) .add_parameter("{{ ratio }}", ratio) .add_parameter("{{ min_ratio }}", min_ratio) .add end if (max_ratio = constraint.max_ratio) && (ratio > max_ratio.round(2, mode: :ties_away)) self .context .build_violation(constraint.max_ratio_message, RATIO_TOO_BIG_ERROR) .add_parameter("{{ ratio }}", ratio) .add_parameter("{{ max_ratio }}", max_ratio) .add end end private def validate_shape(image_size : AIS::Image, constraint : AVD::Constraints::Image) : Nil if !constraint.allow_square? && image_size.width == image_size.height self .context .build_violation(constraint.allow_square_message, SQUARE_NOT_ALLOWED_ERROR) .add_parameter("{{ width }}", image_size.width) .add_parameter("{{ height }}", image_size.height) .add end if !constraint.allow_landscape? && image_size.width > image_size.height self .context .build_violation(constraint.allow_landscape_message, LANDSCAPE_NOT_ALLOWED_ERROR) .add_parameter("{{ width }}", image_size.width) .add_parameter("{{ height }}", image_size.height) .add end if !constraint.allow_portrait? && image_size.width < image_size.height self .context .build_violation(constraint.allow_portrait_message, PORTRAIT_NOT_ALLOWED_ERROR) .add_parameter("{{ width }}", image_size.width) .add_parameter("{{ height }}", image_size.height) .add end end end end ================================================ FILE: src/components/validator/src/constraints/ip.cr ================================================ require "socket" # Validates that a value is a valid IP address. # By default validates the value as an `IPv4` address, but can be customized to validate `IPv6`s, or both. # The underlying value is converted to a string via `#to_s` before being validated. # # NOTE: As with most other constraints, `nil` and empty strings are considered valid values, in order to allow the value to be optional. # If the value is required, consider combining this constraint with `AVD::Constraints::NotBlank`. # # ``` # class Machine # include AVD::Validatable # # def initialize(@ip_address : String); end # # @[Assert::IP] # property ip_address : String # end # ``` # # # Configuration # # ## Optional Arguments # # ### version # # **Type:** `AVD::Constraints::IP::Version` **Default:** `AVD::Constraints::IP::Version::V4` # # Defines the pattern that should be used to validate the IP address. # # ### message # # **Type:** `String` **Default:** `This is not a valid IP address.` # # The message that will be shown if the value is not a valid IP address. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::IP < Athena::Validator::Constraint # Determines _how_ the IP address should be validated. enum Version # Validates for `IPv4` addresses. V4 # Validates for `IPv6` addresses. V6 # Validates for `IPv4` or `IPv6` addresses. V4_V6 end INVALID_IP_ERROR = "326b0aa4-3871-404d-986d-fe3e6c82005c" @@error_names = { INVALID_IP_ERROR => "INVALID_IP_ERROR", } getter version : AVD::Constraints::IP::Version def initialize( @version : AVD::Constraints::IP::Version = :v4, message : String = "This value is not a valid IP address.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::IP) : Nil value = value.to_s return if value.nil? || value.empty? case constraint.version in .v4? then return if Socket::IPAddress.valid_v4? value in .v6? then return if Socket::IPAddress.valid_v6? value in .v4_v6? then return if Socket::IPAddress.valid? value end self.context.add_violation constraint.message, INVALID_IP_ERROR, value end end end ================================================ FILE: src/components/validator/src/constraints/is_false.cr ================================================ # Validates that a value is `false`. # # ``` # class Post # include AVD::Validatable # # def initialize(@is_published : Bool); end # # @[Assert::IsFalse] # property is_published : Bool # end # ``` # # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be false.` # # The message that will be shown if the value is not `false`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::IsFalse < Athena::Validator::Constraint NOT_FALSE_ERROR = "55c076a0-dbaf-453c-90cf-b94664276dbc" @@error_names = { NOT_FALSE_ERROR => "NOT_FALSE_ERROR", } def initialize( message : String = "This value should be false.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::IsFalse) : Nil return if value.nil? || value == false self.context.add_violation constraint.message, NOT_FALSE_ERROR, value end end end ================================================ FILE: src/components/validator/src/constraints/is_nil.cr ================================================ # Validates that a value is `nil`. # # ``` # class Post # include AVD::Validatable # # def initialize(@updated_at : Time?); end # # @[Assert::IsNil] # property updated_at : Time? # end # ``` # # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be null.` # # The message that will be shown if the value is not `nil`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::IsNil < Athena::Validator::Constraint NOT_NIL_ERROR = "2c88e3c7-9275-4b9b-81b4-48c6c44b1804" @@error_names = { NOT_NIL_ERROR => "NOT_NIL_ERROR", } def initialize( message : String = "This value should be null.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::IsNil) : Nil return if value.nil? self.context.add_violation constraint.message, NOT_NIL_ERROR, value end end end ================================================ FILE: src/components/validator/src/constraints/is_true.cr ================================================ # Validates that a value is `true`. # # ``` # class Post # include AVD::Validatable # # def initialize(@is_published : Bool); end # # @[Assert::IsTrue] # property is_published : Bool # end # ``` # # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be true.` # # The message that will be shown if the value is not `true`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::IsTrue < Athena::Validator::Constraint NOT_TRUE_ERROR = "beabd93e-3673-4dfc-8796-01bd1504dd19" @@error_names = { NOT_TRUE_ERROR => "NOT_TRUE_ERROR", } def initialize( message : String = "This value should be true.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::IsTrue) : Nil return if value.nil? || value == true self.context.add_violation constraint.message, NOT_TRUE_ERROR, value end end end ================================================ FILE: src/components/validator/src/constraints/isbn.cr ================================================ # Validates that an [International Standard Book Number (ISBN)](https://en.wikipedia.org/wiki/Isbn) is either a valid `ISBN-10` or `ISBN-13`. # The underlying value is converted to a string via `#to_s` before being validated. # # NOTE: As with most other constraints, `nil` and empty strings are considered valid values, in order to allow the value to be optional. # If the value is required, consider combining this constraint with `AVD::Constraints::NotBlank`. # # ``` # class Book # include AVD::Validatable # # def initialize(@isbn : String); end # # @[Assert::ISBN] # property isbn : String # end # ``` # # # Configuration # # ## Optional Arguments # # ### type # # **Type:** `AVD::Constraints::ISBN::Type` **Default:** `AVD::Constraints::ISBN::Type::Both` # # Type of ISBN to validate against. # # ### message # # **Type:** `String` **Default:** `""` # # The message that will be shown if the value is invalid. # This message has priority over the other messages if not empty. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### isbn10_message # # **Type:** `String` **Default:** `This value is not a valid ISBN-10.` # # The message that will be shown if [type](#type) is `AVD::Constraints::ISBN::Type::ISBN10` and the value is invalid. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### isbn13_message # # **Type:** `String` **Default:** `This value is not a valid ISBN-13.` # # The message that will be shown if [type](#type) is `AVD::Constraints::ISBN::Type::ISBN13` and the value is invalid. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### both_message # # **Type:** `String` **Default:** `This value is neither a valid ISBN-10 nor a valid ISBN-13.` # # The message that will be shown if [type](#type) is `AVD::Constraints::ISBN::Type::Both` and the value is invalid. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::ISBN < Athena::Validator::Constraint enum Type ISBN10 ISBN13 Both def message(constraint : AVD::Constraints::ISBN) : String case self in .isbn10? then constraint.isbn10_message in .isbn13? then constraint.isbn13_message in .both? then constraint.both_message end end end TOO_SHORT_ERROR = "5da9e91f-7956-40f7-9788-4124463d783e" TOO_LONG_ERROR = "ebd28c75-bb42-43d6-9053-f0ea2ea93d44" INVALID_CHARACTERS_ERROR = "25d35907-d822-4bcc-82cc-852e30c89c0d" CHECKSUM_FAILED_ERROR = "f51bae62-6833-43b1-bc27-ae4445c59e30" TYPE_NOT_RECOGNIZED_ERROR = "8d83f04d-2503-4547-97a1-935fcccd1ae1" @@error_names = { TOO_SHORT_ERROR => "TOO_SHORT_ERROR", TOO_LONG_ERROR => "TOO_LONG_ERROR", INVALID_CHARACTERS_ERROR => "INVALID_CHARACTERS_ERROR", CHECKSUM_FAILED_ERROR => "CHECKSUM_FAILED_ERROR", TYPE_NOT_RECOGNIZED_ERROR => "TYPE_NOT_RECOGNIZED_ERROR", } getter type : AVD::Constraints::ISBN::Type getter isbn10_message : String getter isbn13_message : String getter both_message : String def initialize( @type : AVD::Constraints::ISBN::Type = :both, @isbn10_message : String = "This value is not a valid ISBN-10.", @isbn13_message : String = "This value is not a valid ISBN-13.", @both_message : String = "This value is neither a valid ISBN-10 nor a valid ISBN-13.", message : String = "", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end def message : String return @message unless @message.empty? @type.message self end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::ISBN) : Nil value = value.to_s return if value.nil? || value.empty? canonical = value.gsub '-', "" code = case constraint.type in .isbn10? then self.validate_isbn10 canonical in .isbn13? then self.validate_isbn13 canonical in .both? both_code = self.validate_isbn10 canonical if TOO_LONG_ERROR == both_code both_code = self.validate_isbn13 canonical if TOO_SHORT_ERROR == both_code both_code = TYPE_NOT_RECOGNIZED_ERROR end end both_code end return if code.nil? self.context.add_violation constraint.message, code, value end private def validate_isbn10(isbn : String) : String? checksum = 0 10.times do |idx| char = isbn.char_at(idx) { return TOO_SHORT_ERROR } digit = case char when 'X' then 10 when .number? then char.to_i else return INVALID_CHARACTERS_ERROR end checksum += digit * (10 - idx) end return TOO_LONG_ERROR unless isbn[10]?.nil? checksum.divisible_by?(11) ? nil : CHECKSUM_FAILED_ERROR end private def validate_isbn13(isbn : String) : String? return INVALID_CHARACTERS_ERROR unless isbn.each_char.all? &.number? case isbn.size when .< 13 then return TOO_SHORT_ERROR when .> 13 then return TOO_LONG_ERROR end checksum = 0 0.step(to: 12, by: 2) do |idx| checksum += isbn[idx].to_i end 1.step(to: 12, by: 2) do |idx| checksum += isbn[idx].to_i * 3 end checksum.divisible_by?(10) ? nil : CHECKSUM_FAILED_ERROR end end end ================================================ FILE: src/components/validator/src/constraints/isin.cr ================================================ # Validates that a value is a valid [International Securities Identification Number (ISIN)](https://en.wikipedia.org/wiki/International_Securities_Identification_Number). # The underlying value is converted to a string via `#to_s` before being validated. # # NOTE: As with most other constraints, `nil` and empty strings are considered valid values, in order to allow the value to be optional. # If the value is required, consider combining this constraint with `AVD::Constraints::NotBlank`. # # ``` # class UnitAccount # include AVD::Validatable # # def initialize(@isin : String); end # # @[Assert::ISIN] # property isin : String # end # ``` # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value is not a valid International Securities Identification Number (ISIN).` # # The message that will be shown if the value is not a valid ISIN. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::ISIN < Athena::Validator::Constraint INVALID_LENGTH_ERROR = "1d1c3fbe-5b6f-42be-afa5-6840655865da" INVALID_PATTERN_ERROR = "0b6ba8c4-b6aa-44dc-afac-a6f7a9a2556d" INVALID_CHECKSUM_ERROR = "c7d37ffb-0273-4f57-91f7-f47bf49aad08" private VALIDATION_LENGTH = 12 private VALIDATION_PATTERN = /[A-Z]{2}[A-Z0-9]{9}[0-9]{1}/ @@error_names = { INVALID_LENGTH_ERROR => "INVALID_LENGTH_ERROR", INVALID_PATTERN_ERROR => "INVALID_PATTERN_ERROR", INVALID_CHECKSUM_ERROR => "INVALID_CHECKSUM_ERROR", } def initialize( message : String = "This value is not a valid International Securities Identification Number (ISIN).", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::ISIN) : Nil value = value.to_s return if value.nil? || value.empty? value = value.upcase if VALIDATION_LENGTH != value.size return self.context.add_violation constraint.message, INVALID_LENGTH_ERROR, value end unless value.matches? VALIDATION_PATTERN return self.context.add_violation constraint.message, INVALID_PATTERN_ERROR, value end return if self.correct_checksum? value self.context.add_violation constraint.message, INVALID_CHECKSUM_ERROR, value end private def correct_checksum?(isin : String) : Bool number = isin.chars.join &.to_i 36 self.context.validator.validate(number, AVD::Constraints::Luhn.new).empty? end end end ================================================ FILE: src/components/validator/src/constraints/issn.cr ================================================ # Validates that a value is a valid [International Standard Serial Number (ISSN)](https://en.wikipedia.org/wiki/Issn). # The underlying value is converted to a string via `#to_s` before being validated. # # NOTE: As with most other constraints, `nil` and empty strings are considered valid values, in order to allow the value to be optional. # If the value is required, consider combining this constraint with `AVD::Constraints::NotBlank`. # # ``` # class Journal # include AVD::Validatable # # def initialize(@issn : String); end # # @[Assert::ISSN] # property issn : String # end # ``` # # # Configuration # # ## Optional Arguments # # ### case_sensitive # # **Type:** `Bool` **Default:** `false` # # The validator will allow ISSN values to end with a lowercase `x` by default. # When set to `true`, this requires an uppcase case `X`. # # ### require_hyphen # # **Type:** `Bool` **Default:** `false` # # The validator will allow non hyphenated values by default. # When set to `true`, this requires a hyphenated ISSN value. # # ### message # # **Type:** `String` **Default:** `This value is not a valid International Standard Serial Number (ISSN).` # # The message that will be shown if the value is not a valid ISSN. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::ISSN < Athena::Validator::Constraint TOO_SHORT_ERROR = "85c5d3aa-fd0a-4cd0-8cf7-e014e6379d59" TOO_LONG_ERROR = "fab8e3ea-2f77-4da7-b40f-d9b24ff8c0cc" MISSING_HYPHEN_ERROR = "d6c120a9-0b56-4e45-b4bc-7fd186f2cfbd" INVALID_CHARACTERS_ERROR = "85c5d3aa-fd0a-4cd0-8cf7-e014e6379d59" INVALID_CASE_ERROR = "66f892f3-9eed-4176-b823-0dafde72202a" CHECKSUM_FAILED_ERROR = "62c01bab-fe8f-4072-aac8-aa4bdcde8361" @@error_names = { TOO_SHORT_ERROR => "TOO_SHORT_ERROR", TOO_LONG_ERROR => "TOO_LONG_ERROR", MISSING_HYPHEN_ERROR => "MISSING_HYPHEN_ERROR", INVALID_CHARACTERS_ERROR => "INVALID_CHARACTERS_ERROR", INVALID_CASE_ERROR => "INVALID_CASE_ERROR", CHECKSUM_FAILED_ERROR => "CHECKSUM_FAILED_ERROR", } getter? case_sensitive : Bool getter? require_hyphen : Bool def initialize( @case_sensitive : Bool = false, @require_hyphen : Bool = false, message : String = "This value is not a valid International Standard Serial Number (ISSN).", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::ISSN) : Nil value = value.to_s return if value.nil? || value.empty? canonical = value if canonical[4]? == '-' canonical = canonical.delete '-' elsif constraint.require_hyphen? return self.context.add_violation constraint.message, MISSING_HYPHEN_ERROR, value end self.validate_size canonical do |code| return self.context.add_violation constraint.message, code, value end char = self.validate_characters canonical do return self.context.add_violation constraint.message, INVALID_CHARACTERS_ERROR, value end if constraint.case_sensitive? && char == 'x' return self.context.add_violation constraint.message, INVALID_CASE_ERROR, value end self.validate_checksum char, canonical do self.context.add_violation constraint.message, CHECKSUM_FAILED_ERROR, value end end private def validate_size(issn : String, & : String ->) : Nil yield TOO_SHORT_ERROR if issn.size < 8 yield TOO_LONG_ERROR if issn.size > 8 end private def validate_characters(issn : String, &) : Char yield unless issn[...7].each_char.all? &.number? yield if (char = issn[7]) && !char.number? && !char.in? 'x', 'X' char end private def validate_checksum(char : Char, issn : String, &) : Nil checksum = char.in?('x', 'X') ? 10 : char.to_i 7.times do |idx| checksum += (8 - idx) * issn[idx].to_i end yield unless checksum.divisible_by? 11 end end end ================================================ FILE: src/components/validator/src/constraints/length.cr ================================================ # Validates that the length of a `String` is between some minimum and maximum. # Non `String` values are stringified via `#to_s`. # # ``` # class User # include AVD::Validatable # # def initialize(@username : String); end # # @[Assert::Length(3..30)] # property username : String # end # ``` # # # Configuration # # ## Required Arguments # # ### range # # **Type:** `::Range` # # The `::Range` that defines the minimum and maximum values, if any. # An endless range can be used to only have a minimum or maximum. # # ## Optional Arguments # # NOTE: This constraint does not support a `message` argument. # # ### unit # # **Type:** `AVD::Constraints::Length::Unit` **Default:** `AVD::Constraints::Length::Unit::CODEPOINTS` # # Which unit should be used to determine the length of the string. # # ### exact_message # # **Type:** `String` **Default:** `This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters.` # # The message that will be shown if min and max values are equal and the underlying value’s length is not exactly this value. # The message is pluralized depending on how many elements/characters the underlying value has. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ limit }}` - The exact expected length. # * `{{ value }}` - The current (invalid) value. # * `{{ value_length }}` - The current value's length. # # ### min_message # # **Type:** `String` **Default:** `This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more.` # # The message that will be shown if the underlying value’s length is less than the min. # The message is pluralized depending on how many elements/characters the underlying value has. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ limit }}` - The exact minimum length. # * `{{ min }}` - The expected minimum length. # * `{{ max }}` - The expected maximum length. # * `{{ value }}` - The current (invalid) value. # * `{{ value_length }}` - The current value's length. # # ### max_message # # **Type:** `String` **Default:** `This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less.` # # The message that will be shown if the underlying value’s length is greater than the max. # The message is pluralized depending on how many elements/characters the underlying value has. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ limit }}` - The exact maximum length. # * `{{ min }}` - The expected minimum length. # * `{{ max }}` - The expected maximum length. # * `{{ value }}` - The current (invalid) value. # * `{{ value_length }}` - The current value's length. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Length < Athena::Validator::Constraint # The unit used for the length check, defaulting to `CODEPOINTS`. enum Unit # Uses [String#size](https://crystal-lang.org/api/String.html#size%3AInt32-instance-method) to return the number of Unicode codepoints. CODEPOINTS # Uses [String#bytesize](https://crystal-lang.org/api/String.html#bytesize%3AInt32-instance-method) to return the number of bytes. BYTES # Uses [String#grapheme_size](https://crystal-lang.org/api/String.html#grapheme_size%3AInt32-instance-method) to return the number of Unicode graphemes clusters. GRAPHEMES end TOO_SHORT_ERROR = "643f9d15-a5fd-41b7-b6d8-85f40855ba11" TOO_LONG_ERROR = "e07eee2c-be7a-4ac3-be6b-2ea344250f99" NOT_EQUAL_LENGTH_ERROR = "03ef6899-6e39-4e7a-9ac9-5f4374736273" @@error_names = { TOO_SHORT_ERROR => "TOO_SHORT_ERROR", TOO_LONG_ERROR => "TOO_LONG_ERROR", NOT_EQUAL_LENGTH_ERROR => "NOT_EQUAL_LENGTH_ERROR", } getter min : Int32? getter max : Int32? getter min_message : String getter max_message : String getter exact_message : String getter unit : AVD::Constraints::Length::Unit def self.new( range : ::Range, min_message : String = "This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more.", max_message : String = "This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less.", exact_message : String = "This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters.", unit : AVD::Constraints::Length::Unit = :codepoints, groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) new range.begin, range.end, min_message, max_message, exact_message, unit, groups, payload end private def initialize( @min : Int32?, @max : Int32?, @min_message : String = "This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more.", @max_message : String = "This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less.", @exact_message : String = "This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters.", @unit : AVD::Constraints::Length::Unit = :codepoints, groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super "", groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: # ameba:disable Metrics/CyclomaticComplexity def validate(value : _, constraint : AVD::Constraints::Length) : Nil return if value.nil? length = case constraint.unit in .codepoints? then value.to_s.size in .bytes? then value.to_s.bytesize in .graphemes? then value.to_s.grapheme_size end min = constraint.min max = constraint.max if max && length > max exactly_option_enabled = min == max builder = self .context .build_violation( exactly_option_enabled ? constraint.exact_message : constraint.max_message, exactly_option_enabled ? NOT_EQUAL_LENGTH_ERROR : TOO_LONG_ERROR, value ) if min builder.add_parameter("{{ min }}", min) end builder .add_parameter("{{ limit }}", max) .add_parameter("{{ max }}", max) .add_parameter("{{ value_length }}", length) .invalid_value(value) .plural(max) .add end if min && length < min exactly_option_enabled = min == max builder = self .context .build_violation( exactly_option_enabled ? constraint.exact_message : constraint.min_message, exactly_option_enabled ? NOT_EQUAL_LENGTH_ERROR : TOO_SHORT_ERROR, value ) if max builder.add_parameter("{{ max }}", max) end builder .add_parameter("{{ limit }}", min) .add_parameter("{{ min }}", min) .add_parameter("{{ value_length }}", length) .invalid_value(value) .plural(min) .add end end end end ================================================ FILE: src/components/validator/src/constraints/less_than.cr ================================================ # Validates that a value is less than another. # # ``` # class Employee # include AVD::Validatable # # def initialize(@age : Number); end # # @[Assert::LessThan(60)] # property age : Number # end # ``` # # # Configuration # # ## Required Arguments # # ### value # # **Type:** `Number | String | Time` # # Defines the value that the value being validated should be compared to. # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be less than {{ compared_value }}.` # # The message that will be shown if the value is not less than the comparison value. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ compared_value }}` - The expected value. # * `{{ compared_value_type }}` - The type of the expected value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::LessThan(ValueType) < Athena::Validator::Constraint include Athena::Validator::Constraints::AbstractComparison(ValueType) TOO_HIGH_ERROR = "d9fbedb3-c576-45b5-b4dc-996030349bbf" @@error_names = { TOO_HIGH_ERROR => "TOO_HIGH_ERROR", } def default_error_message : String "This value should be less than {{ compared_value }}." end class Validator < Athena::Validator::Constraints::ComparisonValidator def compare_values(actual : Number, expected : Number) : Bool actual < expected end def compare_values(actual : String, expected : String) : Bool actual < expected end def compare_values(actual : Time, expected : Time) : Bool actual < expected end # :inherit: def compare_values(actual : _, expected : _) : NoReturn # TODO: Support checking if arbitrarily typed values are actually comparable once `#responds_to?` supports it. self.raise_invalid_type actual, "Number | String | Time" end # :inherit: def error_code : String TOO_HIGH_ERROR end end end ================================================ FILE: src/components/validator/src/constraints/less_than_or_equal.cr ================================================ # Validates that a value is less than or equal to another. # # ``` # class Employee # include AVD::Validatable # # def initialize(@age : Number); end # # @[Assert::LessThanOrEqual(60)] # property age : Number # end # ``` # # # Configuration # # ## Required Arguments # # ### value # # **Type:** `Number | String | Time` # # Defines the value that the value being validated should be compared to. # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be less than or equal to {{ compared_value }}.` # # The message that will be shown if the value is not less than or equal to the comparison value. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ compared_value }}` - The expected value. # * `{{ compared_value_type }}` - The type of the expected value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::LessThanOrEqual(ValueType) < Athena::Validator::Constraint include Athena::Validator::Constraints::AbstractComparison(ValueType) TOO_HIGH_ERROR = "515a12ff-82f2-4434-9635-137164d5b467" @@error_names = { TOO_HIGH_ERROR => "TOO_HIGH_ERROR", } def default_error_message : String "This value should be less than or equal to {{ compared_value }}." end class Validator < Athena::Validator::Constraints::ComparisonValidator def compare_values(actual : Number, expected : Number) : Bool actual <= expected end def compare_values(actual : String, expected : String) : Bool actual <= expected end def compare_values(actual : Time, expected : Time) : Bool actual <= expected end # :inherit: def compare_values(actual : _, expected : _) : NoReturn # TODO: Support checking if arbitrarily typed values are actually comparable once `#responds_to?` supports it. self.raise_invalid_type actual, "Number | String | Time" end # :inherit: def error_code : String TOO_HIGH_ERROR end end end ================================================ FILE: src/components/validator/src/constraints/luhn.cr ================================================ # Validates that a credit card number passes the [Luhn algorithm](https://en.wikipedia.org/wiki/Luhn_algorithm); a useful first step to validating a credit card. # The underlying value is converted to a string via `#to_s` before being validated. # # NOTE: As with most other constraints, `nil` and empty strings are considered valid values, in order to allow the value to be optional. # If the value is required, consider combining this constraint with `AVD::Constraints::NotBlank`. # # ``` # class Transaction # include AVD::Validatable # # def initialize(@card_number : String); end # # @[Assert::Luhn] # property card_number : String # end # ``` # # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value is not a valid credit card number.` # # The message that will be shown if the value is not pass the Luhn check. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Luhn < Athena::Validator::Constraint INVALID_CHARACTERS_ERROR = "c42b8d36-d9e9-4f5f-aad6-5190e27a1102" CHECKSUM_FAILED_ERROR = "a4f089dd-fd63-4d50-ac30-34ed2a8dc9dd" @@error_names = { INVALID_CHARACTERS_ERROR => "INVALID_CHARACTERS_ERROR", CHECKSUM_FAILED_ERROR => "CHECKSUM_FAILED_ERROR", } def initialize( message : String = "This value is not a valid credit card number.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::Luhn) : Nil value = value.to_s return if value.nil? || value.empty? characters = value.chars unless characters.all? &.number? return self.context.add_violation constraint.message, INVALID_CHARACTERS_ERROR, value end last_dig : Int32 = characters.pop.to_i checksum : Int32 = (characters.reverse.map_with_index { |n, idx| val = idx.even? ? n.to_i * 2 : n.to_i; val -= 9 if val > 9; val }.sum + last_dig) return if !checksum.zero? && checksum.divisible_by?(10) self.context.add_violation constraint.message, CHECKSUM_FAILED_ERROR, value end end end ================================================ FILE: src/components/validator/src/constraints/negative.cr ================================================ # Validates that a value is a negative number. # Use `AVD::Constraints::NegativeOrZero` if you wish to also allow `0`. # # ``` # class Mall # include AVD::Validatable # # def initialize(@lowest_floor : Number); end # # @[Assert::Negative] # property lowest_floor : Number # end # ``` # # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be negative.` # # The message that will be shown if the value is not less than `0`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ compared_value }}` - The expected value. # * `{{ compared_value_type }}` - The type of the expected value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Negative < Athena::Validator::Constraints::LessThan(Int32) def initialize( message : String = "This value should be negative.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super Int32.zero, message, groups, payload end def validated_by : AVD::ConstraintValidator.class AVD::Constraints::LessThan::Validator end end ================================================ FILE: src/components/validator/src/constraints/negative_or_zero.cr ================================================ # Validates that a value is a negative number, or `0`. # Use `AVD::Constraints::Negative` if you don't want to allow `0`. # # ``` # class Mall # include AVD::Validatable # # def initialize(@lowest_floor : Number); end # # @[Assert::NegativeOrZero] # property lowest_floor : Number # end # ``` # # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be negative or zero.` # # The message that will be shown if the value is not less than or equal to `0`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ compared_value }}` - The expected value. # * `{{ compared_value_type }}` - The type of the expected value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The `AVD::Constraint@payload` is not used by `Athena::Validator`, but its processing is completely up to you class Athena::Validator::Constraints::NegativeOrZero < Athena::Validator::Constraints::LessThanOrEqual(Int32) def initialize( message : String = "This value should be negative or zero.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super Int32.zero, message, groups, payload end def validated_by : AVD::ConstraintValidator.class AVD::Constraints::LessThanOrEqual::Validator end end ================================================ FILE: src/components/validator/src/constraints/not_blank.cr ================================================ # Validates that a value is not blank; meaning not equal to a blank string, an empty `Iterable`, `false`, or optionally `nil`. # # ``` # class User # include AVD::Validatable # # def initialize(@name : String); end # # @[Assert::NotBlank] # property name : String # end # ``` # # # Configuration # # ## Optional Arguments # # ### allow_nil # # **Type:** `Bool` **Default:** `false` # # If set to `true`, `nil` values are considered valid and will not trigger a violation. # # ### message # # **Type:** `String` **Default:** `This value should not be blank.` # # The message that will be shown if the value is blank. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::NotBlank < Athena::Validator::Constraint IS_BLANK_ERROR = "0d0c3254-3642-4cb0-9882-46ee5918e6e3" @@error_names = { IS_BLANK_ERROR => "IS_BLANK_ERROR", } getter? allow_nil : Bool def initialize( @allow_nil : Bool = false, message : String = "This value should not be blank.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : String?, constraint : AVD::Constraints::NotBlank) : Nil validate_value(value, constraint) do |v| v.blank? end end # :inherit: def validate(value : Bool?, constraint : AVD::Constraints::NotBlank) : Nil validate_value(value, constraint) do |v| v == false end end # :inherit: def validate(value : Iterable?, constraint : AVD::Constraints::NotBlank) : Nil validate_value(value, constraint) do |v| v.empty? end end private def validate_value(value : _, constraint : AVD::Constraints::NotBlank, & : -> Bool) : Nil return if value.nil? && constraint.allow_nil? if value.nil? || yield value self.context.add_violation constraint.message, IS_BLANK_ERROR, value end end end end ================================================ FILE: src/components/validator/src/constraints/not_equal_to.cr ================================================ # Validates that a value is not equal to another. # # ``` # class User # include AVD::Validatable # # def initialize(@name : String); end # # @[Assert::NotEqualTo("John Doe")] # property name : String # end # ``` # # # Configuration # # ## Required Arguments # # ### value # # Defines the value that the value being validated should be compared to. # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should not be equal to {{ compared_value }}.` # # The message that will be shown if the value is equal to the comparison value. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ compared_value }}` - The expected value. # * `{{ compared_value_type }}` - The type of the expected value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::NotEqualTo(ValueType) < Athena::Validator::Constraint include Athena::Validator::Constraints::AbstractComparison(ValueType) IS_EQUAL_ERROR = "984a0525-d73e-40c0-81c2-2ecbca7e4c96" @@error_names = { IS_EQUAL_ERROR => "IS_EQUAL_ERROR", } def default_error_message : String "This value should not be equal to {{ compared_value }}." end class Validator < Athena::Validator::Constraints::ComparisonValidator # :inherit: def compare_values(actual : _, expected : _) : Bool actual != expected end # :inherit: def error_code : String IS_EQUAL_ERROR end end end ================================================ FILE: src/components/validator/src/constraints/not_nil.cr ================================================ # Validates that a value is not `nil`. # # NOTE: Due to Crystal's static typing, when validating objects the property's type must be nilable, # otherwise `nil` is inherently not allowed due to the compiler's type checking. # # ``` # class Post # include AVD::Validatable # # def initialize(@title : String?, @description : String?); end # # @[Assert::NotNil] # property title : String? # # @[Assert::NotNil] # property description : String? # end # ``` # # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should not be null.` # # The message that will be shown if the value is `nil`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::NotNil < Athena::Validator::Constraint IS_NIL_ERROR = "c7e77b14-744e-44c0-aa7e-391c69cc335c" @@error_names = { IS_NIL_ERROR => "IS_NIL_ERROR", } def initialize( message : String = "This value should not be null.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::NotNil) : Nil return unless value.nil? self.context.add_violation constraint.message, IS_NIL_ERROR, value end end end ================================================ FILE: src/components/validator/src/constraints/optional.cr ================================================ # Allows wrapping `AVD::Constraint`(s) to denote it as being optional within an `AVD::Constraints::Collection`. # See [this][Athena::Validator::Constraints::Collection--required-and-optional-constraints] for more information. class Athena::Validator::Constraints::Optional < Athena::Validator::Constraints::Existence # :inherit: def validated_by : NoReturn raise "BUG: #{self} cannot be validated" end end ================================================ FILE: src/components/validator/src/constraints/positive.cr ================================================ # Validates that a value is a positive number. # Use `AVD::Constraints::PositiveOrZero` if you wish to also allow `0`. # # ``` # class Account # include AVD::Validatable # # def initialize(@balance : Number); end # # @[Assert::Positive] # property balance : Number # end # ``` # # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be positive.` # # The message that will be shown if the value is not greater than `0`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ compared_value }}` - The expected value. # * `{{ compared_value_type }}` - The type of the expected value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Positive < Athena::Validator::Constraints::GreaterThan(Int32) def initialize( message : String = "This value should be positive.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super Int32.zero, message, groups, payload end def validated_by : AVD::ConstraintValidator.class AVD::Constraints::GreaterThan::Validator end end ================================================ FILE: src/components/validator/src/constraints/positive_or_zero.cr ================================================ # Validates that a value is a positive number, or `0`. # Use `AVD::Constraints::Positive` if you don't want to allow `0`. # # ``` # class Account # include AVD::Validatable # # def initialize(@balance : Number); end # # @[Assert::PositiveOrZero] # property balance : Number # end # ``` # # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This value should be positive or zero.` # # The message that will be shown if the value is not greater than or equal to `0`. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ compared_value }}` - The expected value. # * `{{ compared_value_type }}` - The type of the expected value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::PositiveOrZero < Athena::Validator::Constraints::GreaterThanOrEqual(Int32) def initialize( message : String = "This value should be positive or zero.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super Int32.zero, message, groups, payload end def validated_by : AVD::ConstraintValidator.class AVD::Constraints::GreaterThanOrEqual::Validator end end ================================================ FILE: src/components/validator/src/constraints/range.cr ================================================ # Validates that a `Number` or `Time` value is between some minimum and maximum. # # ``` # class House # include AVD::Validatable # # def initialize(@area : Number); end # # @[Assert::Range(15..100)] # property area : Number # end # ``` # # # Configuration # # ## Required Arguments # # ### range # # **Type:** `::Range` # # The `::Range` that defines the minimum and maximum values, if any. # An endless range can be used to only have a minimum or maximum. # # ## Optional Arguments # # NOTE: This constraint does not support a `message` argument. # # ### not_in_range_message # # **Type:** `String` **Default:** `This value should be between {{ min }} and {{ max }}.` # # The message that will be shown if the value is less than the min or greater than the max. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ min }}` - The lower limit. # * `{{ max }}` - The upper limit. # # ### min_message # # **Type:** `String` **Default:** `This value should be {{ limit }} or more.` # # The message that will be shown if the value is less than the min, and no max has been provided. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ limit }}` - The lower limit. # # ### max_message # # **Type:** `String` **Default:** `This value should be {{ limit }} or less.` # # The message that will be shown if the value is more than the max, and no min has been provided. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ limit }}` - The upper limit. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Range < Athena::Validator::Constraint NOT_IN_RANGE_ERROR = "7e62386d-30ae-4e7c-918f-1b7e571c6d69" TOO_HIGH_ERROR = "5d9aed01-ac49-4d8e-9c16-e4aab74ea774" TOO_LOW_ERROR = "f0316644-882e-4779-a404-ee7ac97ddecc" @@error_names = { NOT_IN_RANGE_ERROR => "NOT_IN_RANGE_ERROR", TOO_HIGH_ERROR => "TOO_HIGH_ERROR", TOO_LOW_ERROR => "TOO_LOW_ERROR", } getter min : Number::Primitive | Time | Nil getter max : Number::Primitive | Time | Nil getter not_in_range_message : String getter min_message : String getter max_message : String def self.new( range : ::Range, not_in_range_message : String = "This value should be between {{ min }} and {{ max }}.", min_message : String = "This value should be {{ limit }} or more.", max_message : String = "This value should be {{ limit }} or less.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) range_end = range.end if range_end && range_end.is_a? Number::Primitive && range.excludes_end? range_end -= 1 end new range.begin, range_end, not_in_range_message, min_message, max_message, groups, payload end private def initialize( @min : Number::Primitive | Time | Nil, @max : Number::Primitive | Time | Nil, @not_in_range_message : String, @min_message : String, @max_message : String, groups : Array(String) | String | Nil, payload : Hash(String, String)?, ) super "", groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: # # ameba:disable Metrics/CyclomaticComplexity def validate(value : Number | Time | Nil, constraint : AVD::Constraints::Range) : Nil return if value.nil? min = constraint.min max = constraint.max case {value, min, max} when {Number, Number::Primitive?, Number::Primitive?} return self.add_not_in_range_violation constraint, value, min, max if min && max && (value < min || value > max) return self.add_too_high_violation constraint, value, max if max && value > max add_too_low_violation constraint, value, min if min && value < min when {Time, Time?, Time?} return self.add_not_in_range_violation constraint, value, min, max if min && max && (value < min || value > max) return self.add_too_high_violation constraint, value, max if max && value > max add_too_low_violation constraint, value, min if min && value < min end end def validate(value : _, constraint : AVD::Constraints::Range) : Nil raise AVD::Exception::UnexpectedValueError.new value, "Number | Time" end private def add_not_in_range_violation(constraint, value, min, max) : Nil self .context .build_violation(constraint.not_in_range_message, NOT_IN_RANGE_ERROR, value) .add_parameter("{{ min }}", min) .add_parameter("{{ max }}", max) .add end private def add_too_high_violation(constraint, value, max) : Nil self .context .build_violation(constraint.max_message, TOO_HIGH_ERROR, value) .add_parameter("{{ limit }}", max) .add end private def add_too_low_violation(constraint, value, min) : Nil self .context .build_violation(constraint.min_message, TOO_LOW_ERROR, value) .add_parameter("{{ limit }}", min) .add end end end ================================================ FILE: src/components/validator/src/constraints/regex.cr ================================================ # Validates that a value matches a regular expression. # The underlying value is converted to a string via `#to_s` before being validated. # # NOTE: As with most other constraints, `nil` and empty strings are considered valid values, in order to allow the value to be optional. # If the value is required, consider combining this constraint with `AVD::Constraints::NotBlank`. # # ``` # class User # include AVD::Validatable # # def initialize(@username : String); end # # # this regex verifies that username contains alphanumeric chars # # and some special characters (underscore, space and dash). # @[Assert::Regex(/^[a-zA-Z0-9]+([_ -]?[a-zA-Z0-9])*$/)] # property username : String # end # ``` # # # Configuration # # ## Required Arguments # # ### pattern # # **Type:** `::Regex` # # The `::Regex` pattern that the value should match. # # ## Optional Arguments # # ### match # # **Type:** `Bool` **Default:** `true` # # If set to `false`, validation will require the value does _NOT_ match the [pattern](#pattern). # # ### message # # **Type:** `String` **Default:** `This value should match '{{ pattern }}'.` # # The message that will be shown if the value does not match the [pattern](#pattern). # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # * `{{ pattern }}` - The regular expression pattern that the value should match. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Regex < Athena::Validator::Constraint REGEX_FAILED_ERROR = "108987a0-2d81-44a0-b8d4-1c7ab8815343" @@error_names = { REGEX_FAILED_ERROR => "REGEX_FAILED_ERROR", } getter pattern : ::Regex getter? match : Bool def initialize( @pattern : ::Regex, @match : Bool = true, message : String = "This value should match '{{ pattern }}'.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::Regex) : Nil value = value.to_s return if value.nil? || value.empty? return unless constraint.match? ^ value.matches? constraint.pattern self .context .build_violation(constraint.message, REGEX_FAILED_ERROR, value) .add_parameter("{{ pattern }}", constraint.pattern) .add end end end ================================================ FILE: src/components/validator/src/constraints/required.cr ================================================ # Allows wrapping `AVD::Constraint`(s) to denote it as being required within an `AVD::Constraints::Collection`. # See [this][Athena::Validator::Constraints::Collection--required-and-optional-constraints] for more information. class Athena::Validator::Constraints::Required < Athena::Validator::Constraints::Existence # :inherit: def validated_by : NoReturn raise "BUG: #{self} cannot be validated" end end ================================================ FILE: src/components/validator/src/constraints/sequentially.cr ================================================ # Validates a value against a collection of constraints, stopping once the first violation is raised. # # # Configuration # # ## Required Arguments # # ### constraints # # **Type:** `Array(AVD::Constraint) | AVD::Constraint` # # The `AVD::Constraint`(s) that are to be applied sequentially. # # ## Optional Arguments # # NOTE: This constraint does not support a `message` argument. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. # # # Usage # # Suppose you have an object with a `address` property which should meet the following criteria: # # * Is not a blank string # * Is at least 10 characters long # * Is in a specific format # * Is geolocalizable using an external API # # If you were to apply all of these constraints to the `address` property, you may run into some problems. # For example, multiple violations may be added for the same property, or you may perform a useless and heavy # external call to geolocalize the address when it is not in a proper format. # # To solve this we can validate these constraints sequentially. # # ``` # class Location # include AVD::Validatable # # PATTERN = /some_pattern/ # # def initialize(@address : String); end # # @[Assert::Sequentially([ # @[Assert::NotBlank], # @[Assert::Size(10..)], # @[Assert::Regex(Location::PATTERN)], # @[Assert::CustomGeolocalizationConstraint], # ])] # getter address : String # end # ``` # # NOTE: The annotation approach only supports two levels of nested annotations. # Manually wire up the constraint via code if you require more than that. class Athena::Validator::Constraints::Sequentially < Athena::Validator::Constraints::Composite def initialize( constraints : AVD::Constraints::Composite::Type, groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super constraints, "", groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::Sequentially) : Nil validator = self.context.validator.in_context self.context original_count = validator.violations.size constraint.constraints.each_value do |c| break if original_count != validator.validate(value, c).violations.size end end end end ================================================ FILE: src/components/validator/src/constraints/unique.cr ================================================ # Validates that all elements of an `Indexable` are unique. # # ``` # class School # include AVD::Validatable # # def initialize(@rooms : Array(String)); end # # @[Assert::Unique] # property rooms : Array(String) # end # ``` # # # Configuration # # ## Optional Arguments # # ### message # # **Type:** `String` **Default:** `This collection should contain only unique elements.` # # The message that will be shown if at least one element is repeated in the collection. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::Unique < Athena::Validator::Constraint IS_NOT_UNIQUE_ERROR = "fd1f83d6-94b5-44bc-b39d-b1ff367ebfb8" @@error_names = { IS_NOT_UNIQUE_ERROR => "IS_NOT_UNIQUE_ERROR", } def initialize( message : String = "This collection should contain only unique elements.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : Indexable?, constraint : AVD::Constraints::Unique) : Nil return if value.nil? set = Set(typeof(value[0])).new value.size unless value.all? { |x| set.add?(x) } self.context.add_violation constraint.message, IS_NOT_UNIQUE_ERROR, value end end # :inherit: def validate(actual : _, expected : _) : NoReturn # TODO: Support checking if arbitrarily typed values are actually comparable once `#responds_to?` supports it. self.raise_invalid_type actual, "Indexable" end end end ================================================ FILE: src/components/validator/src/constraints/url.cr ================================================ # Validates that a value is a valid URL string. # The underlying value is converted to a string via `#to_s` before being validated. # # NOTE: As with most other constraints, `nil` and empty strings are considered valid values, in order to allow the value to be optional. # If the value is required, consider combining this constraint with `AVD::Constraints::NotBlank`. # # ``` # class Profile # include AVD::Validatable # # def initialize(@avatar_url : String); end # # @[Assert::URL] # property avatar_url : String # end # ``` # # # Configuration # # ## Optional Arguments # # ### protocols # # **Type:** `Array(String)` **Default:** `["http", "https"]` # # The protocols considered to be valid for the URL. # # ### relative_protocol # # **Type:** `Bool` **Default:** `false` # # If `true` the protocol is considered optional. # # ### require_tld # # **Type:** `Bool` **Default:** `true` # # The [URL spec](https://datatracker.ietf.org/doc/html/rfc1738) considers URLs like `https://aaa` or `https://foobar` to be valid # However, this is most likely not desirable for most use cases. # As such, this argument defaults to `true` and can be used to require that the host part of the URL will have to include a TLD (top-level domain name). # E.g. `https://example.com` is valid but `https://example` is not. # # NOTE: This constraint does _NOT_ validate that the provided TLD is a valid one according to the [official list](https://en.wikipedia.org/wiki/List_of_Internet_top-level_domains). # # ### tld_message # # **Type:** `String` **Default:** `This URL is missing a top-level domain.` # # The message that will be shown if `#require_tld?` is `true` and the URL does not contain at least one TLD. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### message # # **Type:** `String` **Default:** `This value is not a valid URL.` # # The message that will be shown if the URL is not valid. # # #### Placeholders # # The following placeholders can be used in this message: # # * `{{ value }}` - The current (invalid) value. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. class Athena::Validator::Constraints::URL < Athena::Validator::Constraint INVALID_URL_ERROR = "e87ceba6-a896-4906-9957-b102045272ee" MISSING_TLD_ERROR = "4507f4cc-90fd-4616-989b-2166fc0d1083" @@error_names = { INVALID_URL_ERROR => "INVALID_URL_ERROR", MISSING_TLD_ERROR => "MISSING_TLD_ERROR", } getter protocols : Array(String) getter? relative_protocol : Bool getter? require_tld : Bool getter tld_message : String def initialize( @protocols : Array(String) = ["http", "https"], @relative_protocol : Bool = false, @require_tld : Bool = true, @tld_message : String = "This URL is missing a top-level domain.", message : String = "This value is not a valid URL.", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::URL) : Nil value = value.to_s return if value.nil? || value.empty? unless value.matches? self.pattern(constraint) self.context.add_violation constraint.message, INVALID_URL_ERROR, value end return unless constraint.require_tld? return unless url_host = URI.parse(value).host # URL with a TLD must include at least a `.`, but cannot be an IP address if !url_host.includes?('.') || Socket::IPAddress.valid?(url_host) self.context.add_violation constraint.tld_message, MISSING_TLD_ERROR, value end end def pattern(constraint : AVD::Constraints::URL) : ::Regex /^#{constraint.relative_protocol? ? "(?:(#{constraint.protocols.join('|')}):)?" : "(#{constraint.protocols.join('|')}):"}\/\/(((?:[\_\.\pL\pN-]|\%[0-9A-Fa-f]{2})+:)?((?:[\_\.\pL\pN-]|\%[0-9A-Fa-f]{2})+)@)?(([\pL\pN\pS\-\_\.])+(\.?([\pL\pN]|xn\-\-[\pL\pN-]+)+\.?)|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[(?:(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-f]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,1}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,2}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,3}(?:(?:[0-9a-f]{1,4})))?::(?:(?:[0-9a-f]{1,4})):)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,4}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,5}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,6}(?:(?:[0-9a-f]{1,4})))?::))))\])(:[0-9]+)?(?:\/ (?:[\pL\pN\-._\~!$&\'()*+,;=:@]|\%[0-9A-Fa-f]{2})* )*(?:\? (?:[\pL\pN\-._\~!$&\'[\]()*+,;=:@\/?]|\%[0-9A-Fa-f]{2})* )?(?:\# (?:[\pL\pN\-._\~!$&\'()*+,;=:@\/?]|\%[0-9A-Fa-f]{2})* )?$/ix end end end ================================================ FILE: src/components/validator/src/constraints/valid.cr ================================================ # Tells the validator that it should also validate objects embedded as properties on an object being validated. # # # Configuration # # ## Optional Arguments # # NOTE: This constraint does not support a `message` argument. # # ### groups # # **Type:** `Array(String) | String | Nil` **Default:** `nil` # # The [validation groups][Athena::Validator::Constraint--validation-groups] this constraint belongs to. # `AVD::Constraint::DEFAULT_GROUP` is assumed if `nil`. # # ### payload # # **Type:** `Hash(String, String)?` **Default:** `nil` # # Any arbitrary domain-specific data that should be stored with this constraint. # The [payload][Athena::Validator::Constraint--payload] is not used by `Athena::Validator`, but its processing is completely up to you. # # # Usage # # Without this constraint, objects embedded in another object are not valided. # # ``` # class SubObjectOne # include AVD::Validatable # # @[Assert::NotBlank] # getter string : String = "" # end # # class SubObjectTwo # include AVD::Validatable # # @[Assert::NotBlank] # getter string : String = "" # end # # class MyObject # include AVD::Validatable # # # This object is not validated when validating `MyObject`. # getter sub_object_one : SubObjectOne = SubObjectOne.new # # # Have the validator also validate `SubObjectTwo` when validating `MyObject`. # @[Assert::Valid] # getter sub_object_two : SubObjectTwo = SubObjectTwo.new # end # ``` class Athena::Validator::Constraints::Valid < Athena::Validator::Constraint getter? traverse : Bool def initialize( @traverse : Bool = true, groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super "", groups, payload end class Validator < Athena::Validator::ConstraintValidator # :inherit: def validate(value : _, constraint : AVD::Constraints::Valid) : Nil return if value.nil? self .context .validator .in_context(self.context) .validate value, groups: self.context.group end end end ================================================ FILE: src/components/validator/src/exception/invalid_argument.cr ================================================ # Represents a code logic error that should lead directly to a fix in your code. class Athena::Validator::Exception::InvalidArgument < ArgumentError include Athena::Validator::Exception end ================================================ FILE: src/components/validator/src/exception/logic.cr ================================================ # Represents a code logic error that should lead directly to a fix in your code. class Athena::Validator::Exception::Logic < ::Exception include Athena::Validator::Exception end ================================================ FILE: src/components/validator/src/exception/unexpected_value_error.cr ================================================ # Raised when an `AVD::ConstraintValidatorInterface` is unable to validate a value of an unsupported type. # # See `AVD::ConstraintValidator#raise_invalid_type`. class Athena::Validator::Exception::UnexpectedValueError < ArgumentError include Athena::Validator::Exception # A string representing a union of the supported_type(s). getter supported_types : String def initialize(value : _, @supported_types : String) super "Expected argument of type '#{supported_types}', '#{typeof(value)}' given." end end ================================================ FILE: src/components/validator/src/execution_context.cr ================================================ require "./validator/validator_interface" require "./execution_context_interface" # Basic implementation of `AVD::ExecutionContextInterface`. class Athena::Validator::ExecutionContext include Athena::Validator::ExecutionContextInterface # :inherit: getter constraint : AVD::Constraint? # :inherit: getter group : String? # :inherit: getter validator : AVD::Validator::ValidatorInterface # :inherit: getter violations : AVD::Violation::ConstraintViolationList = AVD::Violation::ConstraintViolationList.new # :inherit: @property_path : String = "" # :inherit: getter metadata : AVD::Metadata::MetadataInterface? = nil # The value that is currently being validated. @value_container : AVD::Container = AVD::ValueContainer.new(nil) protected getter root_container : AVD::Container # The object that is currently being validated. getter object_container : AVD::Container = AVD::ValueContainer.new(nil) protected def initialize(@validator : AVD::Validator::ValidatorInterface, root : _) @root_container = AVD::ValueContainer.new root end # :nodoc: def constraint=(@constraint : AVD::Constraint?); end # :nodoc: def group=(@group : String?); end # :inherit: def value @value_container.value end # :inherit: def object @object_container.value end # :inherit: def root @root_container.value end # :inherit: def class_name @metadata.try &.class_name end # :inherit: def property_name : String? @metadata.try &.name end # :inherit: def property_path(path : String = "") : String AVD::PropertyPath.append @property_path, path end # :nodoc: def set_node(value : _, object : _, metadata : AVD::Metadata::MetadataInterface?, property_path : String) : Nil @value_container = AVD::ValueContainer.new value @object_container = AVD::ValueContainer.new object @metadata = metadata @property_path = property_path end # :inherit: def add_violation(message : String, code : String) : Nil self.build_violation(message, code).add end # :inherit: def add_violation(message : String, code : String, value : _) : Nil self.build_violation(message, code, value).add end # :inherit: def add_violation(message : String, parameters : Hash(String, String) = {} of String => String) : Nil self.build_violation(message, parameters).add end # :inherit: def build_violation(message : String, code : String) : AVD::Violation::ConstraintViolationBuilderInterface self.build_violation(message).code(code) end # :inherit: def build_violation(message : String, code : String, value : _) : AVD::Violation::ConstraintViolationBuilderInterface self.build_violation(message).code(code).add_parameter("{{ value }}", value) end # :inherit: def build_violation(message : String, parameters : Hash(String, String) = {} of String => String) : AVD::Violation::ConstraintViolationBuilderInterface AVD::Violation::ConstraintViolationBuilder.new( @violations, @constraint, message, parameters, @root_container, @property_path, @value_container, ) end end ================================================ FILE: src/components/validator/src/execution_context_interface.cr ================================================ # Stores contextual data related to the current validation run. # # This includes the violations generated so far, the current constraint, value being validated, etc. # # ### Adding Violations # # As mentioned in the `AVD::ConstraintValidatorInterface` documentation, # violations are not returned from the `AVD::ConstraintValidatorInterface#validate` method. # Instead they are added to the `AVD::ConstraintValidatorInterface#context` instance. # # The simplest way to do so is via the `#add_violation` method, which accepts the violation message, # and any parameters that should be used to render the message. # Additional overloads exist to make adding a value with a specific message, and code, or message, code, and `{{ value }}` placeholder value easier. # # #### Building violations # # In some cases you may wish to add additional data to the `AVD::Violation::ConstraintViolationInterface` before adding it to `self`. # To do this, you can also use the `#build_violation` method, which returns an `AVD::Violation::ConstraintViolationBuilderInterface` # that can be used to construct a violation, with an easier API. module Athena::Validator::ExecutionContextInterface # Adds a violation with the provided *message*, and optionally *parameters* based on the node currently being validated. abstract def add_violation(message : String, parameters : Hash(String, String) = {} of String => String) : Nil # Adds a violation with the provided *message* and *code* abstract def add_violation(message : String, code : String) : Nil # Adds a violation with the provided *message*, and *code*, *value* parameter. # # The provided *value* is added to the violations' parameters as `"{{ value }}"`. abstract def add_violation(message : String, code : String, value : _) : Nil # Returns an `AVD::Violation::ConstraintViolationBuilderInterface` with the provided *message*. # # Can be used to add additional information to the `AVD::Violation::ConstraintViolationInterface` being adding it to `self`. abstract def build_violation(message : String, parameters : Hash(String, String) = {} of String => String) : AVD::Violation::ConstraintViolationBuilderInterface # Returns an `AVD::Violation::ConstraintViolationBuilderInterface` with the provided *message*, and *code*. abstract def build_violation(message : String, code : String) : AVD::Violation::ConstraintViolationBuilderInterface # Returns an `AVD::Violation::ConstraintViolationBuilderInterface` with the provided *message*, and *code*, and *value*. # # The provided *value* is added to the violations' parameters as `"{{ value }}"`. abstract def build_violation(message : String, code : String, value : _) : AVD::Violation::ConstraintViolationBuilderInterface # Returns the class that is currently being validated. abstract def class_name # Returns the `AVD::Constraint` that is currently being validated, if any. abstract def constraint : AVD::Constraint? # Returns the group that is currently being validated, if any. abstract def group : String? # Returns an `AVD::Metadata::MetadataInterface` object for the value currently being validated. # # This would be an `AVD::Metadata::PropertyMetadataInterface` if the current value is an object, # an `AVD::Metadata::GenericMetadata` if the current value is a plain value, and an # `AVD::Metadata::ClassMetadata` if the current value value is an entire class. abstract def metadata : AVD::Metadata::MetadataInterface? # Returns the object that is currently being validated. abstract def object # Returns the property name of the node currently being validated. abstract def property_name : String? # Returns the path to the property that is currently being validated. # # For example, given a `Person` object that has an `Address` property; # the property path would be empty initially. When the `address` property # is being validated the *property_path* would be `address`. # When the street property of the related `Address` object is being validated # the *property_path* would be `address.street`. # # This also works for collections of objects. If the `Person` object had multiple # addresses, the property path when validating the first street of the first address # would be `addresses[0].street`. abstract def property_path : String # Returns the object initially passed to `AVD::Validator::ValidatorInterface#validate`. abstract def root # Returns a reference to an `AVD::Validator::ValidatorInterface` that can be used to validate # additional constraints as part of another constraint. abstract def validator : AVD::Validator::ValidatorInterface # Returns the value that is currently being validated. abstract def value # Returns the `AVD::Violation::ConstraintViolationInterface` instances generated by the validator thus far. abstract def violations : AVD::Violation::ConstraintViolationListInterface # Internal # :nodoc: protected abstract def set_node(value : _, object : _, metadata : AVD::Metadata::MetadataInterface?, property_path : String) : Nil # :nodoc: protected abstract def group=(group : String) # :nodoc: protected abstract def constraint=(constraint : AVD::Constraint) end ================================================ FILE: src/components/validator/src/metadata/cascading_strategy.cr ================================================ # Determines whether an object should be cascaded. # # If cascading is enabled, the validator will also validate embedded objects. enum Athena::Validator::Metadata::CascadingStrategy None Cascade end ================================================ FILE: src/components/validator/src/metadata/class_metadata.cr ================================================ require "./generic_metadata" # Represents metadata associated with an `AVD::Validatable` instance. # # `self` is lazily initialized and cached at the class level. # # Includes metadata about the class; such as its name, constraints, etc. class Athena::Validator::Metadata::ClassMetadata(T) include Athena::Validator::Metadata::GenericMetadata # Builds `self`, auto registering any annotation based annotations on `T`, # as well as those registered via `T.load_metadata`. def self.build : self class_metadata = new {% begin %} # Add property constraints {% for ivar, idx in T.instance_vars %} {% for constraint in AVD::Constraint.all_subclasses.reject { |c| c.abstract? || (c.type_vars.size > 0 && c.type_vars.first.stringify != "ValueType") } %} {% ann_name = constraint.name(generic_args: false).split("::").last.id %} {% if ann = ivar.annotation Assert.constant(ann_name).resolve %} {% default_arg = ann.args.empty? ? nil : ann.args.first %} {% if default_arg.is_a? ArrayLiteral %} {% default_arg = default_arg.map do |arg| if arg.is_a? Annotation arg_name = arg.stringify.gsub(/@\[/, "").gsub(/\(.*/, "").split("::").last.gsub(/\]/, "") inner_default_arg = arg.args.empty? ? nil : arg.args.first # Support only 2 levels deep for now. inner_default_arg = if inner_default_arg.is_a? ArrayLiteral inner_default_arg.map do |inner_arg| if inner_arg.is_a? Annotation inner_arg_name = inner_arg.stringify.gsub(/@\[/, "").gsub(/\(.*/, "").split("::").last.gsub(/\]/, "") inner_inner_default_arg = inner_arg.args.empty? ? nil : inner_arg.args.first %(AVD::Constraints::#{inner_arg_name.id}.new(#{inner_inner_default_arg ? "#{inner_inner_default_arg},".id : "".id}#{inner_arg.named_args.double_splat})).id else inner_arg end end else inner_default_arg end # Hack this to work correctly for now. if arg_name == "All" || arg_name == "AtLeastOneOf" inner_default_arg = "#{inner_default_arg} of AVD::Constraint".id end # Resolve constraints from the annotations, # TODO: Figure out a better way to do this. %(AVD::Constraints::#{arg_name.id}.new(#{inner_default_arg ? "#{inner_default_arg},".id : "".id}#{arg.named_args.double_splat})).id else arg end end %} {% end %} class_metadata.add_property_constraint( AVD::Metadata::PropertyMetadata(T, {{idx}}).new({{ivar.name.stringify}}), {{constraint.name(generic_args: false).id}}.new( {{ default_arg ? "#{default_arg},".id : "".id }} # Default argument {{ ann.named_args.double_splat }} ) ) {% end %} {% end %} {% end %} # Add getter constraints {% for m, idx in T.methods %} {% for constraint in AVD::Constraint.all_subclasses.reject &.abstract? %} {% ann_name = constraint.name(generic_args: false).split("::").last.id %} {% if ann_name != "Callback" && (ann = m.annotation Assert.constant(ann_name).resolve) %} {% default_arg = ann.args.empty? ? nil : ann.args.first %} class_metadata.add_getter_constraint( AVD::Metadata::GetterMetadata(T, {{idx}}).new({{m.name.stringify}}), {{constraint.name(generic_args: false).id}}.new( {{ default_arg ? "#{default_arg},".id : "".id }} # Default argument {{ ann.named_args.double_splat }} ) ) {% end %} {% end %} {% end %} # Add callback constraints {% for callback in T.methods.select &.annotation(Assert::Callback) %} class_metadata.add_constraint AVD::Constraints::Callback.new(callback_name: {{callback.name.stringify}}, {{callback.annotation(Assert::Callback).named_args.double_splat}}) {% end %} {% for callback in T.class.methods.select &.annotation(Assert::Callback) %} class_metadata.add_constraint AVD::Constraints::Callback.new(callback: ->T.{{callback.name.id}}(AVD::Constraints::Callback::ValueContainer, AVD::ExecutionContextInterface, Hash(String, String)?), {{callback.annotation(Assert::Callback).named_args.double_splat}}) {% end %} {% end %} # Also support adding constraints via code {% if T.class.has_method? :load_metadata %} T.load_metadata class_metadata {% end %} # Check for group sequences {% if group_sequence = T.annotation Assert::GroupSequence %} class_metadata.group_sequence = [{{group_sequence.args.splat}}] {% end %} class_metadata end # The `#class_name` based group for `self`. getter default_group : String # The `AVD::Constraints::GroupSequence` used by `self`, if any. getter group_sequence : AVD::Constraints::GroupSequence? = nil @getters : Hash(String, AVD::Metadata::PropertyMetadataInterface) = Hash(String, AVD::Metadata::PropertyMetadataInterface).new @members : Hash(String, Array(AVD::Metadata::PropertyMetadataInterface)) = Hash(String, Array(AVD::Metadata::PropertyMetadataInterface)).new @properties : Hash(String, AVD::Metadata::PropertyMetadataInterface) = Hash(String, AVD::Metadata::PropertyMetadataInterface).new def initialize @default_group = T.to_s end def class_name : T.class T end # Adds each of the provided *constraints* to `self`. def add_constraint(constraints : Array(AVD::Constraint)) : self constraints.each do |c| self.add_constraint c end self end # :inherit: # # Also adds the `#class_name` based group via `AVD::Constraint#add_implicit_group`. def add_constraint(constraint : AVD::Constraint) : self constraint.add_implicit_group @default_group super constraint self end # Adds the provided *constraint* to the provided *method_name*. def add_getter_constraint(method_name : String, constraint : AVD::Constraint) : self self.add_getter_constraint AVD::Metadata::GetterMetadata(T, Nil).new(method_name), constraint end # Adds a hash of constraints to `self`, where the keys represent the property names, and the value # is the constraint/array of constraints to add. def add_property_constraints(property_hash : Hash(String, AVD::Constraint | Array(AVD::Constraint))) : self property_hash.each do |property_name, constraints| self.add_property_constraint property_name, constraints end self end # Adds each of the provided *constraints* to the provided *property_name*. def add_property_constraint(property_name : String, constraints : Array(AVD::Constraint)) : self constraints.each do |c| self.add_property_constraint property_name, c end self end # Adds the provided *constraint* to the provided *property_name*. def add_property_constraint(property_name : String, constraint : AVD::Constraint) : self self.add_property_constraint AVD::Metadata::PropertyMetadata(T, Nil).new(property_name), constraint end # Returns an array of the properties who `self` has constraints defined for. def constrained_properties : Array(String) @members.keys end # Sets the `AVD::Constraints::GroupSequence` that should be used for `self`. # # Raises an `AVD::Exception::InvalidArgument` if `self` is an `AVD::Constraints::GroupSequence::Provider`, # the *sequence* contains `AVD::Constraint::DEFAULT_GROUP`, # or the `#class_name` based group is missing. def group_sequence=(sequence : Array(String) | AVD::Constraints::GroupSequence) : self raise AVD::Exception::InvalidArgument.new "Defining a static group sequence is not allowed with a group sequence provider." if @group_sequence_provider if sequence.is_a? Array sequence = AVD::Constraints::GroupSequence.new sequence end if sequence.groups.includes? AVD::Constraint::DEFAULT_GROUP raise AVD::Exception::InvalidArgument.new "The group '#{AVD::Constraint::DEFAULT_GROUP}' is not allowed in group sequences." end unless sequence.groups.includes? @default_group raise AVD::Exception::InvalidArgument.new "The group '#{@default_group}' is missing from the group sequence." end @group_sequence = sequence self end # Denotes `self` as a `AVD::Constraints::GroupSequence::Provider`. def group_sequence_provider=(active : Bool) : Nil raise AVD::Exception::InvalidArgument.new "Defining a group sequence provider is not allowed with a static group sequence." unless @group_sequence.nil? # TODO: ensure `T` implements the module interface @group_sequence_provider = active end # Returns `true` if `self` has property metadata for the provided *property_name*. def has_property_metadata?(property_name : String) : Bool @members.has_key? property_name end # Returns an `AVD::Metadata::PropertyMetadataInterface` instance for the provided *property_name*, if any. def property_metadata(property_name : String) : Array(AVD::Metadata::PropertyMetadataInterface) @members.fetch(property_name) { [] of AVD::Metadata::PropertyMetadataInterface } end def name : String? nil end protected def add_getter_constraint(getter_metadata : AVD::Metadata::PropertyMetadataInterface, constraint : AVD::Constraint) : self unless @getters.has_key? getter_metadata.name @getters[getter_metadata.name] = getter_metadata self.add_property_metadata getter_metadata end constraint.add_implicit_group @default_group @getters[getter_metadata.name].add_constraint constraint self end protected def add_property_constraint(property_metadata : AVD::Metadata::PropertyMetadataInterface, constraint : AVD::Constraint) : self unless @properties.has_key? property_metadata.name @properties[property_metadata.name] = property_metadata self.add_property_metadata property_metadata end constraint.add_implicit_group @default_group @properties[property_metadata.name].add_constraint constraint self end protected def invoke_callback(name : String, object : AVD::Validatable, context : AVD::ExecutionContextInterface, payload : Hash(String, String)?) : Nil {% begin %} case name {% for callback in T.methods.select &.annotation(Assert::Callback) %} when {{callback.name.stringify}} if object.responds_to?({{callback.name.id.symbolize}}) object.{{callback.name.id}}(context, payload) end {% end %} else raise "BUG: Unknown method #{name} within #{T}" end {% end %} end private def add_property_metadata(metadata : AVD::Metadata::PropertyMetadataInterface) : Nil (@members[metadata.name] ||= Array(AVD::Metadata::PropertyMetadataInterface).new) << metadata end end ================================================ FILE: src/components/validator/src/metadata/generic_metadata.cr ================================================ require "./metadata_interface" module Athena::Validator::Metadata::GenericMetadata include Athena::Validator::Metadata::MetadataInterface @constraints_by_group = {} of String => Array(AVD::Constraint) getter constraints : Array(AVD::Constraint) = [] of AVD::Constraint # :inherit: getter cascading_strategy : AVD::Metadata::CascadingStrategy = AVD::Metadata::CascadingStrategy::None # Adds the provided *constraint* to `self`'s `#constraints` array. # # Sets `#cascading_strategy` to `AVD::Metadata::CascadingStrategy::Cascade` if the *constraint* is `AVD::Constraints::Valid`. def add_constraint(constraint : AVD::Constraint) : AVD::Metadata::GenericMetadata if constraint.is_a? AVD::Constraints::Valid @cascading_strategy = :cascade return self end @constraints << constraint constraint.groups.each do |group| (@constraints_by_group[group] ||= Array(AVD::Constraint).new) << constraint end self end # Adds each of the provided *constraints* to `self`. def add_constraints(constraints : Array(AVD::Constraint)) : AVD::Metadata::GenericMetadata constraints.each &->add_constraint(AVD::Constraint) self end # :inherit: def find_constraints(group : String) : Array(AVD::Constraint) @constraints_by_group[group]? || Array(AVD::Constraint).new end protected def value(entity : AVD::Validatable) raise "BUG: Invoked default value method." end end ================================================ FILE: src/components/validator/src/metadata/getter_metadata.cr ================================================ require "./property_metadata_interface" class Athena::Validator::Metadata::GetterMetadata(EntityType, MethodIdx) include Athena::Validator::Metadata::GenericMetadata include Athena::Validator::Metadata::PropertyMetadataInterface # :inherit: getter name : String def initialize(@name : String); end # Returns the class the method `self` represents, belongs to. def class_name : EntityType.class EntityType end protected def value(obj : EntityType) {% begin %} {% unless MethodIdx == Nil %} obj.{{EntityType.methods[MethodIdx].name.id}} {% else %} case @name {% for m in EntityType.methods.select &.args.empty? %} when {{m.name.stringify}} then obj.{{m.name.id}} {% end %} else raise "BUG: Unknown method '#{@name}' within #{EntityType}." end {% end %} {% end %} end end ================================================ FILE: src/components/validator/src/metadata/metadata.cr ================================================ # :nodoc: class Athena::Validator::Metadata::Metadata include Athena::Validator::Metadata::GenericMetadata def class_name : Nil end def name : String? end end ================================================ FILE: src/components/validator/src/metadata/metadata_factory.cr ================================================ require "./metadata_factory_interface" # Basic implementation of `AVD::Metadata::MetadataFactoryInterface`. class Athena::Validator::Metadata::MetadataFactory include Athena::Validator::Metadata::MetadataFactoryInterface def metadata(object : AVD::Validatable) : AVD::Metadata::ClassMetadata object.class.validation_class_metadata end end ================================================ FILE: src/components/validator/src/metadata/metadata_factory_interface.cr ================================================ module Athena::Validator::Metadata::MetadataFactoryInterface # Returns an `AVD::Metadata::ClassMetadata` instance for the related `AVD::Validatable` *object*. abstract def metadata(object : AVD::Validatable) : AVD::Metadata::ClassMetadata end ================================================ FILE: src/components/validator/src/metadata/metadata_interface.cr ================================================ module Athena::Validator::Metadata::MetadataInterface # Returns the `AVD::Metadata::CascadingStrategy` for `self`. abstract def cascading_strategy : AVD::Metadata::CascadingStrategy abstract def constraints : Array(AVD::Constraint) # Returns an array of all constraints in the provided *group*. abstract def find_constraints(group : String) : Array(AVD::Constraint) end ================================================ FILE: src/components/validator/src/metadata/property_metadata.cr ================================================ require "./property_metadata_interface" class Athena::Validator::Metadata::PropertyMetadata(EntityType, PropertyIdx) include Athena::Validator::Metadata::GenericMetadata include Athena::Validator::Metadata::PropertyMetadataInterface # :inherit: getter name : String def initialize(@name : String); end # Returns the class the property `self` represents, belongs to. def class_name : EntityType.class EntityType end protected def value(obj : EntityType) {% begin %} {% unless PropertyIdx == Nil %} obj.@{{EntityType.instance_vars[PropertyIdx].name.id}} {% else %} case @name {% for ivar in EntityType.instance_vars %} when {{ivar.name.stringify}} then obj.@{{ivar.id}} {% end %} else raise "BUG: Unknown property '#{@name}' within #{EntityType}." end {% end %} {% end %} end end ================================================ FILE: src/components/validator/src/metadata/property_metadata_interface.cr ================================================ # Stores metadata associated with a specific property. module Athena::Validator::Metadata::PropertyMetadataInterface include Athena::Validator::Metadata::MetadataInterface # Returns the name of the member represented by `self`. abstract def name : String # Returns the value of the member represented by `self. protected abstract def value(obj : ADVD::Valdatable) end ================================================ FILE: src/components/validator/src/property_path.cr ================================================ # Utility type for working with property paths. module Athena::Validator::PropertyPath # Appends the provided *sub_path* to the provided *base_path* based on the following rules: # # * If the base path is empty, the sub path is returned as is. # * If the base path is not empty, and the sub path starts with an `[`, # the concatenation of the two paths is returned. # * If the base path is not empty, and the sub path does not start with an `[`, # the concatenation of the two paths is returned, separated by a `.`. # # ``` # AVD::PropertyPath.append "", "sub_path" # => "sub_path" # AVD::PropertyPath.append "base_path", "[0]" # => "base_path[0]" # AVD::PropertyPath.append "base_path", "sub_path" # => "base_path.sub_path" # ``` def self.append(base_path : String, sub_path : String) : String return base_path if sub_path.blank? return "#{base_path}#{sub_path}" if sub_path.starts_with? '[' !base_path.blank? ? "#{base_path}.#{sub_path}" : sub_path end end ================================================ FILE: src/components/validator/src/spec/abstract_validator_test_case.cr ================================================ # :nodoc: abstract struct Athena::Validator::Spec::AbstractValidatorTestCase < ASPEC::TestCase private class SubEntity include AVD::Validatable property value : String? end private abstract class Parent macro inherited include AVD::Validatable end end private class EntityParent < Parent property data : String = "data" property child : Entity? = nil end private class Entity < Parent property first_name : String? property! last_name : String property! sub_object : SubEntity property! sub_object2 : SubEntity property! hash_sub_object : Hash(String, SubEntity) property! nested_hash_sub_object : Hash(Int32, Hash(String, SubEntity)) property! scalar_array : Array(Int32 | String) property! nil_array : Array(Nil) property! data_hash : Hash(String, String) end @metadata : AVD::Metadata::ClassMetadata(Entity) @sub_object_metadata : AVD::Metadata::ClassMetadata(SubEntity) @metadata_factory : AVD::Spec::MockMetadataFactory(EntityParent, Entity, SubEntity, EntitySequenceProvider, EntityGroupSequenceProvider) def initialize @metadata = AVD::Metadata::ClassMetadata(Entity).new @sub_object_metadata = AVD::Metadata::ClassMetadata(SubEntity).new @metadata_factory = AVD::Spec::MockMetadataFactory(EntityParent, Entity, SubEntity, EntitySequenceProvider, EntityGroupSequenceProvider).new @metadata_factory.add_metadata Entity, @metadata @metadata_factory.add_metadata SubEntity, @sub_object_metadata end abstract def validate(value, constraints, groups) : AVD::Violation::ConstraintViolationListInterface abstract def validate_property(object, property_name, groups) : AVD::Violation::ConstraintViolationListInterface abstract def validate_property_value(object, property_name, value, groups) : AVD::Violation::ConstraintViolationListInterface def test_validate : Nil callback = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| context.class_name.should be_nil context.property_name.should be_nil context.property_path.should be_empty context.group.should eq "group" context.root.should eq "Fred" context.value.should eq "Fred" value.should eq "Fred" context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end constraint = AVD::Constraints::Callback.new callback: callback, groups: ["group"] violations = self.validate "Fred", constraint, "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should be_empty violation.root.should eq "Fred" violation.invalid_value.should eq "Fred" violation.plural.should be_nil violation.code.should be_nil end def test_validate_class_constraint : Nil object = Entity.new callback = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| context.class_name.should eq Entity context.property_name.should be_nil context.property_path.should be_empty context.group.should eq "group" context.root.should eq object context.value.should eq object value.should eq object context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group"] violations = self.validate object, groups: "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should be_empty violation.root.should eq object violation.invalid_value.should eq object violation.plural.should be_nil violation.code.should be_nil end def test_validate_property_constraint : Nil object = Entity.new object.first_name = "Fred" callback = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| property_metadatas = @metadata.property_metadata "first_name" context.class_name.should eq Entity context.property_name.should eq "first_name" context.property_path.should eq "first_name" context.group.should eq "group" property_metadatas.first.should eq context.metadata context.root.should eq object context.value.should eq "Fred" value.should eq "Fred" context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end @metadata.add_property_constraint "first_name", AVD::Constraints::Callback.new callback: callback, groups: ["group"] violations = self.validate object, groups: "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should eq "first_name" violation.root.should eq object violation.invalid_value.should eq "Fred" violation.plural.should be_nil violation.code.should be_nil end def test_validate_getter_constraint : Nil object = Entity.new object.first_name = "Fred" callback = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| property_metadatas = @metadata.property_metadata "first_name" context.class_name.should eq Entity context.property_name.should eq "first_name" context.property_path.should eq "first_name" context.group.should eq "group" property_metadatas.first.should eq context.metadata context.root.should eq object context.value.should eq "Fred" value.should eq "Fred" context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end @metadata.add_getter_constraint "first_name", AVD::Constraints::Callback.new callback: callback, groups: ["group"] violations = self.validate object, groups: "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should eq "first_name" violation.root.should eq object violation.invalid_value.should eq "Fred" violation.plural.should be_nil violation.code.should be_nil end def test_validate_object_in_hash : Nil object = Entity.new hash = {"key" => object} callback = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| context.class_name.should eq Entity context.property_name.should be_nil context.property_path.should eq "[key]" context.group.should eq "group" context.metadata.should eq @metadata context.root.should eq hash context.value.should eq object value.should eq object context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group"] violations = self.validate hash, groups: "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should eq "[key]" violation.root.should eq hash violation.invalid_value.should eq object violation.plural.should be_nil violation.code.should be_nil end def test_validate_object_in_nested_hash : Nil object = Entity.new hash = {2 => {"key" => object}} callback = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| context.class_name.should eq Entity context.property_name.should be_nil context.property_path.should eq "[2][key]" context.group.should eq "group" context.metadata.should eq @metadata context.root.should eq hash context.value.should eq object value.should eq object context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group"] violations = self.validate hash, groups: "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should eq "[2][key]" violation.root.should eq hash violation.invalid_value.should eq object violation.plural.should be_nil violation.code.should be_nil end def test_validate_ignores_null_sub_objects : Nil object = Entity.new @metadata.add_property_constraint "sub_object", AVD::Constraints::Valid.new self.validate(object).should be_empty end def test_validate_only_traversal_cascaded_hash : Nil object = Entity.new object.hash_sub_object = {"key" => SubEntity.new} callback = AVD::Constraints::Callback::CallbackProc.new do |_value, context, _payload| context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end @metadata.add_property_constraint "hash_sub_object", AVD::Constraints::Callback.new callback: AVD::Constraints::Callback::CallbackProc.new { }, groups: ["group"] @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group"] self.validate(object, groups: "group").should be_empty end {% for method in ["add_property_constraint", "add_getter_constraint"] %} {% type = method.gsub(/add_/, "").id %} def test_validate_hash_sub_object_{{type}} : Nil object = Entity.new object.hash_sub_object = {"key" => SubEntity.new} callback = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| context.class_name.should eq SubEntity context.property_name.should be_nil context.property_path.should eq "hash_sub_object[key]" context.group.should eq "group" context.metadata.should eq @sub_object_metadata context.root.should eq object context.value.should eq object.hash_sub_object["key"] value.should eq object.hash_sub_object["key"] context.add_violation "message \{{ value }}", {"\{{ value }}" => "value"} end @metadata.{{method.id}} "hash_sub_object", AVD::Constraints::Valid.new @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group"] violations = self.validate object, groups: "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message \{{ value }}" violation.parameters.should eq({"\{{ value }}" => "value"}) violation.property_path.should eq "hash_sub_object[key]" violation.root.should eq object violation.invalid_value.should eq object.hash_sub_object["key"] violation.plural.should be_nil violation.code.should be_nil end def test_validate_nested_hash_sub_object_{{type}} : Nil object = Entity.new object.nested_hash_sub_object = {2 => {"key" => SubEntity.new}} callback = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| context.class_name.should eq SubEntity context.property_name.should be_nil context.property_path.should eq "nested_hash_sub_object[2][key]" context.group.should eq "group" context.metadata.should eq @sub_object_metadata context.root.should eq object context.value.should eq object.nested_hash_sub_object[2]["key"] value.should eq object.nested_hash_sub_object[2]["key"] context.add_violation "message \{{ value }}", {"\{{ value }}" => "value"} end @metadata.{{method.id}} "nested_hash_sub_object", AVD::Constraints::Valid.new @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group"] violations = self.validate object, groups: "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message \{{ value }}" violation.parameters.should eq({"\{{ value }}" => "value"}) violation.property_path.should eq "nested_hash_sub_object[2][key]" violation.root.should eq object violation.invalid_value.should eq object.nested_hash_sub_object[2]["key"] violation.plural.should be_nil violation.code.should be_nil end def test_validate_hash_traversal_cannot_be_disabled_{{type}} : Nil object = Entity.new object.hash_sub_object = {"key" => SubEntity.new} callback = AVD::Constraints::Callback::CallbackProc.new do |_value, context, _payload| context.add_violation "message \{{ value }}", {"\{{ value }}" => "value"} end @metadata.{{method.id}} "hash_sub_object", AVD::Constraints::Valid.new traverse: false @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group"] self.validate(object, groups: "group").size.should eq 1 end def test_validate_nested_hash_traversal_cannot_be_disabled_{{type}} : Nil object = Entity.new object.nested_hash_sub_object = {2 => {"key" => SubEntity.new}} callback = AVD::Constraints::Callback::CallbackProc.new do |_value, context, _payload| context.add_violation "message \{{ value }}", {"\{{ value }}" => "value"} end @metadata.{{method.id}} "nested_hash_sub_object", AVD::Constraints::Valid.new traverse: false @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group"] self.validate(object, groups: "group").size.should eq 1 end def test_validate_ignore_scalars_during_array_traversal_{{type}} : Nil object = Entity.new object.scalar_array = ["string", 1234] @metadata.{{method.id}} "scalar_array", AVD::Constraints::Valid.new self.validate(object, groups: "group").should be_empty end def test_validate_ignore_null_during_array_traversal_{{type}} : Nil object = Entity.new object.nil_array = [nil] @metadata.{{method.id}} "nil_array", AVD::Constraints::Valid.new self.validate(object, groups: "group").should be_empty end {% end %} def test_validate_property : Nil object = Entity.new object.first_name = "Jon" object.last_name = "Snow" callback = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| property_metadatas = @metadata.property_metadata "first_name" context.class_name.should eq Entity context.property_name.should eq "first_name" context.property_path.should eq "first_name" context.group.should eq "group" context.metadata.should eq property_metadatas.first context.root.should eq object context.value.should eq "Jon" value.should eq "Jon" context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end callback2 = AVD::Constraints::Callback::CallbackProc.new do |_value, context, _payload| context.add_violation "other violation" end @metadata.add_property_constraint "first_name", AVD::Constraints::Callback.new callback: callback, groups: ["group"] @metadata.add_property_constraint "last_name", AVD::Constraints::Callback.new callback: callback2, groups: ["group"] violations = self.validate_property object, "first_name", "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should eq "first_name" violation.root.should eq object violation.invalid_value.should eq "Jon" violation.plural.should be_nil violation.code.should be_nil end def test_validate_property_no_constraints : Nil self.validate_property(Entity.new, "last_name").should be_empty end def test_validate_property_value : Nil object = Entity.new object.last_name = "Snow" callback = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| property_metadatas = @metadata.property_metadata "first_name" context.class_name.should eq Entity context.property_name.should eq "first_name" context.property_path.should eq "first_name" context.group.should eq "group" context.metadata.should eq property_metadatas.first context.root.should eq object context.value.should eq "Jon" value.should eq "Jon" context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end callback2 = AVD::Constraints::Callback::CallbackProc.new do |_value, context, _payload| context.add_violation "other violation" end @metadata.add_property_constraint "first_name", AVD::Constraints::Callback.new callback: callback, groups: ["group"] @metadata.add_property_constraint "last_name", AVD::Constraints::Callback.new callback: callback2, groups: ["group"] violations = self.validate_property_value object, "first_name", "Jon", "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should eq "first_name" violation.root.should eq object violation.invalid_value.should eq "Jon" violation.plural.should be_nil violation.code.should be_nil end def test_validate_property_value_no_constraints : Nil self.validate_property_value(Entity.new, "last_name", "foo").should be_empty end def ptest_validate_object_only_once_per_group : Nil object = Entity.new object.sub_object = object.sub_object2 = SubEntity.new callback = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "message" end @metadata.add_property_constraint "sub_object", AVD::Constraints::Valid.new @metadata.add_property_constraint "sub_object2", AVD::Constraints::Valid.new @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback self.validate(object).size.should eq 1 end def test_validate_different_objects_separately : Nil object = Entity.new object.sub_object = SubEntity.new object.sub_object2 = SubEntity.new callback = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "message" end @metadata.add_property_constraint "sub_object", AVD::Constraints::Valid.new @metadata.add_property_constraint "sub_object2", AVD::Constraints::Valid.new @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback self.validate(object).size.should eq 2 end def test_validate_single_group : Nil object = Entity.new callback = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "message" end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group1"] @metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group2"] self.validate(object, groups: "group1").size.should eq 1 end def test_validate_multiple_groups : Nil object = Entity.new callback = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "message" end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group1"] @metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group2"] self.validate(object, groups: ["group1", "group2"]).size.should eq 2 end def test_validate_replace_default_group_by_sequence_object : Nil object = Entity.new callback1 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "group2 message" end callback2 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "group3 message" end @metadata.add_constraint AVD::Constraints::Callback.new callback: AVD::Constraints::Callback::CallbackProc.new { }, groups: ["group1"] @metadata.add_constraint AVD::Constraints::Callback.new callback: callback1, groups: ["group2"] @metadata.add_constraint AVD::Constraints::Callback.new callback: callback2, groups: ["group3"] @metadata.group_sequence = AVD::Constraints::GroupSequence.new ["group1", "group2", "group3", "Athena::Validator::Spec::AbstractValidatorTestCase::Entity"] violations = self.validate object, groups: "default" violations.size.should eq 1 violations.first.message.should eq "group2 message" end def test_validate_replace_default_group_by_array : Nil object = Entity.new callback1 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "group2 message" end callback2 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "group3 message" end @metadata.add_constraint AVD::Constraints::Callback.new callback: AVD::Constraints::Callback::CallbackProc.new { }, groups: ["group1"] @metadata.add_constraint AVD::Constraints::Callback.new callback: callback1, groups: ["group2"] @metadata.add_constraint AVD::Constraints::Callback.new callback: callback2, groups: ["group3"] @metadata.group_sequence = ["group1", "group2", "group3", "Athena::Validator::Spec::AbstractValidatorTestCase::Entity"] violations = self.validate object, groups: "default" violations.size.should eq 1 violations.first.message.should eq "group2 message" end def test_validate_propagate_default_group_to_sub_object_when_replacing_default_group : Nil object = Entity.new object.sub_object = SubEntity.new callback1 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "default group message" end callback2 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "group sequence message" end @metadata.add_property_constraint "sub_object", AVD::Constraints::Valid.new @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback1, groups: ["default"] @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback2, groups: ["group1"] @metadata.group_sequence = AVD::Constraints::GroupSequence.new ["group1", "Athena::Validator::Spec::AbstractValidatorTestCase::Entity"] violations = self.validate object, groups: "default" violations.size.should eq 1 violations.first.message.should eq "default group message" end def test_validate_custom_group_when_default_group_was_replaced : Nil object = Entity.new object.sub_object = SubEntity.new callback1 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "other group message" end callback2 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "group sequence message" end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback1, groups: ["other group"] @metadata.add_constraint AVD::Constraints::Callback.new callback: callback2, groups: ["group1"] @metadata.group_sequence = AVD::Constraints::GroupSequence.new ["group1", "Athena::Validator::Spec::AbstractValidatorTestCase::Entity"] violations = self.validate object, groups: "other group" violations.size.should eq 1 violations.first.message.should eq "other group message" end @[DataProvider("get_replace_default_group")] def test_replace_default_group(sequence : Array(String | Array(String)) | AVD::Constraints::GroupSequence, expected_violations : Array) : Nil object, metadata = case sequence in Array m = AVD::Metadata::ClassMetadata(EntitySequenceProvider).new @metadata_factory.add_metadata EntitySequenceProvider, m {EntitySequenceProvider.new(sequence), m} in AVD::Constraints::GroupSequence m = AVD::Metadata::ClassMetadata(EntityGroupSequenceProvider).new @metadata_factory.add_metadata EntityGroupSequenceProvider, m {EntityGroupSequenceProvider.new(sequence), m} end callback1 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "violation in group2" end callback2 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "violation in group3" end metadata.add_constraint AVD::Constraints::Callback.new callback: AVD::Constraints::Callback::CallbackProc.new { }, groups: ["group1"] metadata.add_constraint AVD::Constraints::Callback.new callback: callback1, groups: ["group2"] metadata.add_constraint AVD::Constraints::Callback.new callback: callback2, groups: ["group3"] metadata.group_sequence_provider = true violations = self.validate object, groups: "default" violations.size.should eq expected_violations.size expected_violations.each_with_index do |message, idx| violations[idx].message.should eq message end end def get_replace_default_group : Tuple { { AVD::Constraints::GroupSequence.new(["group1", "group2", "group3", "Athena::Validator::Spec::AbstractValidatorTestCase::Entity"]), ["violation in group2"], }, { ["group1", "group2", "group3", "Athena::Validator::Spec::AbstractValidatorTestCase::Entity"] of String | Array(String), ["violation in group2"], }, { AVD::Constraints::GroupSequence.new(["group1", ["group2", "group3"], "Athena::Validator::Spec::AbstractValidatorTestCase::Entity"]), ["violation in group2", "violation in group3"], }, { ["group1", ["group2", "group3"], "Athena::Validator::Spec::AbstractValidatorTestCase::Entity"], ["violation in group2", "violation in group3"], }, } end end ================================================ FILE: src/components/validator/src/spec/compound_constraint_test_case.cr ================================================ # The `AVD::Constraints::Compound` constraint allows grouping other constraints into a single reusable constraint. # Such as for defining requirements of a user's password. # # This type may be used to more easily test compound constraints. # For example, using the `AVD::Constraints::ValidPassword` constraint in the usage docs for the `Compound` constraint: # # ``` # # The generic should be set to the type(s) that the compound constraint supports. # struct ValidPasswordTest < AVD::Spec::CompoundConstraintTestCase(String?) # protected def create_compound : AVD::Constraints::Compound # AVD::Constraints::ValidPassword.new # end # # def test_valid_password : Nil # self.validate_value "1VeryStr0ngP4$$wOrD" # # self.assert_no_violation # end # # @[TestWith( # nil: {nil, AVD::Constraints::NotBlank.new}, # too_short: {"123", AVD::Constraints::Size.new(12..)}, # letter_first: {"abc12345qwerty", AVD::Constraints::Regex.new(/^\d.*/)}, # )] # def test_invalid_password(password : String?, expected_failing_constraint : AVD::Constraint) : Nil # self.validate_value password # # self.assert_violations_raised_by_compound expected_failing_constraint # end # end # ``` abstract struct Athena::Validator::Spec::CompoundConstraintTestCase(T) < ASPEC::TestCase @validator : AVD::Validator::ValidatorInterface @violation_list : Array(AVD::Violation::ConstraintViolationListInterface)? = nil @context : AVD::ExecutionContextInterface @root : String private getter! validated_value : AVD::ValueContainer(T) private class MockCompoundValidator < AVD::Constraints::Compound def initialize(@tested_constraints : Array(AVD::Constraint)) super() end def constraints : AVD::Constraints::Composite::Type @tested_constraints end end protected def initialize @root = "root" @validator = validator = create_validator @context = create_context validator end # :showdoc: # # Returns the compound constraint instance to be tested. protected abstract def create_compound : AVD::Constraints::Compound # :showdoc: # # Asserts that each of the provided *constraints* were properly raised. protected def assert_violations_raised_by_compound(*constraints : AVD::Constraint) : Nil validator = AVD::Constraints::Compound::Validator.new context = self.create_context validator.context = context validator.validate self.validated_value.value, MockCompoundValidator.new(constraints.to_a.map(&.as(AVD::Constraint))) expected_violations = context.violations expected_violations.should_not be_empty, failure_message: "Expected at least one violation for constraint(s): '#{constraints.join(", ", &.class)}', got none." failed_to_assert_violations = [] of AVD::Violation::ConstraintViolationInterface @context.violations.each_with_index do |violation, idx| if violation != expected_violations[idx]? failed_to_assert_violations << violation end end failed_to_assert_violations.should be_empty, failure_message: "Expected violation(s) for constraint(s) '#{failed_to_assert_violations.join(", ", &.constraint.class)}' to be raised by compound." end # :showdoc: # # Validates the provided *value*, populating the data required for further assertions. protected def validate_value(value : T) : Nil @validated_value = AVD::ValueContainer(T).new(value) @validator.in_context(@context).validate(value, self.create_compound) end # :showdoc: # # Asserts there are *count* violations after calling `#validate_value`. protected def assert_violation_count(count : Int) : Nil @context.violations.size.should eq count end # :showdoc: # # Asserts there are no violations after calling `#validate_value`. protected def assert_no_violation : Nil @context.violations.should be_empty end private def create_validator : AVD::Validator::ValidatorInterface AVD.validator end private def create_context(validator : AVD::Validator::ValidatorInterface? = nil) : AVD::ExecutionContextInterface AVD::ExecutionContext.new validator || create_validator, @root end end ================================================ FILE: src/components/validator/src/spec/constraint_validator_test_case.cr ================================================ # Test case designed to make testing `AVD::ConstraintValidatorInterface` easier. # # ### Example # # Using the spec from `AVD::Constraints::NotNil`: # # ``` # # Makes for a bit less typing when needing to reference the constraint. # private alias CONSTRAINT = AVD::Constraints::NotNil # # # Define our test case inheriting from the abstract ConstraintValidatorTestCase. # struct NotNilValidatorTest < AVD::Spec::ConstraintValidatorTestCase # @[DataProvider("valid_values")] # def test_valid_values(value : _) : Nil # # Validate the value against a new instance of the constraint. # self.validator.validate value, self.new_constraint # # # Assert no violations were added to the context. # self.assert_no_violation # end # # # Use data providers to reduce duplication. # def valid_values : NamedTuple # { # string: {""}, # bool_false: {false}, # bool_true: {true}, # zero: {0}, # null_pointer: {Pointer(Void).null}, # } # end # # def test_nil_is_invalid # # Validate an invalid value against a new instance of the constraint with a custom message. # self.validator.validate nil, self.new_constraint message: "my_message" # # # Assert a violation with the expected message, code, and value parameter is added to the context. # self # .build_violation("my_message", CONSTRAINT::IS_NULL_ERROR, nil) # .assert_violation # end # # # Implement some abstract defs to return the validator and constraint class. # private def create_validator : AVD::ConstraintValidatorInterface # CONSTRAINT::Validator.new # end # # private def constraint_class : AVD::Constraint.class # CONSTRAINT # end # end # ``` # # This type is an extension of `ASPEC::TestCase`, see that type for more information on this testing approach. # This approach also allows using `ASPEC::TestCase::DataProvider`s for reducing duplication within your test. abstract struct Athena::Validator::Spec::ConstraintValidatorTestCase < ASPEC::TestCase # Used to assert that a violation added via the `AVD::ConstraintValidatorInterface` was built as expected. # # NOTE: This type should not be instantiated directly, use `AVD::Spec::ConstraintValidatorTestCase#build_violation` instead. record Assertion, context : AVD::ExecutionContextInterface, message : String, constraint : AVD::Constraint do @parameters : Hash(String, String) = Hash(String, String).new @invalid_value : AVD::Container = AVD::ValueContainer.new("invalid_value") @property_path : String = "property.path" @plural : Int32? = nil @code : String? = nil @cause : String? = nil # Sets the `AVD::Violation::ConstraintViolationInterface#property_path` on the expected violation. # # Returns `self` for chaining. def at_path(@property_path : String) : self self end # Adds the provided *key* *value* pair to the expected violations' `AVD::Violation::ConstraintViolationInterface#parameters`. # # Returns `self` for chaining. def add_parameter(key : String, value : _) : self @parameters[key] = value.to_s self end # Sets the `AVD::Violation::ConstraintViolationInterface#invalid_value` on the expected violation. # # Returns `self` for chaining. def invalid_value(value : _) : self @invalid_value = AVD::ValueContainer.new value self end # Sets the `AVD::Violation::ConstraintViolationInterface#plural` on the expected violation. # # Returns `self` for chaining. def plural(@plural : Int32) : self self end # Sets the `AVD::Violation::ConstraintViolationInterface#code` on the expected violation. # # Returns `self` for chaining. def code(@code : String?) : self self end # Sets the `AVD::Violation::ConstraintViolationInterface#cause` on the expected violation. # # Returns `self` for chaining. def cause(@cause : String?) : self self end # Asserts that the violation added to the context equals the violation built via `self`. def assert_violation(*, file : String = __FILE__, line : Int32 = __LINE__) : Nil expected_violations = [self.get_violation] of AVD::Violation::ConstraintViolationInterface violations = @context.violations violations.size.should eq(1), failure_message: "Expected 1 violation, got #{violations.size}." expected_violations.each_with_index do |expected_violation, idx| actual_violation = violations[idx] # This is derived from `AVD::Violation::ConstraintViolation#==(other : AVD::Violation::ConstraintViolationInterface)` # but is broken out here to make knowing _what_ wasn't equal easier to identity within spec failures. self.assert_equals "message", actual_violation.message, expected_violation.message, file: file, line: line self.assert_equals "message_template", actual_violation.message_template, expected_violation.message_template, file: file, line: line self.assert_equals "parameters", actual_violation.parameters, expected_violation.parameters, file: file, line: line self.assert_equals "root_container", actual_violation.root_container, expected_violation.root_container, file: file, line: line self.assert_equals "property_path", actual_violation.property_path, expected_violation.property_path, file: file, line: line self.assert_equals "invalid_value_container", actual_violation.invalid_value_container, expected_violation.invalid_value_container, file: file, line: line self.assert_equals "plural", actual_violation.plural, expected_violation.plural, file: file, line: line self.assert_equals "code", actual_violation.code, expected_violation.code, file: file, line: line self.assert_equals "constraint", actual_violation.constraint, expected_violation.constraint, file: file, line: line self.assert_equals "cause", actual_violation.cause, expected_violation.cause, file: file, line: line end end private def get_violation AVD::Violation::ConstraintViolation.new( @message, @message, @parameters, @context.root_container, @property_path, @invalid_value, @plural, @code, @constraint, @cause ) end private def assert_equals(property : String, actual : _, expected : _, file : String, line : Int32) : Nil actual.should eq(expected), failure_message: "Expected #{property} to be: #{expected}, got: #{actual}", file: file, line: line end end # :nodoc: class AssertingContextualValidator include AVD::Validator::ContextualValidatorInterface record Expectation, value : String | Int32 | Nil, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil, constraints : Proc(Array(AVD::Constraint) | AVD::Constraint | Nil, Nil), violation : AVD::Violation::ConstraintViolationInterface? = nil @context : AVD::ExecutionContextInterface @expect_no_validate = false @at_path_calls = -1 @expected_at_path = [] of String? @validate_calls = -1 @expected_validate = [] of Expectation? def initialize(@context : AVD::ExecutionContextInterface); end def at_path(path : String) : AVD::Validator::ContextualValidatorInterface @expect_no_validate.should be_false, failure_message: "No validation calls have been expected." unless expected_path = @expected_at_path[@at_path_calls += 1]? fail "Validation for property path '#{path}' was not expected." end @expected_at_path[@at_path_calls] = nil path.should eq expected_path self end def validate(value : _, constraints : Array(AVD::Constraint) | AVD::Constraint | Nil = nil, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface @expect_no_validate.should be_false, failure_message: "No validation calls have been expected." unless expectation = @expected_validate[@validate_calls += 1]? return self end @expected_validate[@validate_calls] = nil value.should eq expectation.value expectation.constraints.call constraints expectation.groups.should eq groups if v = expectation.violation @context.add_violation v.message, v.parameters end self end def validate_property(object : AVD::Validatable, property_name : String, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface self end def validate_property_value(object : AVD::Validatable, property_name : String, value : _, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface self end def violations : AVD::Violation::ConstraintViolationListInterface @context.violations end def expect_no_validate : Nil @expect_no_validate = true end def expect_validation( call : Int32, property_path : String?, value : _, group : Array(String) | String | AVD::Constraints::GroupSequence | Nil, violation : AVD::Violation::ConstraintViolationInterface? = nil, &block : Array(AVD::Constraint) | AVD::Constraint | Nil -> Nil ) if property_path @expected_at_path.insert call, property_path end @expected_validate.insert call, Expectation.new value, group, block, violation end end @group : String @metadata : Nil = nil @object : Nil = nil @value : String | Array(String) @root : String @property_path : String @constraint : AVD::Constraint @context : AVD::ExecutionContext? @validator : AVD::ConstraintValidatorInterface? @expected_violations : Array(AVD::Violation::ConstraintViolationListInterface) @call : Int32 protected def initialize @group = "my_group" @value = "invalid_value" @root = "root" @property_path = "property.path" @constraint = AVD::Constraints::NotBlank.new @expected_violations = Array(AVD::Violation::ConstraintViolationListInterface).new @call = 0 ctx = self.create_context validator = self.create_validator validator.context = ctx @context = ctx @validator = validator end # Returns a new validator instance for the constraint being tested. abstract def create_validator : AVD::ConstraintValidatorInterface # Returns the class of the constraint being tested. abstract def constraint_class : AVD::Constraint.class # Returns a new constraint instance based on `#constraint_class` and the provided *args*. def new_constraint(**args) : AVD::Constraint self.constraint_class.new **args end # Asserts that no violations were added to the context. def assert_no_violation(*, file : String = __FILE__, line : Int32 = __LINE__) : Nil unless (violation_count = self.context.violations.size).zero? fail "0 violations expected but got #{violation_count}.", file, line end end # Asserts a violation with the provided *message* was added to the context. def assert_violation(message : String) : Nil self.build_violation(message).assert_violation end # Asserts a violation with the provided provided *message*, and *code* was added to the context. def assert_violation(message : String, code : String) : Nil self.build_violation(message, code).assert_violation end # Asserts a violation with the provided *message*, *code*, and *value* parameter was added to the context. def assert_violation(message : String, code : String, value : _) : Nil self.build_violation(message, code, value).assert_violation end # Returns an `AVD::Spec::ConstraintValidatorTestCase::Assertion` with the provided *message* preset. def build_violation(message : String) : AVD::Spec::ConstraintValidatorTestCase::Assertion Assertion.new self.context, message, @constraint end # Returns an `AVD::Spec::ConstraintValidatorTestCase::Assertion` with the provided *message*, and *code* preset. def build_violation(message : String, code : String) : AVD::Spec::ConstraintValidatorTestCase::Assertion self.build_violation(message).code(code) end # Returns an `AVD::Spec::ConstraintValidatorTestCase::Assertion` with the provided *message*, *code*, and *value* parameter preset. def build_violation(message : String, code : String, value : _) : AVD::Spec::ConstraintValidatorTestCase::Assertion self.build_violation(message).code(code).add_parameter("{{ value }}", value) end # Asserts that a validation within a specific context occurs with the provided *property_path*, *value*, *constraints*, and optionally *groups*. # # See `CollectionValidatorTestCase` for an example. def expect_validate_value_at( idx : Int32, property_path : String, value : _, constraints : Array(AVD::Constraint) | AVD::Constraint, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil, ) raise "BUG: Null context" unless c = @context contextual_validator = c.validator.in_context(c).as AssertingContextualValidator contextual_validator.expect_validation idx, property_path, value, groups do |passed_constraints| constraints.should eq passed_constraints end end # Can be used to have a nested validator return the correct violations when used within another validator. # # Creates a separate validation context, validating the provided *value* against the provided *constraint*, # causing the resulting violations to be returned from the inner validator as they would be in a non-test context. # # See `AVD::Constraints::ISIN::Validator`, and its related specs, for an example. def expect_violation_at(idx : Int, value : _, constraint : AVD::Constraint) : AVD::Violation::ConstraintViolationListInterface ctx = self.create_context validator = constraint.validated_by.new validator.context = ctx validator.validate value, constraint @expected_violations << ctx.violations ctx.violations end # Overrides the value/node currently being validated. def value=(value) : Nil @value = value self.context.set_node(@value, @object, @metadata, @property_path) end # Returns a reference to the context used for the current test. def context : AVD::ExecutionContext @context.not_nil! end # Returns the validator instance returned via `#create_validator`. def validator : AVD::ConstraintValidatorInterface @validator.not_nil! end private def create_context : AVD::ExecutionContext validator = MockValidator.new do (@expected_violations[@call]? || AVD::Violation::ConstraintViolationList.new).tap { @call += 1 } end ctx = AVD::ExecutionContext.new validator, @root ctx.group = @group ctx.set_node @value, @object, @metadata, @property_path ctx.constraint = @constraint contextual_validator = AssertingContextualValidator.new ctx validator.contextual_validator = contextual_validator ctx end end ================================================ FILE: src/components/validator/src/spec/validator_test_case.cr ================================================ # :nodoc: abstract struct Athena::Validator::Spec::ValidatorTestCase < AVD::Spec::AbstractValidatorTestCase getter! validator : AVD::Validator::ValidatorInterface def initialize super @validator = self.create_validator @metadata_factory end abstract def create_validator(metadata_factory : AVD::Metadata::MetadataFactoryInterface) : AVD::Validator::ValidatorInterface def validate(value, constraints = nil, groups = nil) : AVD::Violation::ConstraintViolationListInterface self.validator.validate value, constraints, groups end def validate_property(object, property_name, groups = nil) : AVD::Violation::ConstraintViolationListInterface self.validator.validate_property object, property_name, groups end def validate_property_value(object, property_name, value, groups = nil) : AVD::Violation::ConstraintViolationListInterface self.validator.validate_property_value object, property_name, value, groups end def test_validate_constraint_without_group : Nil self.validate(nil, AVD::Constraints::NotNil.new).size.should eq 1 end def test_validate_empty_array_as_constraint : Nil self.validate(nil, [] of AVD::Constraint).should be_empty end def test_validate_group_sequence_aborts_after_failed_group : Nil object = Entity.new callback1 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "message1" end callback2 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "message2" end @metadata.add_constraint AVD::Constraints::Callback.new callback: AVD::Constraints::Callback::CallbackProc.new { }, groups: ["group1"] @metadata.add_constraint AVD::Constraints::Callback.new callback: callback1, groups: ["group2"] @metadata.add_constraint AVD::Constraints::Callback.new callback: callback2, groups: ["group3"] violations = self.validate object, AVD::Constraints::Valid.new, AVD::Constraints::GroupSequence.new(["group1", "group2", "group3"]) violations.size.should eq 1 violations.first.message.should eq "message1" end def test_validate_group_sequence_includes_sub_objects : Nil object = Entity.new object.sub_object = SubEntity.new callback1 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "message1" end callback2 = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "message2" end @metadata.add_property_constraint "sub_object", AVD::Constraints::Valid.new @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback1, groups: ["group1"] @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback2, groups: ["group2"] violations = self.validate object, AVD::Constraints::Valid.new, AVD::Constraints::GroupSequence.new(["group1", "Athena::Validator::Spec::AbstractValidatorTestCase::Entity"]) violations.size.should eq 1 violations.first.message.should eq "message1" end def test_validate_in_separate_context : Nil object = Entity.new object.sub_object = SubEntity.new callback1 = AVD::Constraints::Callback::CallbackProc.new do |value, context| value = value.get Entity violations = context.validator.validate value.sub_object, AVD::Constraints::Valid.new, "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should be_empty violation.root.should eq value.sub_object violation.invalid_value.should eq value.sub_object violation.plural.should be_nil violation.code.should be_nil context.add_violation "different violation" end callback2 = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| context.class_name.should eq SubEntity context.property_name.should be_nil context.property_path.should be_empty context.group.should eq "group" context.root.should eq object.sub_object context.value.should eq object.sub_object value.should eq object.sub_object context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback1, groups: "group" @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback2, groups: "group" violations = self.validate object, AVD::Constraints::Valid.new, "group" violations.size.should eq 1 violations.first.message.should eq "different violation" end def test_validate_in_context : Nil object = Entity.new object.sub_object = SubEntity.new callback1 = AVD::Constraints::Callback::CallbackProc.new do |value, context| previous_value = context.value previous_object = context.object previous_metadata = context.metadata previous_path = context.property_path previous_group = context.group context .validator .in_context(context) .at_path("subpath") .validate(value.get(Entity).sub_object) # Context changes shouldn't leak from #validate. previous_value.should eq context.value previous_object.should eq context.object previous_metadata.should eq context.metadata previous_path.should eq context.property_path previous_group.should eq context.group end callback2 = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| context.class_name.should eq SubEntity context.property_name.should be_nil context.property_path.should eq "subpath" context.group.should eq "group" context.metadata.should eq @sub_object_metadata context.root.should eq object context.value.should eq object.sub_object value.should eq object.sub_object context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback1, groups: "group" @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback2, groups: "group" violations = self.validate object, AVD::Constraints::Valid.new, "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should eq "subpath" violation.root.should eq object violation.invalid_value.should eq object.sub_object violation.plural.should be_nil violation.code.should be_nil end def test_validate_hash_in_context : Nil object = Entity.new object.sub_object = SubEntity.new callback1 = AVD::Constraints::Callback::CallbackProc.new do |value, context| previous_value = context.value previous_object = context.object previous_metadata = context.metadata previous_path = context.property_path previous_group = context.group context .validator .in_context(context) .at_path("subpath") .validate({"key" => value.get(Entity).sub_object}) # Context changes shouldn't leak from #validate. previous_value.should eq context.value previous_object.should eq context.object previous_metadata.should eq context.metadata previous_path.should eq context.property_path previous_group.should eq context.group end callback2 = AVD::Constraints::Callback::CallbackProc.new do |value, context, _payload| context.class_name.should eq SubEntity context.property_name.should be_nil context.property_path.should eq "subpath[key]" context.group.should eq "group" context.metadata.should eq @sub_object_metadata context.root.should eq object context.value.should eq object.sub_object value.should eq object.sub_object context.add_violation "message {{ value }}", {"{{ value }}" => "value"} end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback1, groups: "group" @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback2, groups: "group" violations = self.validate object, AVD::Constraints::Valid.new, "group" violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should eq "subpath[key]" violation.root.should eq object violation.invalid_value.should eq object.sub_object violation.plural.should be_nil violation.code.should be_nil end def test_validate_sub_object_with_cascade_disabled_by_default : Nil object = Entity.new object.sub_object = SubEntity.new callback = AVD::Constraints::Callback::CallbackProc.new do |_value, _context| fail "Callback should not have been invoked" end @sub_object_metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: "group" self.validate(object, AVD::Constraints::Valid.new, "group").should be_empty end def test_validate_customized_violation : Nil object = Entity.new callback = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context .build_violation("message {{ value }}", "CODE", "value") .plural(2) .invalid_value("Invalid Value") .add end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback violations = self.validate object violations.size.should eq 1 violation = violations.first violation.message.should eq "message value" violation.message_template.should eq "message {{ value }}" violation.parameters.should eq({"{{ value }}" => "value"}) violation.property_path.should be_empty violation.root.should eq object violation.invalid_value.should eq "Invalid Value" violation.plural.should eq 2 violation.code.should eq "CODE" end def ptest_validate_no_duplicate_violations_if_class_constraint_is_in_multiple_groups : Nil object = Entity.new callback = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "message" end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback, groups: ["group1", "group2"] self.validate(object, AVD::Constraints::Valid.new, groups: ["group1", "group2"]).size.should eq 1 end def ptest_validate_no_duplicate_violations_if_property_constraint_is_in_multiple_groups : Nil object = Entity.new callback = AVD::Constraints::Callback::CallbackProc.new do |_value, context| context.add_violation "message" end @metadata.add_property_constraint "first_name", AVD::Constraints::Callback.new callback: callback, groups: ["group1", "group2"] self.validate(object, AVD::Constraints::Valid.new, groups: ["group1", "group2"]).size.should eq 1 end def test_validate_fails_non_object_array_and_no_constraints : Nil expect_raises ArgumentError, "Could not validate values of type 'String' automatically. Please provide a constraint." do self.validate "Foo" end end def test_validate_access_current_object : Nil called = false object = Entity.new object.first_name = "Fred" object.data_hash = {"first_name" => "Jon"} callback = AVD::Constraints::Callback::CallbackProc.new do |_value, context| called = true context.object.should eq object end @metadata.add_constraint AVD::Constraints::Callback.new callback: callback @metadata.add_property_constraint "first_name", AVD::Constraints::Callback.new callback: callback @metadata.add_property_constraints({"data_hash" => AVD::Constraints::EqualTo.new({"first_name" => "Jon"})}) self.validate object called.should be_true end def test_validate_constraint_is_passed_to_violation : Nil constraint = FailingConstraint.new violations = self.validate "foo", constraint violations.size.should eq 1 violations.first.constraint.should eq constraint end def test_validate_sub_object_is_not_validated_if_group_in_valid_constraint_is_not_validated : Nil object = Entity.new object.first_name = "" sub_object = SubEntity.new sub_object.value = "" object.sub_object = sub_object @metadata.add_property_constraint "first_name", AVD::Constraints::NotBlank.new groups: "group1" @metadata.add_property_constraint "sub_object", AVD::Constraints::Valid.new groups: "group1" @sub_object_metadata.add_property_constraint "value", AVD::Constraints::NotBlank.new self.validate(object, nil, [] of String).should be_empty end def test_validate_sub_object_is_validated_if_group_in_valid_constraint_is_valided : Nil object = Entity.new object.first_name = "" sub_object = SubEntity.new sub_object.value = "" object.sub_object = sub_object @metadata.add_property_constraint "first_name", AVD::Constraints::NotBlank.new groups: "group1" @metadata.add_property_constraint "sub_object", AVD::Constraints::Valid.new groups: "group1" @sub_object_metadata.add_property_constraint "value", AVD::Constraints::NotBlank.new groups: "group1" self.validate(object, nil, ["default", "group1"]).size.should eq 2 end def test_validate_sub_object_is_valided_in_multiple_groups_if_group_in_valid_constraint_is_validated : Nil object = Entity.new object.first_name = nil sub_object = SubEntity.new sub_object.value = nil object.sub_object = sub_object @metadata.add_property_constraint "first_name", AVD::Constraints::NotBlank.new @metadata.add_property_constraint "sub_object", AVD::Constraints::Valid.new groups: ["group1", "group2"] @sub_object_metadata.add_property_constraint "value", AVD::Constraints::NotBlank.new groups: "group1" @sub_object_metadata.add_property_constraint "value", AVD::Constraints::NotNil.new groups: "group2" self.validate(object, nil, ["default", "group1", "group2"]).size.should eq 3 end end ================================================ FILE: src/components/validator/src/spec.cr ================================================ require "athena-spec" require "./spec/abstract_validator_test_case" require "./spec/compound_constraint_test_case" require "./spec/constraint_validator_test_case" require "./spec/validator_test_case" # A set of testing utilities/types to aid in testing `Athena::Validator` related types. # # ### Getting Started # # Require this module in your `spec_helper.cr` file. # # ``` # # This also requires "spec" and "athena-spec". # require "athena-validator/spec" # ``` # # Add `Athena::Spec` as a development dependency, then run a `shards install`. # See the individual types for more information. module Athena::Validator::Spec # Extension of `AVD::Spec::ConstraintValidatorTestCase` used for testing `AVD::Constraints::AbstractComparison` based constraints. # # ### Example # # Using the spec from `AVD::Constraints::EqualTo`: # # ``` # # Makes for a bit less typing when needing to reference the constraint. # private alias CONSTRAINT = AVD::Constraints::EqualTo # # # Define our test case inheriting from the abstract `ComparisonConstraintValidatorTestCase`. # struct EqualToValidatorTest < AVD::Spec::ComparisonConstraintValidatorTestCase # # Returns a Tuple of Tuples representing valid comparisons. # # The first item is the actual value and the second item is the expected value. # def valid_comparisons : Tuple # { # {3, 3}, # {'a', 'a'}, # {"a", "a"}, # {Time.utc(2020, 4, 7), Time.utc(2020, 4, 7)}, # {nil, false}, # } # end # # # Returns a Tuple of Tuples representing invalid comparisons. # # The first item is the actual value and the second item is the expected value. # def invalid_comparisons : Tuple # { # {1, 3}, # {'b', 'a'}, # {"b", "a"}, # {Time.utc(2020, 4, 8), Time.utc(2020, 4, 7)}, # } # end # # # The error code related to the current CONSTRAINT. # def error_code : String # CONSTRAINT::NOT_EQUAL_ERROR # end # # # Implement some abstract defs to return the validator and constraint class. # def create_validator : AVD::ConstraintValidatorInterface # CONSTRAINT::Validator.new # end # # def constraint_class : AVD::Constraint.class # CONSTRAINT # end # end # ``` abstract struct ComparisonConstraintValidatorTestCase < ConstraintValidatorTestCase # A `Tuple` of tuples representing valid comparisons. abstract def valid_comparisons : Tuple # A `Tuple` of tuples representing invalid comparisons. abstract def invalid_comparisons : Tuple # The code for the current constraint. abstract def error_code : String @[DataProvider("valid_comparisons")] def test_valid_comparisons(actual, expected) : Nil self.validator.validate actual, self.new_constraint value: expected self.assert_no_violation end @[DataProvider("invalid_comparisons")] def test_invalid_comparisons(actual, expected : T) : Nil forall T self.validator.validate actual, self.new_constraint value: expected, message: "my_message" self .build_violation("my_message", self.error_code, actual) .add_parameter("{{ compared_value }}", expected) .add_parameter("{{ compared_value_type }}", T) .assert_violation end end # A spec implementation of `AVD::Validator::ContextualValidatorInterface`. # # Allows settings the violations that should be returned, defaulting to no violations. class MockContextualValidator include Athena::Validator::Validator::ContextualValidatorInterface setter violations : AVD::Violation::ConstraintViolationListInterface def initialize(@violations : AVD::Violation::ConstraintViolationListInterface = AVD::Violation::ConstraintViolationList.new); end # :inherit: def at_path(path : String) : AVD::Validator::ContextualValidatorInterface self end # :inherit: def validate(value : _, constraints : Array(AVD::Constraint) | AVD::Constraint | Nil = nil, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface self end # :inherit: def validate_property(object : AVD::Validatable, property_name : String, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface self end # :inherit: def validate_property_value(object : AVD::Validatable, property_name : String, value : _, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface self end # :inherit: def violations : AVD::Violation::ConstraintViolationListInterface @violations end end # A spec implementation of `AVD::Validator::ValidatorInterface`. # # Allows settings the violations that should be returned, defaulting to no violations. # Also allows providing a block that is called for each validated value. # E.g. to allow dynamically configuring the returned violations after it is instantiated. class MockValidator include Athena::Validator::Validator::ValidatorInterface setter violations_callback : Proc(AVD::Violation::ConstraintViolationListInterface) protected property! contextual_validator : AVD::Validator::ContextualValidatorInterface? def self.new(violations : AVD::Violation::ConstraintViolationListInterface = AVD::Violation::ConstraintViolationList.new) : self new &-> { violations.as AVD::Violation::ConstraintViolationListInterface } end def initialize( &@violations_callback : -> AVD::Violation::ConstraintViolationListInterface ); end # :inherit: def validate(value : _, constraints : Array(AVD::Constraint) | AVD::Constraint | Nil = nil, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Violation::ConstraintViolationListInterface @violations_callback.call end # :inherit: def validate_property(object : AVD::Validatable, property_name : String, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Violation::ConstraintViolationListInterface @violations_callback.call end # :inherit: def validate_property_value(object : AVD::Validatable, property_name : String, value : _, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Violation::ConstraintViolationListInterface @violations_callback.call end # :inherit: def start_context(root = nil) : AVD::Validator::ContextualValidatorInterface @contextual_validator || MockContextualValidator.new @violations_callback.call end # :inherit: def in_context(context : AVD::ExecutionContextInterface) : AVD::Validator::ContextualValidatorInterface @contextual_validator || MockContextualValidator.new @violations_callback.call end end # A spec implementation of `AVD::Metadata::MetadataFactoryInterface`, supporting a fixed number of additional metadatas struct MockMetadataFactory(T1, T2, T3, T4, T5) include AVD::Metadata::MetadataFactoryInterface @metadatas = Hash(AVD::Validatable::Class, AVD::Metadata::ClassMetadata(T1) | AVD::Metadata::ClassMetadata(T2) | AVD::Metadata::ClassMetadata(T3) | AVD::Metadata::ClassMetadata(T4) | AVD::Metadata::ClassMetadata(T5)).new def metadata(object : AVD::Validatable) : AVD::Metadata::ClassMetadata if metadata = @metadatas[object.class]? return metadata end object.class.validation_class_metadata end def add_metadata(klass : AVD::Validatable::Class, metadata : AVD::Metadata::ClassMetadata) : Nil @metadatas[klass] = metadata end end # A constraint that always adds a violation. class FailingConstraint < AVD::Constraint def initialize( message : String = "Failed", groups : Array(String) | String | Nil = nil, payload : Hash(String, String)? = nil, ) super message, groups, payload end class Validator < AVD::ConstraintValidator def validate(value : _, constraint : FailingConstraint) : Nil self.context.add_violation constraint.message end end end # An `AVD::Validatable` entity using an `Array` based group sequence. record EntitySequenceProvider, sequence : Array(String | Array(String)) do include AVD::Validatable include AVD::Constraints::GroupSequence::Provider def group_sequence : Array(String | Array(String)) | AVD::Constraints::GroupSequence @sequence || AVD::Constraints::GroupSequence.new [] of String end end # An `AVD::Validatable` entity using an `AVD::Constraints::GroupSequence` based group sequence. record EntityGroupSequenceProvider, sequence : AVD::Constraints::GroupSequence do include AVD::Validatable include AVD::Constraints::GroupSequence::Provider def group_sequence : Array(String | Array(String)) | AVD::Constraints::GroupSequence @sequence || Array(String | Array(String)).new end end end ================================================ FILE: src/components/validator/src/validatable.cr ================================================ # When included, denotes that a type (class or struct) can be validated via `Athena::Validator`. # # ### Example # # ``` # class Example # include AVD::Validatable # # def initialize(@name : String); end # # @[Assert::NotBlank] # property name : String # end # # AVD.validator.validate Example.new("Jim") # ``` module Athena::Validator::Validatable # :nodoc: module Class; end macro included extend AVD::Validatable::Class macro inherited include AVD::Validatable end {% unless @type.abstract? %} class_getter validation_class_metadata : AVD::Metadata::ClassMetadata(self) { AVD::Metadata::ClassMetadata(self).build } {% end %} end end ================================================ FILE: src/components/validator/src/validator/contextual_validator_interface.cr ================================================ # A validator that validates in a specific `AVD::ExecutionContextInterface` instance. module Athena::Validator::Validator::ContextualValidatorInterface # Appends the provided *path* to the current `AVD::ExecutionContextInterface#property_path`. abstract def at_path(path : String) : AVD::Validator::ContextualValidatorInterface # Validates the provided *value*, optionally against the provided *constraints*, optionally using the provided *groups*. # `AVD::Constraint::DEFAULT_GROUP` is assumed if no *groups* are provided. abstract def validate(value : _, constraints : Array(AVD::Constraint) | AVD::Constraint | Nil = nil, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface # Validates a property of the provided *object* against the constraints defined for that property, optionally using the provided *groups*. # `AVD::Constraint::DEFAULT_GROUP` is assumed if no *groups* are provided. abstract def validate_property(object : AVD::Validatable, property_name : String, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface # Validates a value against the constraints defined on the property of the provided *object*. # `AVD::Constraint::DEFAULT_GROUP` is assumed if no *groups* are provided. abstract def validate_property_value(object : AVD::Validatable, property_name : String, value : _, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface # Returns any violations that have been generated so far in the context of `self`. abstract def violations : AVD::Violation::ConstraintViolationListInterface end ================================================ FILE: src/components/validator/src/validator/recursive_contextual_validator.cr ================================================ # A recursive implementation of `AVD::Validator::ContextualValidatorInterface`. # # See `Athena::Validator.validator`. class Athena::Validator::Validator::RecursiveContextualValidator private alias GroupsTypes = Array(String) | Array(String | AVD::Constraints::GroupSequence) include Athena::Validator::Validator::ContextualValidatorInterface @default_groups : Array(String) @default_property_path : String def initialize( @context : AVD::ExecutionContextInterface, @constraint_validator_factory : AVD::ConstraintValidatorFactoryInterface, @metadata_factory : AVD::Metadata::MetadataFactoryInterface, ) @default_groups = [(g = @context.group) ? g : Constraint::DEFAULT_GROUP] @default_property_path = @context.property_path end # :inherit: def at_path(path : String) : AVD::Validator::ContextualValidatorInterface @default_property_path = @context.property_path path self end # :inherit: def validate(value : _, constraints : Array(AVD::Constraint) | AVD::Constraint | Nil = nil, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface groups = self.normalize_groups groups previous_value = @context.value previous_object = @context.object previous_metadata = @context.metadata previous_path = @context.property_path previous_group = @context.group previous_constraint = @context.is_a?(AVD::ExecutionContext) ? @context.constraint : nil # Validate the value against explicitly passed constraints unless constraints.nil? constraints = constraints.is_a?(Array) ? constraints : [constraints] metadata = AVD::Metadata::Metadata.new metadata.add_constraints constraints self.validate_generic_node( value, previous_object, metadata, @default_property_path, groups, nil, @context ) @context.set_node previous_value, previous_object, previous_metadata, previous_path @context.group = previous_group unless previous_constraint.nil? @context.constraint = previous_constraint end return self end case value when AVD::Validatable self.validate_object( value, @default_property_path, groups, @context ) @context.set_node previous_value, previous_object, previous_metadata, previous_path @context.group = previous_group self when Iterable, Hash self.validate_each_object_in( value, @default_property_path, groups, @context ) @context.set_node previous_value, previous_object, previous_metadata, previous_path @context.group = previous_group self when Athena::HTTP::UploadedFile # Won't result in violations, but is still supported explicitly. self else raise AVD::Exception::InvalidArgument.new "Could not validate values of type '#{value.class}' automatically. Please provide a constraint." end end # :inherit: def validate_property(object : AVD::Validatable, property_name : String, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface groups = self.normalize_groups groups class_metadata = @metadata_factory.metadata object property_metadatas = class_metadata.property_metadata property_name property_path = AVD::PropertyPath.append @default_property_path, property_name previous_value = @context.value previous_object = @context.object previous_metadata = @context.metadata previous_path = @context.property_path previous_group = @context.group property_metadatas.each do |property_metadata| property_value = property_metadata.value object self.validate_generic_node( property_value, object, property_metadata, property_path, groups, nil, @context ) end @context.set_node previous_value, previous_object, previous_metadata, previous_path @context.group = previous_group self end # :inherit: def validate_property_value(object : AVD::Validatable, property_name : String, value : _, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Validator::ContextualValidatorInterface groups = self.normalize_groups groups class_metadata = @metadata_factory.metadata object property_metadatas = class_metadata.property_metadata property_name property_path = AVD::PropertyPath.append @default_property_path, property_name previous_value = @context.value previous_object = @context.object previous_metadata = @context.metadata previous_path = @context.property_path previous_group = @context.group property_metadatas.each do |property_metadata| self.validate_generic_node( value, object, property_metadata, property_path, groups, nil, @context ) end @context.set_node previous_value, previous_object, previous_metadata, previous_path @context.group = previous_group self end # :inherit: def violations : AVD::Violation::ConstraintViolationListInterface @context.violations end private def validate_each_object_in( collection : Iterable, property_path : String, groups : GroupsTypes, context : AVD::ExecutionContextInterface, ) collection.each_with_index do |item, idx| case item when Iterable, Hash then self.validate_each_object_in(item, "#{property_path}[#{idx}]", groups, context) when AVD::Validatable then self.validate_object(item, "#{property_path}[#{idx}]", groups, context) end end end private def validate_each_object_in( collection : Hash, property_path : String, groups : GroupsTypes, context : AVD::ExecutionContextInterface, ) collection.each do |key, value| case value when Iterable, Hash then self.validate_each_object_in(value, "#{property_path}[#{key}]", groups, context) when AVD::Validatable then self.validate_object(value, "#{property_path}[#{key}]", groups, context) end end end private def validate_generic_node( value : _, object : _, metadata : AVD::Metadata::MetadataInterface?, property_path : String, groups : GroupsTypes, cascaded_groups : Array(String)?, context : AVD::ExecutionContextInterface, ) context.set_node value, object, metadata, property_path groups.each_with_index do |group, idx| if group.is_a? AVD::Constraints::GroupSequence self.step_through_group_sequence( value, object, metadata, property_path, group, nil, context ) groups.delete_at idx next end self.validate_in_group value, metadata, group, context end return if groups.empty? return if value.nil? return unless metadata.cascading_strategy.cascade? cascaded_groups = !cascaded_groups.nil? && cascaded_groups.size > 0 ? cascaded_groups : groups case value when Iterable self.validate_each_object_in( value, property_path, cascaded_groups, context ) when AVD::Validatable self.validate_object( value, property_path, cascaded_groups, context ) end end private def validate_object(object : AVD::Validatable, property_path : String, groups : GroupsTypes, context : AVD::ExecutionContextInterface) : Nil self.validate_class_node( object, @metadata_factory.metadata(object), property_path, groups, nil, context ) end private def validate_class_node( object : AVD::Validatable, class_metadata : AVD::Metadata::ClassMetadata, property_path : String, groups : GroupsTypes, cascaded_groups : Array(String)?, context : AVD::ExecutionContextInterface, ) : Nil context.set_node object, object, class_metadata, property_path groups.each_with_index do |group, idx| # Handle cascading to the "default" group if a GroupSequence is used. default_overridden = false # Replace the "default" group by the group sequence if applicable. if AVD::Constraint::DEFAULT_GROUP == group if group_sequence = class_metadata.group_sequence group = group_sequence default_overridden = true elsif object.is_a? AVD::Constraints::GroupSequence::Provider group = object.group_sequence default_overridden = true unless group.is_a? AVD::Constraints::GroupSequence group = AVD::Constraints::GroupSequence.new group end end end if group.is_a? AVD::Constraints::GroupSequence self.step_through_group_sequence( object, object, class_metadata, property_path, group, default_overridden ? AVD::Constraint::DEFAULT_GROUP : nil, context ) groups.delete_at idx next end # TODO: Can cache validated groups here if needed in the future self.validate_in_group object, class_metadata, group, context end return if groups.empty? class_metadata.constrained_properties.each do |property_name| # A constraint can be applied to a property and getter of that property, # thus resulting in two metadata objects being returned. class_metadata.property_metadata(property_name).each do |property_metadata| property_value = property_metadata.value object self.validate_generic_node( property_value, object, property_metadata, AVD::PropertyPath.append(property_path, property_name), groups, cascaded_groups, context ) end end return unless object.is_a? Iterable self.validate_each_object_in( object, property_path, groups, context ) end private def step_through_group_sequence( value : _, object : _, metadata : AVD::Metadata::MetadataInterface?, property_path : String, group_sequence : AVD::Constraints::GroupSequence, cascaded_groups : String?, context : AVD::ExecutionContextInterface, ) : Nil violation_count = context.violations.size cascaded_groups = cascaded_groups ? [cascaded_groups] : nil group_sequence.groups.each do |group_in_sequence| groups = group_in_sequence.is_a?(Array) ? group_in_sequence : [group_in_sequence] if metadata.is_a? AVD::Metadata::ClassMetadata self.validate_class_node( value, metadata, property_path, groups, cascaded_groups, context ) else self.validate_generic_node( value, object, metadata, property_path, groups, cascaded_groups, context ) end # Don't validate future groups if a violation was generated break if context.violations.size > violation_count end end private def validate_in_group(value : _, metadata : AVD::Metadata::MetadataInterface, group : String, context : AVD::ExecutionContextInterface) : Nil context.group = group metadata.find_constraints(group).each do |constraint| # TODO: Can cache validated groups here if needed in the future context.constraint = constraint validator = @constraint_validator_factory.validator constraint.validated_by validator.context = context validator.validate value, constraint rescue ex : AVD::Exception::UnexpectedValueError context.add_violation "This value should be a valid: {{ type }}", {"{{ type }}" => ex.supported_types} end end private def normalize_groups(groups) : GroupsTypes case groups in Nil then @default_groups in String, AVD::Constraints::GroupSequence then [groups] of String | AVD::Constraints::GroupSequence in Array then groups end end end ================================================ FILE: src/components/validator/src/validator/recursive_validator.cr ================================================ require "../constraint_validator_factory_interface" # A recursive implementation of `AVD::Validator::ValidatorInterface`. # # See `Athena::Validator.validator`. class Athena::Validator::Validator::RecursiveValidator include Athena::Validator::Validator::ValidatorInterface @validator_factory : AVD::ConstraintValidatorFactoryInterface @metadata_factory : AVD::Metadata::MetadataFactoryInterface def initialize(validator_factory : AVD::ConstraintValidatorFactoryInterface? = nil, metadata_factory : AVD::Metadata::MetadataFactoryInterface? = nil) @validator_factory = validator_factory || AVD::ConstraintValidatorFactory.new @metadata_factory = metadata_factory || AVD::Metadata::MetadataFactory.new end # :inherit: def validate(value : _, constraints : Array(AVD::Constraint) | AVD::Constraint | Nil = nil, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Violation::ConstraintViolationListInterface start_context(value).validate(value, constraints, groups).violations end # :inherit: def validate_property(object : AVD::Validatable, property_name : String, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Violation::ConstraintViolationListInterface start_context(object).validate_property(object, property_name, groups).violations end # :inherit: def validate_property_value(object : AVD::Validatable, property_name : String, value : _, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Violation::ConstraintViolationListInterface start_context(object).validate_property_value(object, property_name, value, groups).violations end # :inherit: def start_context(root = nil) : AVD::Validator::ContextualValidatorInterface AVD::Validator::RecursiveContextualValidator.new create_context(root), @validator_factory, @metadata_factory end # :inherit: def in_context(context : AVD::ExecutionContextInterface) : AVD::Validator::ContextualValidatorInterface AVD::Validator::RecursiveContextualValidator.new context, @validator_factory, @metadata_factory end private def create_context(root = nil) : AVD::ExecutionContextInterface AVD::ExecutionContext.new self, root end end ================================================ FILE: src/components/validator/src/validator/validator_interface.cr ================================================ module Athena::Validator::Validator::ValidatorInterface # Validates the provided *value*, optionally against the provided *constraints*, optionally using the provided *groups*. # `AVD::Constraint::DEFAULT_GROUP` is assumed if no *groups* are provided. abstract def validate(value : _, constraints : Array(AVD::Constraint) | AVD::Constraint | Nil = nil, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Violation::ConstraintViolationListInterface # Validates a property of the provided *object* against the constraints defined for that property, optionally using the provided *groups*. # `AVD::Constraint::DEFAULT_GROUP` is assumed if no *groups* are provided. abstract def validate_property(object : AVD::Validatable, property_name : String, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Violation::ConstraintViolationListInterface # Validates a value against the constraints defined on the property of the provided *object*. # `AVD::Constraint::DEFAULT_GROUP` is assumed if no *groups* are provided. abstract def validate_property_value(object : AVD::Validatable, property_name : String, value : _, groups : Array(String) | String | AVD::Constraints::GroupSequence | Nil = nil) : AVD::Violation::ConstraintViolationListInterface # Creates a new `AVD::ExecutionContextInterface` and returns a new validator for that context. # # Violations generated by the returned validator can be accessed via `AVD::Validator::ContextualValidatorInterface#violations`. abstract def start_context : AVD::Validator::ContextualValidatorInterface # Returns a validator in the provided *context*. # # Violations generated by the returned validator are added to the provided *context*. abstract def in_context(context : AVD::ExecutionContextInterface) : AVD::Validator::ContextualValidatorInterface end ================================================ FILE: src/components/validator/src/violation/constraint_violation.cr ================================================ require "./constraint_violation_interface" # Basic implementation of `AVD::Violation::ConstraintViolationInterface`. struct Athena::Validator::Violation::ConstraintViolation include Athena::Validator::Violation::ConstraintViolationInterface protected getter invalid_value_container : AVD::Container protected getter root_container : AVD::Container # :inherit: getter cause : String? # :inherit: getter code : String? # :inherit: getter! constraint : AVD::Constraint # :inherit: getter message : String # :inherit: getter message_template : String? # :inherit: getter parameters : Hash(String, String) # :inherit: getter plural : Int32? # :inherit: getter property_path : String def initialize( @message : String, @message_template : String?, @parameters : Hash(String, String), root : _, @property_path : String, @invalid_value_container : AVD::Container, @plural : Int32? = nil, @code : String? = nil, @constraint : AVD::Constraint? = nil, @cause : String? = nil, ) @root_container = root.is_a?(AVD::Container) ? root : AVD::ValueContainer.new(root) end # :inherit: def invalid_value @invalid_value_container.value end # :inherit: def root @root_container.value end # :inherit: def to_json(builder : JSON::Builder) : Nil builder.object do builder.field "property", @property_path builder.field "message", @message if code = @code builder.field "code", code end end end # :inherit: def inspect(io : IO) : Nil io << "#" end # :inherit: def to_s(io : IO) : Nil klass = case self.root when Hash then "Hash" when AVD::Validatable, Enumerable then "Object(#{self.root.class})" else self.root.to_s end klass += '.' if !@property_path.blank? && !@property_path.starts_with?('[') && !klass.blank? if (c = code) && !c.blank? code = " (code: #{c})" end io << klass io << @property_path io << ':' << '\n' << '\t' io << @message io << code io << '\n' end # Returns `true` if *other* is the same as `self`, otherwise `false`. def ==(other : AVD::Violation::ConstraintViolationInterface) : Bool @message == other.message && @message_template == other.message_template && @parameters == other.parameters && @root_container == other.root_container && @property_path == other.property_path && @invalid_value_container == other.invalid_value_container && @plural == other.plural && @code == other.code && @constraint == other.constraint? && @cause == other.cause end end ================================================ FILE: src/components/validator/src/violation/constraint_violation_builder.cr ================================================ require "./constraint_violation_builder_interface" # Basic implementation of `AVD::Violation::ConstraintViolationBuilderInterface`. class Athena::Validator::Violation::ConstraintViolationBuilder include Athena::Validator::Violation::ConstraintViolationBuilderInterface @plural : Int32? @cause : String? protected def initialize( @violations : AVD::Violation::ConstraintViolationListInterface, @constraint : AVD::Constraint?, @message : String, @parameters : Hash(String, String), @root_container : AVD::Container, @property_path : String, @invalid_value : AVD::Container, ) end # :inherit: def add : Nil # Split and determine the message to use based on plural value translated_message = if !(count = @plural).nil? && @message.includes? '|' parts = @message.split('|') # TODO: Support more robust translations count == 1 ? parts.first : parts[1] else @message end rendered_message = translated_message.gsub(/(?:{{ \w+ }})+/, @parameters) @violations.add AVD::Violation::ConstraintViolation.new( rendered_message, @message, @parameters, @root_container, @property_path, @invalid_value, @plural, @code, @constraint, @cause ) end # :inherit: def add_parameter(key : String, value : _) : AVD::Violation::ConstraintViolationBuilderInterface @parameters[key] = value.to_s self end # :inherit: def at_path(path : String) : AVD::Violation::ConstraintViolationBuilderInterface @property_path = AVD::PropertyPath.append @property_path, path self end # :inherit: def cause(@cause : String?) : AVD::Violation::ConstraintViolationBuilderInterface self end # :inherit: def code(@code : String?) : AVD::Violation::ConstraintViolationBuilderInterface self end # :inherit: def constraint(@constraint : AVD::Constraint?) : AVD::Violation::ConstraintViolationBuilderInterface self end # :inherit: def invalid_value(value : _) : AVD::Violation::ConstraintViolationBuilderInterface @invalid_value = AVD::ValueContainer.new value self end # :inherit: def plural(number : Int32) : AVD::Violation::ConstraintViolationBuilderInterface @plural = number self end # :inherit: def set_parameters(@parameters : Hash(String, String)) : AVD::Violation::ConstraintViolationBuilderInterface self end end ================================================ FILE: src/components/validator/src/violation/constraint_violation_builder_interface.cr ================================================ # A [Builder Pattern](https://en.wikipedia.org/wiki/Builder_pattern) type for building `AVD::Violation::ConstraintViolationInterface`s. # # Allows using the methods defined on `self` to construct the desired violation before adding it to the context. module Athena::Validator::Violation::ConstraintViolationBuilderInterface # Adds the violation to the current `AVD::ExecutionContextInterface`. abstract def add : Nil # Adds a parameter with the provided *key* and *value* to the violations' `AVD::Violation::ConstraintViolationInterface#parameters`. # The provided *value* is stringified via `#to_s` before being added to the parameters. # # Returns `self` for chaining. abstract def add_parameter(key : String, value : _) : AVD::Violation::ConstraintViolationBuilderInterface # Sets the `AVD::Violation::ConstraintViolationInterface#property_path`. # # Returns `self` for chaining. abstract def at_path(path : String) : AVD::Violation::ConstraintViolationBuilderInterface # Sets the `AVD::Violation::ConstraintViolationInterface#cause` # # Returns `self` for chaining. abstract def cause(cause : String?) : AVD::Violation::ConstraintViolationBuilderInterface # Sets the `AVD::Violation::ConstraintViolationInterface#code` # # Returns `self` for chaining. abstract def code(code : String?) : AVD::Violation::ConstraintViolationBuilderInterface # Sets the `AVD::Violation::ConstraintViolationInterface#constraint` # # Returns `self` for chaining. abstract def constraint(constraint : AVD::Constraint?) : AVD::Violation::ConstraintViolationBuilderInterface # Sets the `AVD::Violation::ConstraintViolationInterface#invalid_value` # # Returns `self` for chaining. abstract def invalid_value(value : _) : AVD::Violation::ConstraintViolationBuilderInterface # Sets `AVD::Violation::ConstraintViolationInterface#plural` # # Returns `self` for chaining. abstract def plural(number : Int32) : AVD::Violation::ConstraintViolationBuilderInterface # Overrides the entire `AVD::Violation::ConstraintViolationInterface#parameters` hash with the provided *parameters*. # # Returns `self` for chaining. abstract def set_parameters(parameters : Hash(String, String)) : AVD::Violation::ConstraintViolationBuilderInterface end ================================================ FILE: src/components/validator/src/violation/constraint_violation_interface.cr ================================================ # Represents a violation of a constraint during validation. # # Each failed constraint that fails during validation; one or more violations are created. # The violations store the violation message, the path to the failing element, # and the root element originally passed to the validator. module Athena::Validator::Violation::ConstraintViolationInterface # Returns the cause of the violation. abstract def cause : String? # Returns a unique machine readable error code representing `self.` # All constraints of a specific "type" should have the same code. abstract def code : String? # Returns the `AVD::Constraint` whose validation caused the violation, if any. abstract def constraint : AVD::Constraint? # Returns the value that caused the violation. abstract def invalid_value # Returns the violation message. abstract def message : String # Returns the raw violation message. # # The message template contains placeholders for the parameters returned via `#parameters`. abstract def message_template : String? # Returns the parameters used to render the `#message_template`. abstract def parameters : Hash(String, String) # Returns a number used to pluralize the violation message. # # The returned value is used to determine the right plurlaization form. abstract def plural : Int32? # Returns the path from the root element to the violation. abstract def property_path : String # Returns the element originally passed to the validator. abstract def root # Returns a `JSON` representation of `self`. abstract def to_json(builder : JSON::Builder) : Nil # Returns a string representation of `self`. abstract def to_s(io : IO) : Nil end ================================================ FILE: src/components/validator/src/violation/constraint_violation_list.cr ================================================ require "./constraint_violation_list_interface" # Basic implementation of `AVD::Violation::ConstraintViolationListInterface`. struct Athena::Validator::Violation::ConstraintViolationList include Athena::Validator::Violation::ConstraintViolationListInterface include Indexable(Athena::Validator::Violation::ConstraintViolationInterface) @violations : Array(AVD::Violation::ConstraintViolationInterface) = [] of AVD::Violation::ConstraintViolationInterface def initialize(violations : Array(AVD::Violation::ConstraintViolationInterface) = [] of AVD::Violation::ConstraintViolationInterface) violations.each do |violation| add violation end end # Returns a new `AVD::Violation::ConstraintViolationInterface` that consists only of violations with the provided *error_code*. def find_by_code(error_code : String) : AVD::Violation::ConstraintViolationListInterface self.class.new @violations.select &.code.==(error_code) end # :inherit: def add(violation : AVD::Violation::ConstraintViolationInterface) : Nil @violations << violation end # :inherit: def add(violations : AVD::Violation::ConstraintViolationListInterface) : Nil @violations.concat violations end # :inherit: def has?(index : Int) : Bool !@violations[index]?.nil? end # :inherit: def remove(index : Int) : Nil @violations.delete_at index end # :inherit: def set(index : Int, violation : AVD::Violation::ConstraintViolationInterface) : Nil @violations[index] = violation end # :inherit: def size : Int @violations.size end # :inherit: def to_json(builder : JSON::Builder) : Nil builder.array do @violations.each do |violation| violation.to_json builder end end end # :inherit: def inspect(io : IO) : Nil io << "#" end # :inherit: def to_s(io : IO) : Nil @violations.each do |violation| violation.to_s io end end # :nodoc: @[AlwaysInline] def unsafe_fetch(index : Int) : AVD::Violation::ConstraintViolationInterface @violations[index] end end ================================================ FILE: src/components/validator/src/violation/constraint_violation_list_interface.cr ================================================ # A wrapper type around an `Array(AVD::ConstraintViolationInterface)`. module Athena::Validator::Violation::ConstraintViolationListInterface # Adds the provided *violation* to `self`. abstract def add(violation : AVD::Violation::ConstraintViolationInterface) : Nil # Adds each of the provided *violations* to `self`. abstract def add(violations : AVD::Violation::ConstraintViolationListInterface) : Nil # Returns `true` if a violation exists at the provided *index*, otherwise `false`. abstract def has?(index : Int) : Bool # Sets the provided *violation* at the provided *index*. abstract def set(index : Int, violation : AVD::Violation::ConstraintViolationInterface) : Nil # Returns the number of violations in `self`. abstract def size : Int # Returns the violation at the provided *index*. abstract def remove(index : Int) : Nil # Returns a `JSON` representation of `self`. abstract def to_json(builder : JSON::Builder) : Nil # Returns a string representation of `self`. abstract def to_s(io : IO) : Nil end