diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..e2c55a2
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,865 @@
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = tab
+insert_final_newline = false
+max_line_length = 150
+tab_width = 4
+ij_continuation_indent_size = 8
+ij_formatter_off_tag = @formatter:off
+ij_formatter_on_tag = @formatter:on
+ij_formatter_tags_enabled = true
+ij_smart_tabs = false
+ij_visual_guides =
+ij_wrap_on_typing = false
+
+[*.blade.php]
+indent_style = space
+ij_blade_keep_indents_on_empty_lines = false
+
+[*.css]
+indent_size = 2
+indent_style = space
+tab_width = 2
+ij_continuation_indent_size = 2
+ij_css_align_closing_brace_with_properties = false
+ij_css_blank_lines_around_nested_selector = 0
+ij_css_blank_lines_between_blocks = 0
+ij_css_block_comment_add_space = false
+ij_css_brace_placement = end_of_line
+ij_css_enforce_quotes_on_format = false
+ij_css_hex_color_long_format = false
+ij_css_hex_color_lower_case = true
+ij_css_hex_color_short_format = true
+ij_css_hex_color_upper_case = false
+ij_css_keep_blank_lines_in_code = 2
+ij_css_keep_indents_on_empty_lines = false
+ij_css_keep_single_line_blocks = true
+ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
+ij_css_space_after_colon = true
+ij_css_space_before_opening_brace = true
+ij_css_use_double_quotes = true
+ij_css_value_alignment = do_not_align
+
+[*.feature]
+indent_size = 2
+indent_style = space
+ij_gherkin_keep_indents_on_empty_lines = false
+
+[*.less]
+indent_size = 2
+indent_style = space
+ij_less_align_closing_brace_with_properties = false
+ij_less_blank_lines_around_nested_selector = 1
+ij_less_blank_lines_between_blocks = 1
+ij_less_block_comment_add_space = false
+ij_less_brace_placement = 0
+ij_less_enforce_quotes_on_format = false
+ij_less_hex_color_long_format = false
+ij_less_hex_color_lower_case = false
+ij_less_hex_color_short_format = false
+ij_less_hex_color_upper_case = false
+ij_less_keep_blank_lines_in_code = 2
+ij_less_keep_indents_on_empty_lines = false
+ij_less_keep_single_line_blocks = false
+ij_less_line_comment_add_space = false
+ij_less_line_comment_at_first_column = false
+ij_less_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
+ij_less_space_after_colon = true
+ij_less_space_before_opening_brace = true
+ij_less_use_double_quotes = true
+ij_less_value_alignment = 0
+
+[*.markdown]
+indent_style = space
+ij_markdown_force_one_space_after_blockquote_symbol = true
+ij_markdown_force_one_space_after_header_symbol = true
+ij_markdown_force_one_space_after_list_bullet = true
+ij_markdown_force_one_space_between_words = true
+ij_markdown_format_tables = true
+ij_markdown_insert_quote_arrows_on_wrap = true
+ij_markdown_keep_indents_on_empty_lines = false
+ij_markdown_keep_line_breaks_inside_text_blocks = true
+ij_markdown_max_lines_around_block_elements = 1
+ij_markdown_max_lines_around_header = 1
+ij_markdown_max_lines_between_paragraphs = 1
+ij_markdown_min_lines_around_block_elements = 1
+ij_markdown_min_lines_around_header = 1
+ij_markdown_min_lines_between_paragraphs = 1
+ij_markdown_wrap_text_if_long = true
+ij_markdown_wrap_text_inside_blockquotes = true
+
+[*.properties]
+ij_properties_align_group_field_declarations = false
+ij_properties_keep_blank_lines = false
+ij_properties_key_value_delimiter = equals
+ij_properties_spaces_around_key_value_delimiter = false
+
+[*.sass]
+indent_size = 2
+indent_style = space
+ij_sass_align_closing_brace_with_properties = false
+ij_sass_blank_lines_around_nested_selector = 1
+ij_sass_blank_lines_between_blocks = 1
+ij_sass_brace_placement = 0
+ij_sass_enforce_quotes_on_format = false
+ij_sass_hex_color_long_format = false
+ij_sass_hex_color_lower_case = false
+ij_sass_hex_color_short_format = false
+ij_sass_hex_color_upper_case = false
+ij_sass_keep_blank_lines_in_code = 2
+ij_sass_keep_indents_on_empty_lines = false
+ij_sass_keep_single_line_blocks = false
+ij_sass_line_comment_add_space = false
+ij_sass_line_comment_at_first_column = false
+ij_sass_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
+ij_sass_space_after_colon = true
+ij_sass_space_before_opening_brace = true
+ij_sass_use_double_quotes = true
+ij_sass_value_alignment = 0
+
+[*.scss]
+indent_size = 2
+indent_style = space
+ij_scss_align_closing_brace_with_properties = false
+ij_scss_blank_lines_around_nested_selector = 1
+ij_scss_blank_lines_between_blocks = 1
+ij_scss_block_comment_add_space = false
+ij_scss_brace_placement = 0
+ij_scss_enforce_quotes_on_format = false
+ij_scss_hex_color_long_format = false
+ij_scss_hex_color_lower_case = false
+ij_scss_hex_color_short_format = false
+ij_scss_hex_color_upper_case = false
+ij_scss_keep_blank_lines_in_code = 2
+ij_scss_keep_indents_on_empty_lines = false
+ij_scss_keep_single_line_blocks = false
+ij_scss_line_comment_add_space = false
+ij_scss_line_comment_at_first_column = false
+ij_scss_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow
+ij_scss_space_after_colon = true
+ij_scss_space_before_opening_brace = true
+ij_scss_use_double_quotes = true
+ij_scss_value_alignment = 0
+
+[*.twig]
+indent_style = space
+ij_twig_keep_indents_on_empty_lines = false
+ij_twig_spaces_inside_comments_delimiters = true
+ij_twig_spaces_inside_delimiters = true
+ij_twig_spaces_inside_variable_delimiters = true
+
+[*.vue]
+indent_size = 2
+indent_style = space
+tab_width = 2
+ij_continuation_indent_size = 4
+ij_vue_indent_children_of_top_level = template
+ij_vue_interpolation_new_line_after_start_delimiter = true
+ij_vue_interpolation_new_line_before_end_delimiter = true
+ij_vue_interpolation_wrap = off
+ij_vue_keep_indents_on_empty_lines = false
+ij_vue_spaces_within_interpolation_expressions = true
+
+[.editorconfig]
+ij_editorconfig_align_group_field_declarations = false
+ij_editorconfig_space_after_colon = false
+ij_editorconfig_space_after_comma = true
+ij_editorconfig_space_before_colon = false
+ij_editorconfig_space_before_comma = false
+ij_editorconfig_spaces_around_assignment_operators = true
+
+[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul,phpunit.xml.dist}]
+indent_size = 2
+indent_style = space
+tab_width = 2
+ij_continuation_indent_size = 2
+ij_xml_align_attributes = true
+ij_xml_align_text = false
+ij_xml_attribute_wrap = normal
+ij_xml_block_comment_add_space = false
+ij_xml_block_comment_at_first_column = true
+ij_xml_keep_blank_lines = 2
+ij_xml_keep_indents_on_empty_lines = false
+ij_xml_keep_line_breaks = true
+ij_xml_keep_line_breaks_in_text = true
+ij_xml_keep_whitespaces = false
+ij_xml_keep_whitespaces_around_cdata = preserve
+ij_xml_keep_whitespaces_inside_cdata = false
+ij_xml_line_comment_at_first_column = true
+ij_xml_space_after_tag_name = false
+ij_xml_space_around_equals_in_attribute = false
+ij_xml_space_inside_empty_tag = false
+ij_xml_text_wrap = normal
+
+[{*.ats,*.cts,*.mts,*.ts}]
+indent_style = space
+ij_continuation_indent_size = 4
+ij_typescript_align_imports = false
+ij_typescript_align_multiline_array_initializer_expression = false
+ij_typescript_align_multiline_binary_operation = false
+ij_typescript_align_multiline_chained_methods = false
+ij_typescript_align_multiline_extends_list = false
+ij_typescript_align_multiline_for = true
+ij_typescript_align_multiline_parameters = true
+ij_typescript_align_multiline_parameters_in_calls = false
+ij_typescript_align_multiline_ternary_operation = false
+ij_typescript_align_object_properties = 0
+ij_typescript_align_union_types = false
+ij_typescript_align_var_statements = 0
+ij_typescript_array_initializer_new_line_after_left_brace = false
+ij_typescript_array_initializer_right_brace_on_new_line = false
+ij_typescript_array_initializer_wrap = off
+ij_typescript_assignment_wrap = off
+ij_typescript_binary_operation_sign_on_next_line = false
+ij_typescript_binary_operation_wrap = off
+ij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**
+ij_typescript_blank_lines_after_imports = 1
+ij_typescript_blank_lines_around_class = 1
+ij_typescript_blank_lines_around_field = 0
+ij_typescript_blank_lines_around_field_in_interface = 0
+ij_typescript_blank_lines_around_function = 1
+ij_typescript_blank_lines_around_method = 1
+ij_typescript_blank_lines_around_method_in_interface = 1
+ij_typescript_block_brace_style = end_of_line
+ij_typescript_block_comment_add_space = false
+ij_typescript_block_comment_at_first_column = true
+ij_typescript_call_parameters_new_line_after_left_paren = false
+ij_typescript_call_parameters_right_paren_on_new_line = false
+ij_typescript_call_parameters_wrap = off
+ij_typescript_catch_on_new_line = false
+ij_typescript_chained_call_dot_on_new_line = true
+ij_typescript_class_brace_style = end_of_line
+ij_typescript_class_decorator_wrap = split_into_lines
+ij_typescript_class_field_decorator_wrap = off
+ij_typescript_class_method_decorator_wrap = off
+ij_typescript_comma_on_new_line = false
+ij_typescript_do_while_brace_force = never
+ij_typescript_else_on_new_line = false
+ij_typescript_enforce_trailing_comma = keep
+ij_typescript_enum_constants_wrap = on_every_item
+ij_typescript_extends_keyword_wrap = off
+ij_typescript_extends_list_wrap = off
+ij_typescript_field_prefix = _
+ij_typescript_file_name_style = relaxed
+ij_typescript_finally_on_new_line = false
+ij_typescript_for_brace_force = never
+ij_typescript_for_statement_new_line_after_left_paren = false
+ij_typescript_for_statement_right_paren_on_new_line = false
+ij_typescript_for_statement_wrap = off
+ij_typescript_force_quote_style = false
+ij_typescript_force_semicolon_style = false
+ij_typescript_function_expression_brace_style = end_of_line
+ij_typescript_function_parameter_decorator_wrap = off
+ij_typescript_if_brace_force = never
+ij_typescript_import_merge_members = global
+ij_typescript_import_prefer_absolute_path = global
+ij_typescript_import_sort_members = true
+ij_typescript_import_sort_module_name = false
+ij_typescript_import_use_node_resolution = true
+ij_typescript_imports_wrap = on_every_item
+ij_typescript_indent_case_from_switch = true
+ij_typescript_indent_chained_calls = true
+ij_typescript_indent_package_children = 0
+ij_typescript_jsdoc_include_types = false
+ij_typescript_jsx_attribute_value = braces
+ij_typescript_keep_blank_lines_in_code = 2
+ij_typescript_keep_first_column_comment = true
+ij_typescript_keep_indents_on_empty_lines = false
+ij_typescript_keep_line_breaks = true
+ij_typescript_keep_simple_blocks_in_one_line = false
+ij_typescript_keep_simple_methods_in_one_line = false
+ij_typescript_line_comment_add_space = true
+ij_typescript_line_comment_at_first_column = false
+ij_typescript_method_brace_style = end_of_line
+ij_typescript_method_call_chain_wrap = off
+ij_typescript_method_parameters_new_line_after_left_paren = false
+ij_typescript_method_parameters_right_paren_on_new_line = false
+ij_typescript_method_parameters_wrap = off
+ij_typescript_object_literal_wrap = on_every_item
+ij_typescript_object_types_wrap = on_every_item
+ij_typescript_parentheses_expression_new_line_after_left_paren = false
+ij_typescript_parentheses_expression_right_paren_on_new_line = false
+ij_typescript_place_assignment_sign_on_next_line = false
+ij_typescript_prefer_as_type_cast = false
+ij_typescript_prefer_explicit_types_function_expression_returns = false
+ij_typescript_prefer_explicit_types_function_returns = false
+ij_typescript_prefer_explicit_types_vars_fields = false
+ij_typescript_prefer_parameters_wrap = false
+ij_typescript_property_prefix =
+ij_typescript_reformat_c_style_comments = false
+ij_typescript_space_after_colon = true
+ij_typescript_space_after_comma = true
+ij_typescript_space_after_dots_in_rest_parameter = false
+ij_typescript_space_after_generator_mult = true
+ij_typescript_space_after_property_colon = true
+ij_typescript_space_after_quest = true
+ij_typescript_space_after_type_colon = true
+ij_typescript_space_after_unary_not = false
+ij_typescript_space_before_async_arrow_lparen = true
+ij_typescript_space_before_catch_keyword = true
+ij_typescript_space_before_catch_left_brace = true
+ij_typescript_space_before_catch_parentheses = true
+ij_typescript_space_before_class_lbrace = true
+ij_typescript_space_before_class_left_brace = true
+ij_typescript_space_before_colon = true
+ij_typescript_space_before_comma = false
+ij_typescript_space_before_do_left_brace = true
+ij_typescript_space_before_else_keyword = true
+ij_typescript_space_before_else_left_brace = true
+ij_typescript_space_before_finally_keyword = true
+ij_typescript_space_before_finally_left_brace = true
+ij_typescript_space_before_for_left_brace = true
+ij_typescript_space_before_for_parentheses = true
+ij_typescript_space_before_for_semicolon = false
+ij_typescript_space_before_function_left_parenth = true
+ij_typescript_space_before_generator_mult = false
+ij_typescript_space_before_if_left_brace = true
+ij_typescript_space_before_if_parentheses = true
+ij_typescript_space_before_method_call_parentheses = false
+ij_typescript_space_before_method_left_brace = true
+ij_typescript_space_before_method_parentheses = false
+ij_typescript_space_before_property_colon = false
+ij_typescript_space_before_quest = true
+ij_typescript_space_before_switch_left_brace = true
+ij_typescript_space_before_switch_parentheses = true
+ij_typescript_space_before_try_left_brace = true
+ij_typescript_space_before_type_colon = false
+ij_typescript_space_before_unary_not = false
+ij_typescript_space_before_while_keyword = true
+ij_typescript_space_before_while_left_brace = true
+ij_typescript_space_before_while_parentheses = true
+ij_typescript_spaces_around_additive_operators = true
+ij_typescript_spaces_around_arrow_function_operator = true
+ij_typescript_spaces_around_assignment_operators = true
+ij_typescript_spaces_around_bitwise_operators = true
+ij_typescript_spaces_around_equality_operators = true
+ij_typescript_spaces_around_logical_operators = true
+ij_typescript_spaces_around_multiplicative_operators = true
+ij_typescript_spaces_around_relational_operators = true
+ij_typescript_spaces_around_shift_operators = true
+ij_typescript_spaces_around_unary_operator = false
+ij_typescript_spaces_within_array_initializer_brackets = false
+ij_typescript_spaces_within_brackets = false
+ij_typescript_spaces_within_catch_parentheses = false
+ij_typescript_spaces_within_for_parentheses = false
+ij_typescript_spaces_within_if_parentheses = false
+ij_typescript_spaces_within_imports = false
+ij_typescript_spaces_within_interpolation_expressions = false
+ij_typescript_spaces_within_method_call_parentheses = false
+ij_typescript_spaces_within_method_parentheses = false
+ij_typescript_spaces_within_object_literal_braces = false
+ij_typescript_spaces_within_object_type_braces = true
+ij_typescript_spaces_within_parentheses = false
+ij_typescript_spaces_within_switch_parentheses = false
+ij_typescript_spaces_within_type_assertion = false
+ij_typescript_spaces_within_union_types = true
+ij_typescript_spaces_within_while_parentheses = false
+ij_typescript_special_else_if_treatment = true
+ij_typescript_ternary_operation_signs_on_next_line = false
+ij_typescript_ternary_operation_wrap = off
+ij_typescript_union_types_wrap = on_every_item
+ij_typescript_use_chained_calls_group_indents = false
+ij_typescript_use_double_quotes = true
+ij_typescript_use_explicit_js_extension = auto
+ij_typescript_use_import_type = auto
+ij_typescript_use_path_mapping = always
+ij_typescript_use_public_modifier = false
+ij_typescript_use_semicolon_after_statement = true
+ij_typescript_var_declaration_wrap = normal
+ij_typescript_while_brace_force = never
+ij_typescript_while_on_new_line = false
+ij_typescript_wrap_comments = false
+
+[{*.bash,*.sh,*.zsh}]
+indent_size = 2
+indent_style = space
+tab_width = 2
+ij_shell_binary_ops_start_line = false
+ij_shell_keep_column_alignment_padding = false
+ij_shell_minify_program = false
+ij_shell_redirect_followed_by_space = false
+ij_shell_switch_cases_indented = false
+ij_shell_use_unix_line_separator = true
+
+[{*.cjs,*.es6,*.js,*.mjs}]
+max_line_length = 80
+ij_continuation_indent_size = 4
+ij_javascript_align_imports = false
+ij_javascript_align_multiline_array_initializer_expression = false
+ij_javascript_align_multiline_binary_operation = false
+ij_javascript_align_multiline_chained_methods = false
+ij_javascript_align_multiline_extends_list = false
+ij_javascript_align_multiline_for = false
+ij_javascript_align_multiline_parameters = false
+ij_javascript_align_multiline_parameters_in_calls = false
+ij_javascript_align_multiline_ternary_operation = false
+ij_javascript_align_object_properties = 0
+ij_javascript_align_union_types = false
+ij_javascript_align_var_statements = 0
+ij_javascript_array_initializer_new_line_after_left_brace = true
+ij_javascript_array_initializer_right_brace_on_new_line = true
+ij_javascript_array_initializer_wrap = off
+ij_javascript_assignment_wrap = off
+ij_javascript_binary_operation_sign_on_next_line = false
+ij_javascript_binary_operation_wrap = off
+ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**
+ij_javascript_blank_lines_after_imports = 1
+ij_javascript_blank_lines_around_class = 1
+ij_javascript_blank_lines_around_field = 0
+ij_javascript_blank_lines_around_function = 1
+ij_javascript_blank_lines_around_method = 1
+ij_javascript_block_brace_style = end_of_line
+ij_javascript_block_comment_add_space = false
+ij_javascript_block_comment_at_first_column = true
+ij_javascript_call_parameters_new_line_after_left_paren = false
+ij_javascript_call_parameters_right_paren_on_new_line = false
+ij_javascript_call_parameters_wrap = off
+ij_javascript_catch_on_new_line = false
+ij_javascript_chained_call_dot_on_new_line = true
+ij_javascript_class_brace_style = end_of_line
+ij_javascript_class_decorator_wrap = split_into_lines
+ij_javascript_class_field_decorator_wrap = off
+ij_javascript_class_method_decorator_wrap = off
+ij_javascript_comma_on_new_line = false
+ij_javascript_do_while_brace_force = always
+ij_javascript_else_on_new_line = false
+ij_javascript_enforce_trailing_comma = whenmultiline
+ij_javascript_extends_keyword_wrap = off
+ij_javascript_extends_list_wrap = off
+ij_javascript_field_prefix = _
+ij_javascript_file_name_style = relaxed
+ij_javascript_finally_on_new_line = false
+ij_javascript_for_brace_force = always
+ij_javascript_for_statement_new_line_after_left_paren = false
+ij_javascript_for_statement_right_paren_on_new_line = false
+ij_javascript_for_statement_wrap = off
+ij_javascript_force_quote_style = true
+ij_javascript_force_semicolon_style = true
+ij_javascript_function_expression_brace_style = end_of_line
+ij_javascript_function_parameter_decorator_wrap = off
+ij_javascript_if_brace_force = always
+ij_javascript_import_merge_members = global
+ij_javascript_import_prefer_absolute_path = global
+ij_javascript_import_sort_members = true
+ij_javascript_import_sort_module_name = false
+ij_javascript_import_use_node_resolution = true
+ij_javascript_imports_wrap = on_every_item
+ij_javascript_indent_case_from_switch = true
+ij_javascript_indent_chained_calls = true
+ij_javascript_indent_package_children = 0
+ij_javascript_jsx_attribute_value = braces
+ij_javascript_keep_blank_lines_in_code = 1
+ij_javascript_keep_first_column_comment = false
+ij_javascript_keep_indents_on_empty_lines = false
+ij_javascript_keep_line_breaks = true
+ij_javascript_keep_simple_blocks_in_one_line = true
+ij_javascript_keep_simple_methods_in_one_line = true
+ij_javascript_line_comment_add_space = true
+ij_javascript_line_comment_at_first_column = false
+ij_javascript_method_brace_style = end_of_line
+ij_javascript_method_call_chain_wrap = off
+ij_javascript_method_parameters_new_line_after_left_paren = false
+ij_javascript_method_parameters_right_paren_on_new_line = false
+ij_javascript_method_parameters_wrap = off
+ij_javascript_object_literal_wrap = off
+ij_javascript_object_types_wrap = on_every_item
+ij_javascript_parentheses_expression_new_line_after_left_paren = false
+ij_javascript_parentheses_expression_right_paren_on_new_line = false
+ij_javascript_place_assignment_sign_on_next_line = false
+ij_javascript_prefer_as_type_cast = false
+ij_javascript_prefer_explicit_types_function_expression_returns = false
+ij_javascript_prefer_explicit_types_function_returns = false
+ij_javascript_prefer_explicit_types_vars_fields = false
+ij_javascript_prefer_parameters_wrap = false
+ij_javascript_property_prefix =
+ij_javascript_reformat_c_style_comments = false
+ij_javascript_space_after_colon = true
+ij_javascript_space_after_comma = true
+ij_javascript_space_after_dots_in_rest_parameter = false
+ij_javascript_space_after_generator_mult = true
+ij_javascript_space_after_property_colon = true
+ij_javascript_space_after_quest = true
+ij_javascript_space_after_type_colon = true
+ij_javascript_space_after_unary_not = false
+ij_javascript_space_before_async_arrow_lparen = true
+ij_javascript_space_before_catch_keyword = true
+ij_javascript_space_before_catch_left_brace = true
+ij_javascript_space_before_catch_parentheses = true
+ij_javascript_space_before_class_lbrace = true
+ij_javascript_space_before_class_left_brace = true
+ij_javascript_space_before_colon = true
+ij_javascript_space_before_comma = false
+ij_javascript_space_before_do_left_brace = true
+ij_javascript_space_before_else_keyword = true
+ij_javascript_space_before_else_left_brace = true
+ij_javascript_space_before_finally_keyword = true
+ij_javascript_space_before_finally_left_brace = true
+ij_javascript_space_before_for_left_brace = true
+ij_javascript_space_before_for_parentheses = true
+ij_javascript_space_before_for_semicolon = false
+ij_javascript_space_before_function_left_parenth = true
+ij_javascript_space_before_generator_mult = false
+ij_javascript_space_before_if_left_brace = true
+ij_javascript_space_before_if_parentheses = true
+ij_javascript_space_before_method_call_parentheses = false
+ij_javascript_space_before_method_left_brace = true
+ij_javascript_space_before_method_parentheses = false
+ij_javascript_space_before_property_colon = false
+ij_javascript_space_before_quest = true
+ij_javascript_space_before_switch_left_brace = true
+ij_javascript_space_before_switch_parentheses = true
+ij_javascript_space_before_try_left_brace = true
+ij_javascript_space_before_type_colon = false
+ij_javascript_space_before_unary_not = false
+ij_javascript_space_before_while_keyword = true
+ij_javascript_space_before_while_left_brace = true
+ij_javascript_space_before_while_parentheses = true
+ij_javascript_spaces_around_additive_operators = true
+ij_javascript_spaces_around_arrow_function_operator = true
+ij_javascript_spaces_around_assignment_operators = true
+ij_javascript_spaces_around_bitwise_operators = true
+ij_javascript_spaces_around_equality_operators = true
+ij_javascript_spaces_around_logical_operators = true
+ij_javascript_spaces_around_multiplicative_operators = true
+ij_javascript_spaces_around_relational_operators = true
+ij_javascript_spaces_around_shift_operators = true
+ij_javascript_spaces_around_unary_operator = false
+ij_javascript_spaces_within_array_initializer_brackets = false
+ij_javascript_spaces_within_brackets = false
+ij_javascript_spaces_within_catch_parentheses = false
+ij_javascript_spaces_within_for_parentheses = false
+ij_javascript_spaces_within_if_parentheses = false
+ij_javascript_spaces_within_imports = true
+ij_javascript_spaces_within_interpolation_expressions = false
+ij_javascript_spaces_within_method_call_parentheses = false
+ij_javascript_spaces_within_method_parentheses = false
+ij_javascript_spaces_within_object_literal_braces = true
+ij_javascript_spaces_within_object_type_braces = true
+ij_javascript_spaces_within_parentheses = false
+ij_javascript_spaces_within_switch_parentheses = false
+ij_javascript_spaces_within_type_assertion = false
+ij_javascript_spaces_within_union_types = true
+ij_javascript_spaces_within_while_parentheses = false
+ij_javascript_special_else_if_treatment = true
+ij_javascript_ternary_operation_signs_on_next_line = false
+ij_javascript_ternary_operation_wrap = off
+ij_javascript_union_types_wrap = on_every_item
+ij_javascript_use_chained_calls_group_indents = false
+ij_javascript_use_double_quotes = false
+ij_javascript_use_explicit_js_extension = auto
+ij_javascript_use_import_type = auto
+ij_javascript_use_path_mapping = always
+ij_javascript_use_public_modifier = false
+ij_javascript_use_semicolon_after_statement = true
+ij_javascript_var_declaration_wrap = normal
+ij_javascript_while_brace_force = always
+ij_javascript_while_on_new_line = false
+ij_javascript_wrap_comments = false
+
+[{*.ctp,*.hphp,*.inc,*.module,*.php,*.php4,*.php5,*.phtml}]
+ij_continuation_indent_size = 4
+ij_smart_tabs = true
+ij_php_align_assignments = true
+ij_php_align_class_constants = true
+ij_php_align_enum_cases = true
+ij_php_align_group_field_declarations = true
+ij_php_align_inline_comments = true
+ij_php_align_key_value_pairs = true
+ij_php_align_match_arm_bodies = true
+ij_php_align_multiline_array_initializer_expression = true
+ij_php_align_multiline_binary_operation = true
+ij_php_align_multiline_chained_methods = true
+ij_php_align_multiline_extends_list = true
+ij_php_align_multiline_for = false
+ij_php_align_multiline_parameters = true
+ij_php_align_multiline_parameters_in_calls = false
+ij_php_align_multiline_ternary_operation = false
+ij_php_align_named_arguments = true
+ij_php_align_phpdoc_comments = true
+ij_php_align_phpdoc_param_names = true
+ij_php_anonymous_brace_style = end_of_line
+ij_php_api_weight = 1
+ij_php_array_initializer_new_line_after_left_brace = true
+ij_php_array_initializer_right_brace_on_new_line = true
+ij_php_array_initializer_wrap = on_every_item
+ij_php_assignment_wrap = normal
+ij_php_attributes_wrap = split_into_lines
+ij_php_author_weight = 4
+ij_php_binary_operation_sign_on_next_line = false
+ij_php_binary_operation_wrap = normal
+ij_php_blank_lines_after_class_header = 1
+ij_php_blank_lines_after_function = 1
+ij_php_blank_lines_after_imports = 1
+ij_php_blank_lines_after_opening_tag = 1
+ij_php_blank_lines_after_package = 1
+ij_php_blank_lines_around_class = 1
+ij_php_blank_lines_around_constants = 1
+ij_php_blank_lines_around_enum_cases = 0
+ij_php_blank_lines_around_field = 0
+ij_php_blank_lines_around_method = 1
+ij_php_blank_lines_before_class_end = 1
+ij_php_blank_lines_before_imports = 0
+ij_php_blank_lines_before_method_body = 0
+ij_php_blank_lines_before_package = 0
+ij_php_blank_lines_before_return_statement = 1
+ij_php_blank_lines_between_imports = 1
+ij_php_block_brace_style = end_of_line
+ij_php_call_parameters_new_line_after_left_paren = false
+ij_php_call_parameters_right_paren_on_new_line = false
+ij_php_call_parameters_wrap = on_every_item
+ij_php_catch_on_new_line = false
+ij_php_category_weight = 28
+ij_php_class_brace_style = end_of_line
+ij_php_comma_after_last_argument = true
+ij_php_comma_after_last_argument_style = when_multiline
+ij_php_comma_after_last_array_element = true
+ij_php_comma_after_last_closure_use_var = true
+ij_php_comma_after_last_closure_use_var_style = when_multiline
+ij_php_comma_after_last_match_arm = false
+ij_php_comma_after_last_parameter = true
+ij_php_comma_after_last_parameter_style = when_multiline
+ij_php_concat_spaces = true
+ij_php_copyright_weight = 5
+ij_php_deprecated_weight = 3
+ij_php_do_while_brace_force = always
+ij_php_else_if_style = combine
+ij_php_else_on_new_line = false
+ij_php_example_weight = 28
+ij_php_extends_keyword_wrap = normal
+ij_php_extends_list_wrap = on_every_item
+ij_php_fields_default_visibility = private
+ij_php_filesource_weight = 28
+ij_php_finally_on_new_line = false
+ij_php_for_brace_force = always
+ij_php_for_statement_new_line_after_left_paren = false
+ij_php_for_statement_right_paren_on_new_line = false
+ij_php_for_statement_wrap = normal
+ij_php_force_empty_classes_in_one_line = true
+ij_php_force_empty_methods_in_one_line = true
+ij_php_force_short_declaration_array_style = true
+ij_php_getters_setters_naming_style = camel_case
+ij_php_getters_setters_order_style = getters_first
+ij_php_global_weight = 9
+ij_php_group_use_wrap = on_every_item
+ij_php_heredoc_on_same_line = true
+ij_php_if_brace_force = always
+ij_php_if_lparen_on_next_line = false
+ij_php_if_rparen_on_next_line = true
+ij_php_ignore_weight = 28
+ij_php_import_sorting = by_length
+ij_php_indent_break_from_case = true
+ij_php_indent_case_from_switch = true
+ij_php_indent_code_in_php_tags = false
+ij_php_internal_weight = 28
+ij_php_keep_blank_lines_after_lbrace = 0
+ij_php_keep_blank_lines_before_right_brace = 0
+ij_php_keep_blank_lines_in_code = 1
+ij_php_keep_blank_lines_in_declarations = 1
+ij_php_keep_control_statement_in_one_line = true
+ij_php_keep_first_column_comment = false
+ij_php_keep_indents_on_empty_lines = false
+ij_php_keep_line_breaks = true
+ij_php_keep_rparen_and_lbrace_on_one_line = true
+ij_php_keep_simple_classes_in_one_line = true
+ij_php_keep_simple_methods_in_one_line = true
+ij_php_lambda_brace_style = end_of_line
+ij_php_license_weight = 28
+ij_php_line_comment_add_space = false
+ij_php_line_comment_at_first_column = true
+ij_php_link_weight = 8
+ij_php_lower_case_boolean_const = true
+ij_php_lower_case_keywords = true
+ij_php_lower_case_null_const = false
+ij_php_method_brace_style = end_of_line
+ij_php_method_call_chain_wrap = on_every_item
+ij_php_method_parameters_new_line_after_left_paren = true
+ij_php_method_parameters_right_paren_on_new_line = true
+ij_php_method_parameters_wrap = on_every_item
+ij_php_method_weight = 15
+ij_php_modifier_list_wrap = false
+ij_php_multiline_chained_calls_first_call_on_new_line = true
+ij_php_multiline_chained_calls_semicolon_on_new_line = false
+ij_php_multiline_closure_lambda_on_new_line = false
+ij_php_namespace_brace_style = 1
+ij_php_new_line_after_php_opening_tag = true
+ij_php_null_type_position = in_the_end
+ij_php_package_weight = 0
+ij_php_param_weight = 10
+ij_php_parameters_attributes_wrap = off
+ij_php_parentheses_expression_new_line_after_left_paren = true
+ij_php_parentheses_expression_right_paren_on_new_line = true
+ij_php_phpdoc_blank_line_before_tags = true
+ij_php_phpdoc_blank_lines_around_parameters = true
+ij_php_phpdoc_keep_blank_lines = true
+ij_php_phpdoc_param_spaces_between_name_and_description = 2
+ij_php_phpdoc_param_spaces_between_tag_and_type = 3
+ij_php_phpdoc_param_spaces_between_type_and_name = 2
+ij_php_phpdoc_use_fqcn = true
+ij_php_phpdoc_wrap_long_lines = true
+ij_php_place_assignment_sign_on_next_line = false
+ij_php_place_parens_for_constructor = 1
+ij_php_property_read_weight = 28
+ij_php_property_weight = 28
+ij_php_property_write_weight = 28
+ij_php_return_type_on_new_line = false
+ij_php_return_weight = 11
+ij_php_see_weight = 7
+ij_php_since_weight = 2
+ij_php_sort_phpdoc_elements = true
+ij_php_space_after_colon = true
+ij_php_space_after_colon_in_enum_backed_type = true
+ij_php_space_after_colon_in_named_argument = true
+ij_php_space_after_colon_in_return_type = true
+ij_php_space_after_comma = true
+ij_php_space_after_for_semicolon = true
+ij_php_space_after_quest = true
+ij_php_space_after_type_cast = true
+ij_php_space_after_unary_not = false
+ij_php_space_before_array_initializer_left_brace = false
+ij_php_space_before_catch_keyword = true
+ij_php_space_before_catch_left_brace = true
+ij_php_space_before_catch_parentheses = false
+ij_php_space_before_class_left_brace = true
+ij_php_space_before_closure_left_parenthesis = false
+ij_php_space_before_colon = true
+ij_php_space_before_colon_in_enum_backed_type = false
+ij_php_space_before_colon_in_named_argument = false
+ij_php_space_before_colon_in_return_type = false
+ij_php_space_before_comma = false
+ij_php_space_before_do_left_brace = true
+ij_php_space_before_else_keyword = true
+ij_php_space_before_else_left_brace = true
+ij_php_space_before_finally_keyword = true
+ij_php_space_before_finally_left_brace = true
+ij_php_space_before_for_left_brace = true
+ij_php_space_before_for_parentheses = false
+ij_php_space_before_for_semicolon = false
+ij_php_space_before_if_left_brace = true
+ij_php_space_before_if_parentheses = false
+ij_php_space_before_method_call_parentheses = false
+ij_php_space_before_method_left_brace = true
+ij_php_space_before_method_parentheses = false
+ij_php_space_before_quest = false
+ij_php_space_before_short_closure_left_parenthesis = false
+ij_php_space_before_switch_left_brace = true
+ij_php_space_before_switch_parentheses = false
+ij_php_space_before_try_left_brace = true
+ij_php_space_before_unary_not = false
+ij_php_space_before_while_keyword = true
+ij_php_space_before_while_left_brace = true
+ij_php_space_before_while_parentheses = false
+ij_php_space_between_ternary_quest_and_colon = true
+ij_php_spaces_around_additive_operators = true
+ij_php_spaces_around_arrow = false
+ij_php_spaces_around_assignment_in_declare = false
+ij_php_spaces_around_assignment_operators = true
+ij_php_spaces_around_bitwise_operators = false
+ij_php_spaces_around_equality_operators = true
+ij_php_spaces_around_logical_operators = true
+ij_php_spaces_around_multiplicative_operators = true
+ij_php_spaces_around_null_coalesce_operator = true
+ij_php_spaces_around_pipe_in_union_type = false
+ij_php_spaces_around_relational_operators = true
+ij_php_spaces_around_shift_operators = true
+ij_php_spaces_around_unary_operator = false
+ij_php_spaces_around_var_within_brackets = false
+ij_php_spaces_within_array_initializer_braces = false
+ij_php_spaces_within_brackets = false
+ij_php_spaces_within_catch_parentheses = false
+ij_php_spaces_within_for_parentheses = false
+ij_php_spaces_within_if_parentheses = false
+ij_php_spaces_within_method_call_parentheses = false
+ij_php_spaces_within_method_parentheses = false
+ij_php_spaces_within_parentheses = false
+ij_php_spaces_within_short_echo_tags = true
+ij_php_spaces_within_switch_parentheses = false
+ij_php_spaces_within_while_parentheses = false
+ij_php_special_else_if_treatment = true
+ij_php_subpackage_weight = 28
+ij_php_ternary_operation_signs_on_next_line = true
+ij_php_ternary_operation_wrap = normal
+ij_php_throws_weight = 12
+ij_php_todo_weight = 14
+ij_php_treat_multiline_arrays_and_lambdas_multiline = false
+ij_php_unknown_tag_weight = 28
+ij_php_upper_case_boolean_const = false
+ij_php_upper_case_null_const = true
+ij_php_uses_weight = 28
+ij_php_var_weight = 13
+ij_php_variable_naming_style = camel_case
+ij_php_version_weight = 6
+ij_php_while_brace_force = always
+ij_php_while_on_new_line = false
+
+[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,composer.lock,jest.config}]
+ij_continuation_indent_size = 4
+ij_smart_tabs = true
+ij_json_array_wrapping = split_into_lines
+ij_json_keep_blank_lines_in_code = 0
+ij_json_keep_indents_on_empty_lines = false
+ij_json_keep_line_breaks = true
+ij_json_keep_trailing_comma = false
+ij_json_object_wrapping = split_into_lines
+ij_json_property_alignment = do_not_align
+ij_json_space_after_colon = true
+ij_json_space_after_comma = true
+ij_json_space_before_colon = false
+ij_json_space_before_comma = false
+ij_json_spaces_within_braces = false
+ij_json_spaces_within_brackets = false
+ij_json_wrap_long_lines = true
+
+[{*.htm,*.html,*.sht,*.shtm,*.shtml}]
+ij_continuation_indent_size = 4
+ij_smart_tabs = true
+ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3
+ij_html_align_attributes = true
+ij_html_align_text = false
+ij_html_attribute_wrap = normal
+ij_html_block_comment_add_space = false
+ij_html_block_comment_at_first_column = true
+ij_html_do_not_align_children_of_min_lines = 0
+ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p
+ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot
+ij_html_enforce_quotes = false
+ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var
+ij_html_keep_blank_lines = 2
+ij_html_keep_indents_on_empty_lines = false
+ij_html_keep_line_breaks = true
+ij_html_keep_line_breaks_in_text = true
+ij_html_keep_whitespaces = false
+ij_html_keep_whitespaces_inside = span,pre,textarea
+ij_html_line_comment_at_first_column = true
+ij_html_new_line_after_last_attribute = never
+ij_html_new_line_before_first_attribute = never
+ij_html_quote_style = double
+ij_html_remove_new_line_before_tags = br
+ij_html_space_after_tag_name = false
+ij_html_space_around_equality_in_attribute = false
+ij_html_space_inside_empty_tag = false
+ij_html_text_wrap = normal
+
+[{*.http,*.rest}]
+ij_continuation_indent_size = 4
+ij_http-request_call_parameters_wrap = normal
+ij_http-request_method_parameters_wrap = split_into_lines
+ij_http-request_space_before_comma = true
+ij_http-request_spaces_around_assignment_operators = true
+
+[{*.yaml,*.yml}]
+indent_size = 2
+ij_yaml_align_values_properties = do_not_align
+ij_yaml_autoinsert_sequence_marker = true
+ij_yaml_block_mapping_on_new_line = false
+ij_yaml_indent_sequence_value = true
+ij_yaml_keep_indents_on_empty_lines = false
+ij_yaml_keep_line_breaks = true
+ij_yaml_line_comment_add_space = false
+ij_yaml_line_comment_add_space_on_reformat = false
+ij_yaml_line_comment_at_first_column = true
+ij_yaml_sequence_on_new_line = false
+ij_yaml_space_before_colon = false
+ij_yaml_spaces_within_braces = true
+ij_yaml_spaces_within_brackets = true
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..d363631
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+API_KEY=
+API_URL=
+API_VERSION=
+CACHE_DIRECTORY=
+CACHE_LIFETIME=
+CACHE_ENABLED=1
+ITERATE_MAX_PAGES=100
+APP_ENV=
+DEBUG=0
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..8150594
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,7 @@
+# These are supported funding model platforms
+
+github: [Gokujo]
+ko_fi: devcraft
+thanks_dev: gh/gokujo
+buy_me_a_coffee: maharder
+custom: ['https://paypal.me/MaximH', 'https://yoomoney.ru/to/41001454367103', 'https://dalink.to/maharder']
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..e275786
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,73 @@
+name: Ошибка
+description: Сообщить об ошибке / баге в библиотеке
+title: "[Ошибка]: "
+labels:
+ - Ошибка
+ - bug
+assignees:
+ - Gokujo
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Спасибо, что нашли время, чтобы составить этот отчёт
+ - type: checkboxes
+ id: read-rules
+ attributes:
+ label: Пользовательское соглашение
+ description: Перед просьбой добавить или "починить" что-либо в срочном порядке - [нужно прочесть соглашение](https://devcraft.club/pages/licence-agreement/).
+ options:
+ - label: Я прочёл / прочла соглашение и согласен / согласна с ними
+ required: true
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: В чём проблема?
+ description: Опиши проблему и шаги воспроизведения.
+ placeholder: Что произошло и как это повторить
+ validations:
+ required: true
+ - type: textarea
+ id: what-should-happen
+ attributes:
+ label: Что ожидали?
+ description: Ожидаемое поведение
+ validations:
+ required: false
+ - type: input
+ id: library-version
+ attributes:
+ label: Версия библиотеки / пакета
+ description: Версия из Composer / Packagist / CHANGELOG
+ placeholder: например 1.2.3
+ validations:
+ required: true
+ - type: dropdown
+ id: php-version
+ attributes:
+ label: Версия PHP
+ description: На какой версии PHP воспроизводится?
+ options:
+ - "8.5"
+ - "8.4"
+ - "8.3 (Default)"
+ - "8.2"
+ - "другое"
+ validations:
+ required: true
+ - type: input
+ id: composer-version
+ attributes:
+ label: Версия Composer (опционально)
+ description: Вывод `composer --version`
+ validations:
+ required: false
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Скриншоты / логи
+ description: Трейсы, логи, скриншоты
+ value: |
+ 
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/bug_report_en.yml b/.github/ISSUE_TEMPLATE/bug_report_en.yml
new file mode 100644
index 0000000..d3e6445
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report_en.yml
@@ -0,0 +1,69 @@
+name: Bug report
+description: Report a bug in the library
+title: "[Bug]: "
+labels:
+ - bug
+assignees:
+ - Gokujo
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to file this report.
+ - type: checkboxes
+ id: read-rules
+ attributes:
+ label: Licence agreement
+ description: Please read the [licence agreement](https://devcraft.club/pages/licence-agreement/) before requesting urgent fixes.
+ options:
+ - label: I have read and agree to the licence agreement
+ required: true
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened?
+ description: Describe the bug and steps to reproduce.
+ placeholder: Steps and observed behaviour
+ validations:
+ required: true
+ - type: textarea
+ id: what-should-happen
+ attributes:
+ label: Expected behaviour
+ validations:
+ required: false
+ - type: input
+ id: library-version
+ attributes:
+ label: Package / library version
+ description: Version from Composer / Packagist / CHANGELOG
+ placeholder: e.g. 1.2.3
+ validations:
+ required: true
+ - type: dropdown
+ id: php-version
+ attributes:
+ label: PHP version
+ options:
+ - "8.5"
+ - "8.4"
+ - "8.3 (Default)"
+ - "8.2"
+ - "other"
+ validations:
+ required: true
+ - type: input
+ id: composer-version
+ attributes:
+ label: Composer version (optional)
+ description: Output of `composer --version`
+ validations:
+ required: false
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots / logs
+ value: |
+ 
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..b53f651
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Documentation
+ url: https://readme.devcraft.club/
+ about: DevCraft documentation site.
+ - name: Licence agreement
+ url: https://devcraft.club/pages/licence-agreement/
+ about: Licence agreement before support requests.
diff --git a/.github/ISSUE_TEMPLATE/custom.yml b/.github/ISSUE_TEMPLATE/custom.yml
new file mode 100644
index 0000000..cb404c1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/custom.yml
@@ -0,0 +1,53 @@
+name: Другие запросы
+description: Если не подходит баг или feature — этот тикет
+title: "[Прочее]: "
+labels:
+ - Прочее
+assignees:
+ - Gokujo
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Спасибо, что нашли время, чтобы составить этот отчёт
+ - type: checkboxes
+ id: read-rules
+ attributes:
+ label: Пользовательское соглашение
+ description: Перед просьбой добавить или "починить" что-либо в срочном порядке - [нужно прочесть соглашение](https://devcraft.club/pages/licence-agreement/).
+ options:
+ - label: Я прочёл / прочла соглашение и согласен / согласна с ними
+ required: true
+ - type: textarea
+ id: what-do-you-need
+ attributes:
+ label: В чём соль?
+ description: Опиши запрос как можно детальнее.
+ validations:
+ required: true
+ - type: input
+ id: library-version
+ attributes:
+ label: Версия библиотеки / пакета (если применимо)
+ validations:
+ required: false
+ - type: dropdown
+ id: php-version
+ attributes:
+ label: Версия PHP
+ options:
+ - "8.5"
+ - "8.4"
+ - "8.3 (Default)"
+ - "8.2"
+ - "другое"
+ validations:
+ required: true
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Скриншоты и файлы
+ value: |
+ 
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/custom_en.yml b/.github/ISSUE_TEMPLATE/custom_en.yml
new file mode 100644
index 0000000..d875e58
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/custom_en.yml
@@ -0,0 +1,52 @@
+name: Other
+description: If it is not a bug or feature request
+title: "[Other]: "
+labels:
+ - question
+assignees:
+ - Gokujo
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to open this issue.
+ - type: checkboxes
+ id: read-rules
+ attributes:
+ label: Licence agreement
+ description: Please read the [licence agreement](https://devcraft.club/pages/licence-agreement/).
+ options:
+ - label: I have read and agree to the licence agreement
+ required: true
+ - type: textarea
+ id: what-do-you-need
+ attributes:
+ label: What do you need?
+ validations:
+ required: true
+ - type: input
+ id: library-version
+ attributes:
+ label: Package / library version (if applicable)
+ validations:
+ required: false
+ - type: dropdown
+ id: php-version
+ attributes:
+ label: PHP version
+ options:
+ - "8.5"
+ - "8.4"
+ - "8.3 (Default)"
+ - "8.2"
+ - "other"
+ validations:
+ required: true
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots / files
+ value: |
+ 
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..51f83fb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,56 @@
+name: Запрос на новый функционал
+description: Опиши желаемую возможность библиотеки
+title: "[Запрос]: "
+labels:
+ - Запрос на функционал
+ - enhancement
+assignees:
+ - Gokujo
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Спасибо, что нашли время, чтобы составить этот отчёт
+ - type: checkboxes
+ id: read-rules
+ attributes:
+ label: Пользовательское соглашение
+ description: Перед просьбой добавить или "починить" что-либо в срочном порядке - [нужно прочесть соглашение](https://devcraft.club/pages/licence-agreement/).
+ options:
+ - label: Я прочёл / прочла соглашение и согласен / согласна с ними
+ required: true
+ - type: textarea
+ id: what-do-you-need
+ attributes:
+ label: В чём соль?
+ description: Опиши желаемый функционал как можно детальнее.
+ placeholder: Что хочешь добавить или изменить
+ validations:
+ required: true
+ - type: input
+ id: library-version
+ attributes:
+ label: Версия библиотеки / пакета (если применимо)
+ placeholder: например 1.2.3
+ validations:
+ required: false
+ - type: dropdown
+ id: php-version
+ attributes:
+ label: Версия PHP
+ options:
+ - "8.5"
+ - "8.4"
+ - "8.3 (Default)"
+ - "8.2"
+ - "другое"
+ validations:
+ required: true
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Скриншоты и файлы
+ value: |
+ 
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/feature_request_en.yml b/.github/ISSUE_TEMPLATE/feature_request_en.yml
new file mode 100644
index 0000000..8176e9a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request_en.yml
@@ -0,0 +1,54 @@
+name: Feature request
+description: Suggest a new library capability
+title: "[Feature]: "
+labels:
+ - enhancement
+assignees:
+ - Gokujo
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to file this request.
+ - type: checkboxes
+ id: read-rules
+ attributes:
+ label: Licence agreement
+ description: Please read the [licence agreement](https://devcraft.club/pages/licence-agreement/) before requesting urgent changes.
+ options:
+ - label: I have read and agree to the licence agreement
+ required: true
+ - type: textarea
+ id: what-do-you-need
+ attributes:
+ label: What do you need?
+ description: Describe the desired feature in detail.
+ validations:
+ required: true
+ - type: input
+ id: library-version
+ attributes:
+ label: Package / library version (if applicable)
+ placeholder: e.g. 1.2.3
+ validations:
+ required: false
+ - type: dropdown
+ id: php-version
+ attributes:
+ label: PHP version
+ options:
+ - "8.5"
+ - "8.4"
+ - "8.3 (Default)"
+ - "8.2"
+ - "other"
+ validations:
+ required: true
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots / files
+ value: |
+ 
+ validations:
+ required: false
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..1615a44
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,61 @@
+## Создайте заголовок для этого Pull Request
+
+### Описание
+
+... Дайте ревьюверу общее представление о проделанной работе и объясните, почему были внесены эти изменения – **ПОЧЕМУ** данное изменение необходимо. Это поле должно предоставлять более глубокий контекст с точки зрения продукта. ...
+
+... Подробно опишите, как именно была реализована данная задача. Здесь может быть представлена техническая информация. Какие конкретные шаги предприняты для достижения цели? Следует указать детали интеграции сервисов, логику работы, особенности реализации и другие технические аспекты. ...
+
+### Изменения в коде
+
+... Здесь следует сосредоточиться на технической стороне реализации. Укажите, какие именно изменения были внесены: добавленный или изменённый функционал, рефакторинг, улучшение существующей структуры кода и т. д. ...
+
+#### Внесённые изменения за пределами кода
+
+... Если были изменены внешние сервисы, добавлены новые параметры в настройки задач или внесены изменения в базу данных, опишите это здесь. Сюда также можно включить обновления сторонних сервисов, изменения в инфраструктуре или интеграцию с внешними API. ...
+
+### Дополнительная информация
+
+... Добавьте любую дополнительную информацию, которая может быть полезна ревьюеру при оценке данного Pull Request. Это могут быть соображения по производительности, архитектурные решения и т. д. ...
+
+### Issues
+
+Fixes #(Issue)
+
+### Тип изменений
+
+Удалите неподходящие варианты.
+
+- [ ] Исправление ошибки (обратная совместимость, исправление проблемы)
+- [ ] Новая функциональность (обратная совместимость, добавление новой функции)
+- [ ] Изменение, нарушающее обратную совместимость (исправление или новый функционал, который может сломать существующую логику)
+- [ ] Эти изменения требуют обновления документации
+
+### Контрольный список:
+
+Удалите неподходящие варианты.
+
+- [ ] Мой код соответствует стилевым правилам проекта
+- [ ] Я провёл самопроверку своего кода
+- [ ] Я добавил комментарии в код, особенно в сложных для понимания местах
+- [ ] Я обновил соответствующую документацию
+- [ ] Мои изменения не вызывают новых предупреждений
+- [ ] Я добавил тесты, доказывающие работоспособность исправления или новой функциональности
+- [ ] Все новые и существующие unit-тесты успешно проходят локальную проверку
+- [ ] Все зависимые изменения были объединены и опубликованы в соответствующих модулях
+- [ ] Я проверил код на наличие орфографических ошибок
+
+### Reviewers
+
+- Maxim
+- Gokujo
+
+### Labels / Release
+
+Immer automatisch: `Automatic`, `DevBot CI`, `Publish`.
+Forgejo-Labels werden gespiegelt (`Publish/Github` nicht).
+**Release:** Label `releases/vX.Y.Z` → Tag + Packagist **erst nach Merge dieses PRs**.
+
+### Changelog
+
+Vor Release: Eintrag in `CHANGELOG.md` unter der Version (Keep a Changelog).
diff --git a/.github/workflows/packagist.yml b/.github/workflows/packagist.yml
new file mode 100644
index 0000000..4708065
--- /dev/null
+++ b/.github/workflows/packagist.yml
@@ -0,0 +1,22 @@
+name: Packagist
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ notify-packagist:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Notify Packagist
+ env:
+ PACKAGIST_USERNAME: ${{ secrets.COMPOSER_USERNAME }}
+ PACKAGIST_TOKEN: ${{ secrets.COMPOSER_UPDATE_API_KEY }}
+ run: |
+ test -n "$PACKAGIST_USERNAME" && test -n "$PACKAGIST_TOKEN"
+ curl --fail-with-body -sS -XPOST \
+ -H 'content-type:application/json' \
+ "https://packagist.org/api/update-package?username=${PACKAGIST_USERNAME}&apiToken=${PACKAGIST_TOKEN}" \
+ -d "{\"repository\":{\"url\":\"https://github.com/${{ github.repository }}\"}}"
diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml
new file mode 100644
index 0000000..e11b8fd
--- /dev/null
+++ b/.github/workflows/release-tag.yml
@@ -0,0 +1,55 @@
+name: Release tag
+
+on:
+ pull_request:
+ types: [closed]
+ branches: [main]
+
+permissions:
+ contents: write
+
+jobs:
+ tag:
+ if: >
+ github.event.pull_request.merged == true &&
+ contains(join(github.event.pull_request.labels.*.name, ','), 'Publish') &&
+ contains(join(github.event.pull_request.labels.*.name, ','), 'releases/v')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.merge_commit_sha }}
+ fetch-depth: 0
+ fetch-tags: true
+
+ - name: Create tag from releases/v* label
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ TAG="$(jq -r '[.pull_request.labels[].name | select(startswith("releases/v"))][0] // empty | sub("^releases/"; "")' "$GITHUB_EVENT_PATH")"
+ if [ -z "$TAG" ]; then
+ echo "No releases/v* label found"
+ exit 1
+ fi
+ case "$TAG" in
+ v*) ;;
+ *) echo "Tag must start with v, got: $TAG"; exit 1 ;;
+ esac
+
+ SHA="${{ github.event.pull_request.merge_commit_sha }}"
+ if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
+ EXISTING="$(git rev-list -n 1 "refs/tags/${TAG}")"
+ if [ "$EXISTING" = "$SHA" ]; then
+ echo "Tag ${TAG} already points at ${SHA} — skip"
+ exit 0
+ fi
+ echo "Tag ${TAG} exists at ${EXISTING}, refusing to move to ${SHA}"
+ exit 1
+ fi
+
+ git config user.email "ci@devcraft.club"
+ git config user.name "DevBot CI"
+ git tag -a "$TAG" -m "Release ${TAG}" "$SHA"
+ git push origin "refs/tags/${TAG}"
+ echo "Created tag ${TAG} at ${SHA}"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bd4ff1a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,540 @@
+# ==============================================================================
+# 1. Операционные системы (OS)
+# ==============================================================================
+### macOS ###
+.DS_Store
+.AppleDouble
+.LSOverride
+Icon
+._*
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### Windows ###
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+*.stackdump
+[Dd]esktop.ini
+$RECYCLE.BIN/
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+*.lnk
+
+### Linux ###
+*~
+.fuse_hidden*
+.directory
+.Trash-*
+.nfs*
+
+# ==============================================================================
+# 2. ИИ-инструменты, ассистенты и остатки LLM
+# ==============================================================================
+### AI / Copilots / IDE-плагины ###
+.claude/
+.cursor/
+.cursor-server/
+.continue/
+.codeium/
+.tabnine/
+.cody/
+*.chatgpt
+chatgpt_history/
+.aix/
+.copilot/
+github-copilot-*
+
+# ==============================================================================
+# 3. IDE и редакторы
+# ==============================================================================
+### JetBrains (IntelliJ, PyCharm, WebStorm, Android Studio и др.) ###
+.idea/
+*.iml
+*.ipr
+*.iws
+out/
+cmake-build-*/
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+### Visual Studio Code ###
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+*.code-workspace
+.history/
+*.vsix
+.vscode-test/
+
+### Eclipse ###
+.metadata
+.settings/
+.loadpath
+.recommenders/
+.externalToolBuilders/
+*.launch
+*.pydevproject
+.cproject
+.autotools
+.factorypath
+.buildpath
+.target
+.tern-project
+.texlipse
+.springBeans
+.apt_generated/
+.apt_generated_test/
+.cache-main
+.scala_dependencies
+.worksheet
+.sts4-cache/
+.project
+.classpath
+
+### Sublime Text ###
+*.tmlanguage.cache
+*.tmPreferences.cache
+*.stTheme.cache
+*.sublime-workspace
+sftp-config.json
+sftp-config-alt*.json
+Package Control.*
+GitHub.sublime-settings
+*.sublime*
+
+### NetBeans ###
+**/nbproject/private/
+**/nbproject/Makefile-*.mk
+**/nbproject/Package-*.bash
+nbbuild/
+nbdist/
+.nb-gradle/
+
+# ==============================================================================
+# 4. Языки и фреймворки
+# ==============================================================================
+### Python ###
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+*.manifest
+*.spec
+pip-log.txt
+pip-delete-this-directory.txt
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+*.mo
+*.pot
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+instance/
+.webassets-cache
+.scrapy
+docs/_build/
+.pybuilder/
+.ipynb_checkpoints
+profile_default/
+ipython_config.py
+.pdm.toml
+.pdm-python
+.pdm-build/
+__pypackages__/
+celerybeat-schedule
+celerybeat.pid
+*.sage.py
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+.env
+.spyderproject
+.spyproject
+.ropeproject
+/site
+.mypy_cache/
+.dmypy.json
+dmypy.json
+.pyre/
+.pytype/
+cython_debug/
+poetry.toml
+.ruff_cache/
+pyrightconfig.json
+
+### Node.js / React / Vue / JS-экосистема ###
+node_modules/
+jspm_packages/
+web_modules/
+logs/
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+pids/
+*.pid
+*.seed
+*.pid.lock
+lib-cov/
+coverage/
+*.lcov
+.nyc_output/
+.grunt/
+bower_components/
+.lock-wscript
+build/Release/
+typings/
+*.tsbuildinfo
+.npm/
+.eslintcache
+.stylelintcache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+.node_repl_history
+*.tgz
+.yarn-integrity
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+.cache/
+.parcel-cache/
+.next/
+out/
+.nuxt/
+.vuepress/dist/
+.temp/
+.docusaurus/
+.serverless/
+.fusebox/
+.dynamodb/
+.tern-port
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+psd/
+thumb/
+sketch/
+
+### Java / Android ###
+*.class
+*.apk
+*.aar
+*.ap_
+*.aab
+*.dex
+bin/
+gen/
+local.properties
+proguard/
+.navigation/
+captures/
+.externalNativeBuild/
+.cxx/
+freeline.py
+freeline/
+freeline_project_description.json
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots/
+fastlane/test_output/
+fastlane/readme.md
+vcs.xml
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+gen-external-apklibs/
+output.json
+*.ctxt
+.mtj.tmp/
+*.jar
+*.war
+*.nar
+*.ear
+hs_err_pid*
+replay_pid*
+
+### PHP / Composer / Symfony / CakePHP ###
+composer.phar
+/vendor/
+/app/cache/*
+/app/logs/*
+!app/cache/.gitkeep
+!app/logs/.gitkeep
+/app/spool/*
+/var/cache/*
+/var/logs/*
+/var/sessions/*
+!var/cache/.gitkeep
+!var/logs/.gitkeep
+!var/sessions/.gitkeep
+/var/log/*
+!var/log/.gitkeep
+/app/config/parameters.yml
+/app/config/parameters.ini
+/app/bootstrap.php.cache
+/var/bootstrap.php.cache
+/bin/*
+!bin/console
+!bin/symfony_requirements
+/web/bundles/
+/web/uploads/
+/app/phpunit.xml
+/phpunit.xml
+**/Entity/*~
+/.web-server-pid
+/config/app.php
+/tmp/cache/models/*
+!/tmp/cache/models/empty
+/tmp/cache/persistent/*
+!/tmp/cache/persistent/empty
+/tmp/cache/views/*
+!/tmp/cache/views/empty
+/tmp/sessions/*
+!/tmp/sessions/empty
+/tmp/tests/*
+!/tmp/tests/empty
+/logs/*
+!/logs/empty
+/app/tmp/*
+/app/Config/core.php
+/app/Config/database.php
+/vendors/*
+
+### C / C++ / CMake ###
+CMakeLists.txt.user
+CMakeCache.txt
+CMakeFiles/
+CMakeScripts/
+Testing/
+Makefile
+cmake_install.cmake
+install_manifest.txt
+compile_commands.json
+CTestTestfile.cmake
+_deps/
+CMakeUserPresets.json
+
+### NW.js ###
+credits.html
+locales/
+libEGL.dll
+libGLEv2.dll
+node.dll
+nw.dll
+nw.exe
+natives_blob.bin
+nw_100_percent.pak
+nw_200_percent.pak
+nw_elf.dll
+snapshot_blob.bin
+resources.pak
+d3dcompiler_47.dll
+ffmpeg.dll
+icudtl.dat
+pnacl/
+chromedriver.exe
+nacl_irt_x86_64.nexe
+nwjc.exe
+payload.exe
+
+### GitBook / CMS ###
+_book/
+*.epub
+*.mobi
+*.pdf
+
+# ==============================================================================
+# 5. Сборка и DevOps
+# ==============================================================================
+### Gradle ###
+.gradle/
+**/build/
+!src/**/build/
+gradle-app.setting
+!gradle-wrapper.jar
+!gradle-wrapper.properties
+.gradletasknamecache
+
+### Maven ###
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+dependency-reduced-pom.xml
+buildNumber.properties
+.mvn/timing.properties
+.mvn/wrapper/maven-wrapper.jar
+
+### JBoss ###
+jboss/server/all/deploy/project.ext
+jboss/server/default/deploy/project.ext
+jboss/server/minimal/deploy/project.ext
+jboss/server/all/log/*.log
+jboss/server/all/tmp/**/*
+jboss/server/all/data/**/*
+jboss/server/all/work/**/*
+jboss/server/default/log/*.log
+jboss/server/default/tmp/**/*
+jboss/server/default/data/**/*
+jboss/server/default/work/**/*
+jboss/server/minimal/log/*.log
+jboss/server/minimal/tmp/**/*
+jboss/server/minimal/data/**/*
+jboss/server/minimal/work/**/*
+*.DEPLOYED
+
+### Turbo ###
+.turbo/
+
+# ==============================================================================
+# 6. Разное (архивы, логи, резервные копии и т.д.)
+# ==============================================================================
+### Git: резервные копии и diff ###
+*.orig
+*.BACKUP.*
+*.BASE.*
+*.LOCAL.*
+*.REMOTE.*
+*_BACKUP_*.txt
+*_BASE_*.txt
+*_LOCAL_*.txt
+*_REMOTE_*.txt
+*.patch
+*.diff
+*.gho
+*.ori
+*.bak
+*.swp
+*.tmp
+*~.nib
+
+### Безопасность / ключи ###
+*.ppk
+
+### Архивы ###
+*.7z
+*.rar
+*.zip
+*.gz
+*.gzip
+*.bzip
+*.bzip2
+*.bz2
+*.xz
+*.lzma
+*.xar
+*.zst
+*.tzst
+*.iso
+*.tar
+*.dmg
+*.xpi
+*.gem
+*.deb
+*.rpm
+*.txz
+
+### Office: временные файлы ###
+~$*.doc*
+Backup of *.doc*
+~$*.xls*
+*.xlk
+~$*.ppt*
+*.~vsd*
+
+# ==============================================================================
+# 7. DevCraft / DLE Plugin
+# ==============================================================================
+### DevCraft (локальная разработка) ###
+!devcraft/
+!devcraft/src/
+!devcraft/src/*
+devcraft/vendor/
+devcraft/composer.phar
+devcraft/logs/*
+devcraft/cache/*
+devcraft/.composer/*
+
+### DevCraft в upload/ (структура установки DLE) ###
+!upload/devcraft/
+!upload/devcraft/src/
+!upload/devcraft/src/*
+upload/devcraft/vendor/
+upload/devcraft/composer.phar
+upload/devcraft/logs/*
+upload/devcraft/cache/*
+upload/devcraft/.composer/*
+
+### Артефакты сборки (create_install_archive.sh) ###
+install_*.zip
+
+composer.lock
+vendor/
+cache/
+
+.phpunit.result.cache/
+.phpunit.cache/
+.phpunit/
+*.code-workspace
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..be34873
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,12 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## 1.0.0
+
+### Added
+
+- Initial release
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..94a9ed0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
index 7286b17..0353215 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,140 @@
-# Webshare-Proxy
+# WebShare Proxy API Wrapper
+
+PHP wrapper for the [WebShare Proxy API](https://apidocs.webshare.io) with rate-limit retries, GET response caching, filtering, pagination iterators, and typed request helpers.
+
+Domain terms: see [CONTEXT.md](CONTEXT.md).
+
+## Requirements
+
+- PHP >= 8.3
+- Composer
+
+## Installation
+
+```bash
+composer require devcraft/webshare-io-api-wrapper
+```
+
+## Configuration
+
+```bash
+export API_KEY="your_webshare_api_key"
+```
+
+Or `.env`:
+
+```
+API_KEY=your_webshare_api_key
+API_URL=https://proxy.webshare.io/api/
+API_VERSION=v2
+CACHE_ENABLED=1
+CACHE_LIFETIME=3600
+CACHE_DIRECTORY=./cache
+ITERATE_MAX_PAGES=100
+DEBUG=0
+```
+
+## Quick start
+
+```php
+query()
+ ->withFilter('mode', 'direct')
+ ->withPageSize(25)
+ ->withFilter('country_code', ['US', 'DE'], FilterOperator::IN);
+
+$list = $proxies->getList();
+
+foreach ($proxies->iterate() as $proxy) {
+ echo $proxy->getConnectionString(backbone: true), "\n";
+}
+```
+
+`App::Client()` returns the shared HTTP client. `App::Requests('Profile'|'Notification'|…)` auto-discovers request classes.
+
+## Request surface
+
+| App::Requests key | Coverage |
+|-------------------|----------|
+| `Proxy` | list, iterate, download, refresh |
+| `Profile` | profile + preferences |
+| `Notification` | list/retrieve/dismiss/restore |
+| `ProxyReplacement` | list/retrieve/create (v3) |
+| `ReplacedProxy` | list/download |
+| `ProxyConfig` | config/status/listStats/update (v3) |
+| `IpAuthorization` | list/iterate/CRUD + whatsMyIp |
+| `ProxyStats` | list/aggregate/activities/download |
+| `DownloadToken` | get/reset token by scope |
+| `ApiKey` | list/iterate/CRUD |
+| `SubUser` | list/iterate/CRUD/refresh + `masqueradeAs()` |
+
+**Proxy Connection** is not a REST resource — use `Proxy::getConnectionString()` (backbone/direct). See CONTEXT.md.
+
+## Caching
+
+Successful **GET** responses are cached (`CACHE_LIFETIME`). Mutations invalidate the resource tag. Paths containing `/download/` are never cached. Bypass with `forceRefresh: true` on `request()` / `Client::send()`, or `CACHE_ENABLED=0`.
+
+## Filtering, ordering & search
+
+```php
+$proxies->query()
+ ->withFilter('country_code', ['US', 'DE'], FilterOperator::IN)
+ ->withFilter('created_at', '2024-01-01', FilterOperator::GT)
+ ->withOrderBy('-created_at', 'country_code')
+ ->withSearch('residential');
+```
+
+| Operator | API syntax |
+|----------|------------|
+| `EQUAL` | `?field=value` |
+| `IN` | `?field__in=a,b` |
+| `GT` / `LT` | `?field__gt=` / `__lt=` |
+| `CONTAINS` | `?field__contains=` |
+
+## Pagination
+
+Manual pages via `withPage` / `withStartingAfter`, or `iterate()` / `PageIterator::items()` (default max pages from `ITERATE_MAX_PAGES`, default 100).
+
+## Sub-user masquerade
+
+```php
+App::Requests('SubUser')->masqueradeAs(7);
+// subsequent Client calls send X-Subuser: 7
+App::Requests('SubUser')->masqueradeAs(null);
+```
+
+## Error handling
+
+```php
+use Devcraft\Webshare\Exceptions\ApiException;
+use Devcraft\Webshare\Exceptions\RateLimitException;
+
+try {
+ App::Requests('Proxy')->query()->withFilter('mode', 'direct');
+ App::Requests('Proxy')->getList();
+} catch (RateLimitException $e) {
+ echo "Retry after {$e->retryAfterSeconds}s\n";
+} catch (ApiException $e) {
+ foreach ($e->getValidationErrors() as $field => $errors) {
+ foreach ($errors as $error) {
+ echo "$field: {$error['message']}\n";
+ }
+ }
+}
+```
+
+429/5xx: up to 3 retries with exponential backoff; `Retry-After` preferred; `x-ratelimit-reset` timestamps are converted and clamped (1–120s).
+
+## License
+
+AGPL-3.0-or-later — see [LICENSE](LICENSE).
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..d376da8
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,61 @@
+{
+ "name": "devcraftclub/webshare-io-api-wrapper",
+ "description": "PHP wrapper for the WebShare Proxy API with rate limiting, filtering, and error handling",
+ "version": "1.0.0",
+ "type": "library",
+ "license": "AGPL-3.0-or-later",
+ "keywords": [
+ "webshare",
+ "proxy",
+ "api",
+ "wrapper"
+ ],
+ "homepage": "https://github.com/DevCraftClub/Webshare-Proxy",
+ "support": {
+ "email": "dev@devcraft.club",
+ "issues": "https://github.com/DevCraftClub/Webshare-Proxy/issues",
+ "forum": "https://devcraft.club",
+ "docs": "https://readme.devcraft.club/dev/api_wrappers/webshare-proxy/1.0.0/guides/english/"
+ },
+ "autoload": {
+ "psr-4": {
+ "Devcraft\\Webshare\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Devcraft\\Webshare\\Tests\\": "tests/"
+ }
+ },
+ "authors": [
+ {
+ "name": "Maxim Harder",
+ "email": "dev@devcraft.club",
+ "role": "developer",
+ "homepage": "https://devcraft.club"
+ },
+ {
+ "name": "DevCraft",
+ "email": "admin@devcraft.club",
+ "role": "company",
+ "homepage": "https://devcraft.club"
+ }
+ ],
+ "minimum-stability": "stable",
+ "require": {
+ "php": ">=8.3",
+ "guzzlehttp/guzzle": "^7.0 || ^8.0",
+ "vlucas/phpdotenv": "^5.6",
+ "marcin-orlowski/lombok-php": "^1.2",
+ "psr/log": "^3.0",
+ "analog/analog": "^1.0",
+ "symfony/cache": "^8.1",
+ "devcraftclub/dev-tools": "^1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0"
+ },
+ "scripts": {
+ "test": "phpunit"
+ }
+}
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..db9838b
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,4 @@
+{
+ "version": "1.0.0",
+ "name": "webshare-io-api-wrapper"
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..6f31bec
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,17 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/src/Abstracts/AbstractBasicClass.php b/src/Abstracts/AbstractBasicClass.php
new file mode 100644
index 0000000..11cc39d
--- /dev/null
+++ b/src/Abstracts/AbstractBasicClass.php
@@ -0,0 +1,9 @@
+endpoint = $endpoint;
+ $this->queryBuilder = (new QueryBuilder())->withOptionalFields($parameters)->withRequiredFields($requiredFields);
+ }
+
+ public function query(): QueryBuilder {
+ return $this->queryBuilder;
+ }
+
+ public function updateQuery(QueryBuilder $queryBuilder): void {
+ $this->queryBuilder = $queryBuilder;
+ }
+
+ public function setEndpoint(string $endpoint): void {
+ $this->endpoint = $endpoint;
+ }
+
+ public function withEndpoint(string $endpoint): self {
+ $this->endpoint = $endpoint;
+
+ return $this;
+ }
+
+ public function getEndpoint(): string {
+ return $this->endpoint;
+ }
+
+ /**
+ * @throws \JsonException
+ */
+ public function request(
+ RequestMethod $method = RequestMethod::GET,
+ ?string $additionalEndpoint = NULL,
+ ?RequestBody $body = NULL,
+ ?string $apiVersion = NULL,
+ bool $forceRefresh = false,
+ ): string {
+ $endpoint = $this->endpoint;
+ if($additionalEndpoint !== NULL) {
+ $endpoint = rtrim($endpoint, '/') . '/' . $additionalEndpoint;
+ }
+
+ $apiVersion ??= App::Client()->getApiVersion();
+
+ return $this->defineClient($apiVersion)->send($endpoint, $method, $this->queryBuilder, $body, $forceRefresh);
+ }
+
+ private function defineClient(string $apiVersion): Client {
+ $client = App::Client();
+ if($apiVersion === $client->getApiVersion()) {
+ return $client;
+ }
+
+ return App::newClient(version: $apiVersion);
+ }
+
+}
diff --git a/src/Classes/App.php b/src/Classes/App.php
new file mode 100644
index 0000000..6247e73
--- /dev/null
+++ b/src/Classes/App.php
@@ -0,0 +1,207 @@
+ self::loadRequests(),
+ ttl : 86400,
+ );
+
+ $requestClass = $requests[$requestType] ?? NULL;
+
+ if($requestClass === NULL) {
+ return NULL;
+ }
+
+ return new $requestClass();
+ }
+
+ public static function cacheEnabled(): bool {
+ self::init();
+
+ return self::$cacheEnabled;
+ }
+
+ public static function cacheLifetime(): int {
+ self::init();
+
+ return self::$cacheLifetime;
+ }
+
+ public static function iterateMaxPages(): int {
+ self::init();
+
+ return self::$iterateMaxPages;
+ }
+
+ public static function Debug(): bool {
+ return self::$debug;
+ }
+
+ private static function env(string $key, mixed $default = NULL): mixed {
+ if(array_key_exists($key, $_ENV)) {
+ return $_ENV[$key];
+ }
+
+ $value = getenv($key);
+
+ return $value === false ? $default : $value;
+ }
+
+ /**
+ * @throws \ReflectionException
+ */
+ private static function loadRequests(): array {
+ self::init();
+
+ $requests = [];
+ $requestsDir = rtrim(dirname(__FILE__, 2), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'Requests';
+
+ if(!is_dir($requestsDir)) {
+ throw new \RuntimeException("Requests directory not found: {$requestsDir}");
+ }
+
+ $dirIterator = new RecursiveDirectoryIterator($requestsDir, FilesystemIterator::SKIP_DOTS);
+ $iterator = new RecursiveIteratorIterator($dirIterator);
+ $phpFiles = new RegexIterator($iterator, '/^.+\.php$/i', RegexIterator::GET_MATCH);
+
+ foreach($phpFiles as $file) {
+ $filePath = $file[0];
+ $relativePath = str_replace($requestsDir, '', $filePath);
+ $classRelative = str_replace([DIRECTORY_SEPARATOR, '.php'], ['\\', ''], $relativePath);
+ $fqn = rtrim('Devcraft\\Webshare\\Requests\\', '\\') . '\\' . ltrim($classRelative, '\\');
+
+ if(class_exists($fqn, true) && is_subclass_of($fqn, AbstractRequest::class)) {
+ $reflection = new ReflectionClass($fqn);
+
+ if(!$reflection->isAbstract()) {
+ $key = str_replace('Request', '', $reflection->getShortName());
+ $requests[$key] = $fqn;
+ }
+ }
+ }
+
+ return $requests;
+ }
+
+}
diff --git a/src/Classes/CacheManager.php b/src/Classes/CacheManager.php
new file mode 100644
index 0000000..ae6d7fc
--- /dev/null
+++ b/src/Classes/CacheManager.php
@@ -0,0 +1,117 @@
+ $tags
+ *
+ * @throws \Psr\Cache\InvalidArgumentException
+ */
+ public static function set(mixed $value, ?string $key = NULL, ?int $ttl = NULL, array $tags = []): void {
+ $cacheKey = self::resolveCacheKey($key);
+ $cachedItem = App::Cache()->getItem($cacheKey);
+ $cachedItem->set($value);
+
+ if($ttl !== NULL) {
+ $cachedItem->expiresAfter($ttl);
+ }
+
+ if($tags !== [] && $cachedItem instanceof \Symfony\Component\Cache\CacheItem) {
+ $cachedItem->tag($tags);
+ }
+
+ App::Cache()->save($cachedItem);
+ }
+
+ /**
+ * @throws \Psr\Cache\InvalidArgumentException
+ */
+ public static function get(?string $key = NULL): mixed {
+ $cacheKey = self::resolveCacheKey($key);
+ $cachedItem = App::Cache()->getItem($cacheKey);
+
+ return $cachedItem->isHit() ? $cachedItem->get() : NULL;
+ }
+
+ /**
+ * @param list $tags
+ */
+ public static function remember(callable $callback, ?string $key = NULL, ?int $ttl = NULL, array $tags = []): mixed {
+ $cacheKey = self::resolveCacheKey($key);
+
+ if(App::Debug()) {
+ $ttl = 5;
+ }
+
+ return App::Cache()->get($cacheKey, function(ItemInterface $item) use ($callback, $ttl, $tags) {
+ if($ttl !== NULL) {
+ $item->expiresAfter($ttl);
+ }
+ if($tags !== [] && method_exists($item, 'tag')) {
+ $item->tag($tags);
+ }
+
+ return $callback();
+ });
+ }
+
+ /**
+ * @throws \Psr\Cache\InvalidArgumentException
+ */
+ public static function forget(?string $key = NULL): bool {
+ return App::Cache()->deleteItem(self::resolveCacheKey($key));
+ }
+
+ /**
+ * @param list $tags
+ */
+ public static function invalidateTags(array $tags): void {
+ $cache = App::Cache();
+ if($cache instanceof TagAwareAdapter) {
+ $cache->invalidateTags($tags);
+ }
+ }
+
+ private static function resolveCacheKey(?string $key): string {
+ if($key !== NULL && str_starts_with($key, 'http.')) {
+ return self::hashKey($key);
+ }
+
+ $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2] ?? [];
+
+ $parts = [
+ $trace['class'] ?? 'global',
+ $trace['function'] ?? 'unknown',
+ ];
+
+ if($key !== NULL) {
+ $parts[] = $key;
+ }
+
+ $parts = array_map([self::class, 'sanitizeKey'], $parts);
+
+ return self::hashKey(implode('.', $parts));
+ }
+
+ private static function hashKey(string $rawKey): string {
+ if(App::Debug()) {
+ return strtolower(self::sanitizeKey($rawKey));
+ }
+
+ return hash('xxh128', strtolower($rawKey));
+ }
+
+ private static function sanitizeKey(mixed $key): string {
+ $sanitized = filter_var((string) $key, FILTER_SANITIZE_FULL_SPECIAL_CHARS);
+
+ return strtolower(str_replace(['{', '}', '(', ')', '/', '\\', '@', ':'], '_', $sanitized));
+ }
+
+}
diff --git a/src/Classes/Client.php b/src/Classes/Client.php
new file mode 100644
index 0000000..f6d0045
--- /dev/null
+++ b/src/Classes/Client.php
@@ -0,0 +1,238 @@
+apiKey)) {
+ throw new \InvalidArgumentException('API key must not be empty');
+ }
+
+ $this->httpClient = $httpClient ?? new GuzzleClient([
+ 'base_uri' => rtrim($this->apiUrl, '/') . '/',
+ 'headers' => [
+ 'Authorization' => 'Token ' . $this->apiKey,
+ ],
+ ]);
+ }
+
+ public function withSubuser(?string $subuserId): self {
+ $this->subuserId = $subuserId;
+
+ return $this;
+ }
+
+ public function getSubuser(): ?string {
+ return $this->subuserId;
+ }
+
+ /**
+ * Send a request to the WebShare API.
+ *
+ * @throws ApiException on 4xx/5xx responses
+ * @throws RateLimitException|\JsonException when retries exhausted after 429
+ */
+ public function send(
+ string $endpoint,
+ #[ExpectedValues(valuesFromClass: RequestMethod::class)]
+ RequestMethod $method = RequestMethod::GET,
+ ?QueryBuilder $query = NULL,
+ ?RequestBody $body = NULL,
+ bool $forceRefresh = false,
+ ): string {
+ $url = $this->resolveUrl($endpoint, $query);
+ $options = [
+ RequestOptions::HEADERS => $this->buildHeaders($body),
+ ];
+
+ if($body !== NULL && in_array($method, [RequestMethod::POST, RequestMethod::PUT, RequestMethod::PATCH], true)) {
+ $body->build();
+
+ match (true) {
+ $body->multipart !== [] => $options[RequestOptions::MULTIPART] = $body->multipart,
+ $body->postData !== [] => $options[RequestOptions::JSON] = $body->postData,
+ default => $options[RequestOptions::BODY] = $body->body,
+ };
+ }
+
+ $cacheable = $this->isCacheable($method, $endpoint);
+ $cacheKey = $cacheable ? $this->cacheKey($method, $url) : NULL;
+ $cacheTag = $this->cacheTag($endpoint);
+
+ if($cacheable && !$forceRefresh && App::cacheEnabled()) {
+ $cached = CacheManager::get($cacheKey);
+ if(is_string($cached)) {
+ return $cached;
+ }
+ }
+
+ $attempt = 0;
+ $backoffMs = 1000;
+
+ while(true) {
+ try {
+ $response = $this->httpClient->request($method->value, $url, $options);
+ $this->trackRateLimits($response->getHeaders());
+ $result = $response->getBody()->getContents();
+
+ if($cacheable && App::cacheEnabled() && $cacheKey !== NULL) {
+ CacheManager::set($result, $cacheKey, App::cacheLifetime(), [$cacheTag]);
+ }
+
+ if($method !== RequestMethod::GET && App::cacheEnabled()) {
+ CacheManager::invalidateTags([$cacheTag]);
+ }
+
+ return $result;
+ } catch(RequestException $e) {
+ $response = $e->getResponse();
+ if($response === NULL) {
+ throw new ApiException('Network error: ' . $e->getMessage(), 0, $e);
+ }
+
+ $status = $response->getStatusCode();
+ $responseBody = (string) $response->getBody();
+
+ if($status === 429 || $status >= 500) {
+ $attempt++;
+ if($attempt >= self::MAX_RETRIES) {
+ $status === 429
+ ? throw new RateLimitException($this->parseRetryAfter($response->getHeaders()), $e)
+ : throw new ApiException("HTTP $status: " . ($responseBody !== '' ? $responseBody : $e->getMessage()), $status, $e);
+ }
+ usleep($this->calculateBackoff($backoffMs, $response->getHeaders()) * 1000);
+ $backoffMs *= 2;
+ continue;
+ }
+
+ if($status === 400) {
+ throw ApiException::fromValidationResponse($responseBody, $status);
+ }
+
+ throw new ApiException("HTTP $status: " . ($responseBody !== '' ? $responseBody : $e->getMessage()), $status, $e);
+ } catch(GuzzleException $e) {
+ throw new ApiException('HTTP error: ' . $e->getMessage(), 0, $e);
+ }
+ }
+ }
+
+ /**
+ * @return array{remaining?: int, reset?: int}
+ */
+ public function getRateLimits(): array {
+ return $this->rateLimits;
+ }
+
+ public function getApiVersion(): string {
+ return $this->apiVersion;
+ }
+
+ private function buildHeaders(?RequestBody $body): array {
+ $headers = $body?->header ?? [];
+
+ if($this->subuserId !== NULL && $this->subuserId !== '') {
+ $headers['X-Subuser'] = $this->subuserId;
+ }
+
+ return $headers;
+ }
+
+ private function resolveUrl(string $endpoint, ?QueryBuilder $query = NULL): string {
+ $path = $this->apiVersion . '/' . ltrim($endpoint, '/');
+ $queryString = $query?->build() ?? '';
+
+ return $queryString !== '' ? $path . '?' . $queryString : $path;
+ }
+
+ private function isCacheable(RequestMethod $method, string $endpoint): bool {
+ if($method !== RequestMethod::GET) {
+ return false;
+ }
+
+ return !str_contains('/' . ltrim($endpoint, '/') . '/', '/download/');
+ }
+
+ private function cacheKey(RequestMethod $method, string $url): string {
+ $subuser = $this->subuserId ?? '';
+
+ return 'http.' . $method->value . '.' . $url . '.sub:' . $subuser;
+ }
+
+ private function cacheTag(string $endpoint): string {
+ $parts = explode('/', trim($endpoint, '/'));
+
+ return 'http.' . ($parts[0] !== '' ? $parts[0] : 'root');
+ }
+
+ private function trackRateLimits(array $headers): void {
+ $headers = array_change_key_case($headers);
+
+ if(isset($headers['x-ratelimit-remaining'][0])) {
+ $this->rateLimits['remaining'] = (int) $headers['x-ratelimit-remaining'][0];
+ }
+ if(isset($headers['x-ratelimit-reset'][0])) {
+ $this->rateLimits['reset'] = (int) $headers['x-ratelimit-reset'][0];
+ }
+ }
+
+ private function calculateBackoff(int $baseMs, array $headers): int {
+ return max($baseMs, $this->parseRetryAfter($headers) * 1000);
+ }
+
+ /**
+ * Prefer Retry-After (seconds). Treat large x-ratelimit-reset values as unix timestamps.
+ */
+ private function parseRetryAfter(array $headers): int {
+ $headers = array_change_key_case($headers);
+
+ if(isset($headers['retry-after'][0])) {
+ return $this->clampBackoffSeconds((int) $headers['retry-after'][0]);
+ }
+
+ if(isset($headers['x-ratelimit-reset'][0])) {
+ $reset = (int) $headers['x-ratelimit-reset'][0];
+ $seconds = $reset > time() ? $reset - time() : $reset;
+
+ return $this->clampBackoffSeconds($seconds);
+ }
+
+ return 60;
+ }
+
+ private function clampBackoffSeconds(int $seconds): int {
+ return max(self::BACKOFF_MIN_SEC, min(self::BACKOFF_MAX_SEC, $seconds));
+ }
+
+}
diff --git a/src/Classes/PageIterator.php b/src/Classes/PageIterator.php
new file mode 100644
index 0000000..d0dede8
--- /dev/null
+++ b/src/Classes/PageIterator.php
@@ -0,0 +1,42 @@
+
+ */
+ public static function items(callable $fetchPage, ?int $maxPages = NULL): \Generator {
+ $maxPages ??= App::iterateMaxPages();
+ $maxPages = max(1, $maxPages);
+
+ for($pageIndex = 0; $pageIndex < $maxPages; $pageIndex++) {
+ $response = $fetchPage($pageIndex);
+ if(!$response instanceof PaginatedResponse) {
+ throw new ApiException('PageIterator expects a PaginatedResponse from fetchPage.');
+ }
+
+ foreach($response->results as $item) {
+ yield $item;
+ }
+
+ if($response->next === NULL) {
+ return;
+ }
+ }
+
+ throw new ApiException("PageIterator exceeded max pages ({$maxPages}).");
+ }
+
+}
diff --git a/src/Classes/QueryBuilder.php b/src/Classes/QueryBuilder.php
new file mode 100644
index 0000000..c52e52b
--- /dev/null
+++ b/src/Classes/QueryBuilder.php
@@ -0,0 +1,140 @@
+pageSize = $pageSize;
+
+ return $this;
+ }
+
+ public function withPage(int $page): self {
+ $this->page = $page;
+
+ return $this;
+ }
+
+ public function withStartingAfter(string $startingAfter): self {
+ $this->startingAfter = $startingAfter;
+
+ return $this;
+ }
+
+ public function withRequiredField(string $field): self {
+ $this->requiredFields[] = $field;
+
+ return $this;
+ }
+
+ public function withOptionalField(string $field): self {
+ $this->optionalFields[] = $field;
+
+ return $this;
+ }
+
+ public function withRequiredFields(array $fields): self {
+ $this->requiredFields = array_merge($this->requiredFields, $fields);
+
+ return $this;
+ }
+
+ public function withOptionalFields(array $fields): self {
+ $this->optionalFields = array_merge($this->optionalFields, $fields);
+
+ return $this;
+ }
+
+ /**
+ * Add a filter parameter using WebShare API filter syntax.
+ *
+ * @see https://apidocs.webshare.io/#filtering
+ */
+ public function withFilter(string $field, mixed $value, FilterOperator $operator = FilterOperator::EQUAL): self {
+ $this->checkForPossibleField($field);
+
+ $key = $field . $operator->value;
+ $this->filters[$key] = is_array($value)? implode(',', $value) : $value;
+
+ return $this;
+ }
+
+ /**
+ * Set ordering for list endpoints. Prefix with `-` for descending.
+ *
+ * @see https://apidocs.webshare.io/#ordering
+ *
+ * @param string ...$fields e.g. 'status', '-created_at'
+ */
+ public function withOrderBy(string ...$fields): self {
+ $this->ordering = array_merge($this->ordering, $fields);
+
+ return $this;
+ }
+
+ /**
+ * Set text search parameter.
+ *
+ * @see https://apidocs.webshare.io/#search
+ */
+ public function withSearch(string $term): self {
+ $this->search = $term;
+
+ return $this;
+ }
+
+ public function build(): string {
+ $params = array_filter([
+ 'page_size' => $this->pageSize,
+ 'page' => $this->page,
+ 'starting_after' => $this->startingAfter,
+ 'ordering' => $this->ordering? implode(',', $this->ordering) : NULL,
+ 'search' => $this->search,
+ ...$this->filters,
+ ], static fn($v) => $v !== NULL);
+
+ $this->checkRequiredFields($params);
+
+ return http_build_query($params);
+ }
+
+ public function getFields(): array {
+ return array_merge($this->getRequiredFields(), $this->getOptionalFields());
+ }
+
+ private function checkForPossibleField(string $field): void {
+ if (!in_array($field, $this->getFields())) {
+ throw new ApiException("Field '$field' is not allowed.");
+ }
+ }
+
+ private function checkRequiredFields(array $parameters): void {
+ if($this->requiredFields === []) {
+ return;
+ }
+
+ $missing = array_diff($this->requiredFields, array_keys($parameters));
+ if($missing !== []) {
+ throw new ApiException('Required fields are not set: ' . implode(', ', $missing));
+ }
+ }
+}
diff --git a/src/Entities/ApiKey.php b/src/Entities/ApiKey.php
new file mode 100644
index 0000000..6578b6c
--- /dev/null
+++ b/src/Entities/ApiKey.php
@@ -0,0 +1,17 @@
+username || !$this->password) {
+ return sprintf("http://%s:%s", $this->proxy_address, $this->port);
+ }
+
+ if(!$backbone) {
+ return sprintf("http://%s:%s@%s:%s", $this->username, $this->password, $this->proxy_address, $this->port);
+ }
+
+ $user = $this->username;
+ if($this->country_code) {
+ $user .= '-' . strtolower($this->country_code);
+ }
+ if($city !== NULL) {
+ $user .= '-city_' . str_replace(' ', '_', strtolower($city));
+ }
+ if($session !== NULL) {
+ $user .= '-' . $session;
+ }
+
+ return sprintf("http://%s:%s@p.webshare.io:80", $user, $this->password);
+ }
+
+}
\ No newline at end of file
diff --git a/src/Entities/ProxyActivity.php b/src/Entities/ProxyActivity.php
new file mode 100644
index 0000000..0944ca5
--- /dev/null
+++ b/src/Entities/ProxyActivity.php
@@ -0,0 +1,30 @@
+
+ */
+ #[ArrayOf(ProxyStatErrorReason::class)]
+ public array $error_reasons = [];
+ public array $countries_used = [];
+ public ?int $number_of_proxies_used = NULL;
+ public array $protocols_used = [];
+ public ?float $average_concurrency = NULL;
+ public ?float $average_rps = NULL;
+ public ?string $last_request_sent_at = NULL;
+
+}
diff --git a/src/Entities/ProxyStatErrorReason.php b/src/Entities/ProxyStatErrorReason.php
new file mode 100644
index 0000000..3d458e0
--- /dev/null
+++ b/src/Entities/ProxyStatErrorReason.php
@@ -0,0 +1,17 @@
+
+ */
+ public array $header = [];
+
+ public ?string $body = NULL;
+
+ /**
+ * @var array
+ */
+ public array $postData = [];
+
+ /**
+ * @var array
+ */
+ public array $formData = [];
+
+ /**
+ * @var list
+ */
+ public array $multipart = [];
+
+ public function withPostData(string $key, mixed $value): self {
+ $this->guardBodyTypeConflict('postData');
+ $this->postData[$key] = $value;
+
+ return $this;
+ }
+
+ public function withHeader(string $key, mixed $value): self {
+ $this->header[$key] = $value;
+
+ return $this;
+ }
+
+ public function withFormData(string $key, mixed $value): self {
+ $this->guardBodyTypeConflict('formData');
+ $this->formData[$key] = $value;
+
+ return $this;
+ }
+
+ public function withMultipart(string $key, mixed $value): self {
+ $this->guardBodyTypeConflict('multipart');
+ $this->multipart[] = [
+ 'name' => $key,
+ 'contents' => $value,
+ ];
+
+ return $this;
+ }
+
+ /**
+ * @throws \JsonException
+ */
+ public function build(): self {
+ $this->prepare();
+
+ return $this;
+ }
+
+ private function guardBodyTypeConflict(string $incoming): void {
+ $active = array_filter([
+ 'postData' => !empty($this->postData),
+ 'formData' => !empty($this->formData),
+ 'multipart' => !empty($this->multipart),
+ ]);
+
+ unset($active[$incoming]);
+
+ if($active !== []) {
+ throw new \LogicException(sprintf(
+ 'Cannot use "%s": request body already uses "%s". Only one body type is allowed per request.',
+ $incoming,
+ implode('", "', array_keys($active)),
+ ));
+ }
+ }
+
+ /**
+ * @throws \JsonException
+ */
+ private function prepare(): void {
+ $this->header = array_filter($this->header, static fn($value) => $value !== NULL);
+
+ if(!array_key_exists('Content-Type', $this->header) && !isset(array_change_key_case($this->header)['content-type'])) {
+ $this->header['Content-Type'] = $this->resolveContentType();
+ }
+
+ if(!array_key_exists('Accept', $this->header) && !isset(array_change_key_case($this->header)['accept'])) {
+ $this->header['Accept'] = self::CONTENT_TYPE_JSON;
+ }
+
+ $this->body = $this->buildBody();
+ }
+
+ private function resolveContentType(): string {
+ return match (true) {
+ $this->formData !== [] => self::CONTENT_TYPE_FORM,
+ default => self::CONTENT_TYPE_JSON,
+ };
+ }
+
+ /**
+ * @throws \JsonException
+ */
+ private function buildBody(): ?string {
+ if($this->formData !== []) {
+ $formData = array_filter($this->formData, static fn($value) => $value !== NULL);
+
+ return http_build_query($formData);
+ }
+
+ if($this->multipart !== []) {
+ return NULL;
+ }
+
+ if($this->postData === []) {
+ return NULL;
+ }
+
+ $postData = array_filter($this->postData, static fn($value) => $value !== NULL);
+
+ return json_encode($postData, JSON_THROW_ON_ERROR);
+ }
+
+}
\ No newline at end of file
diff --git a/src/Entities/SubUser.php b/src/Entities/SubUser.php
new file mode 100644
index 0000000..63dbde4
--- /dev/null
+++ b/src/Entities/SubUser.php
@@ -0,0 +1,22 @@
+validationErrors = $validationErrors;
+ parent::__construct($message, $code, $previous);
+ }
+
+ public function getValidationErrors(): array {
+ return $this->validationErrors;
+ }
+
+ /**
+ * Parse the WebShare API 400 response body into structured validation errors.
+ *
+ * @return array>
+ */
+ public static function fromValidationResponse(string $body, int $statusCode = 400): self {
+ $decoded = json_decode($body, true);
+ if (!is_array($decoded)) {
+ return new self('Bad Request', $statusCode);
+ }
+
+ $errors = [];
+ $messages = [];
+ foreach ($decoded as $field => $fieldErrors) {
+ if (!is_array($fieldErrors)) {
+ continue;
+ }
+ foreach ($fieldErrors as $error) {
+ $errors[$field][] = [
+ 'message' => $error['message'] ?? 'Unknown error',
+ 'code' => $error['code'] ?? 'unknown',
+ ];
+ $messages[] = "$field: " . ($error['message'] ?? 'Unknown error');
+ }
+ }
+
+ return new self(
+ $messages ? implode('; ', $messages) : 'Bad Request',
+ $statusCode,
+ null,
+ $errors,
+ );
+ }
+}
diff --git a/src/Exceptions/RateLimitException.php b/src/Exceptions/RateLimitException.php
new file mode 100644
index 0000000..068826b
--- /dev/null
+++ b/src/Exceptions/RateLimitException.php
@@ -0,0 +1,19 @@
+request(), true));
+ }
+
+ /**
+ * @return \Generator
+ */
+ public function iterate(?int $maxPages = NULL): \Generator {
+ return PageIterator::items(function(int $pageIndex) {
+ $this->query()->withPage($pageIndex + 1);
+
+ return $this->list();
+ }, $maxPages);
+ }
+
+ public function retrieve(int $id): ApiKey {
+ return ApiKey::fromArray(json_decode($this->request(additionalEndpoint: (string) $id), true));
+ }
+
+ public function create(string $label): ApiKey {
+ $body = (new RequestBody())->withPostData('label', $label);
+
+ return ApiKey::fromArray(json_decode($this->request(RequestMethod::POST, body: $body), true));
+ }
+
+ public function update(int $id, string $label): ApiKey {
+ $body = (new RequestBody())->withPostData('label', $label);
+
+ return ApiKey::fromArray(json_decode($this->request(RequestMethod::PATCH, (string) $id, $body), true));
+ }
+
+ public function delete(int $id): void {
+ $this->request(RequestMethod::DELETE, (string) $id);
+ }
+
+}
diff --git a/src/Requests/DownloadTokenRequest.php b/src/Requests/DownloadTokenRequest.php
new file mode 100644
index 0000000..b824041
--- /dev/null
+++ b/src/Requests/DownloadTokenRequest.php
@@ -0,0 +1,31 @@
+request(RequestMethod::POST, rtrim($scope, '/') . '/'),
+ true,
+ ));
+ }
+
+ public function reset(string $scope): DownloadToken {
+ return DownloadToken::fromArray(json_decode(
+ $this->request(RequestMethod::POST, rtrim($scope, '/') . '/reset/'),
+ true,
+ ));
+ }
+
+}
diff --git a/src/Requests/IpAuthorizationRequest.php b/src/Requests/IpAuthorizationRequest.php
new file mode 100644
index 0000000..5729f29
--- /dev/null
+++ b/src/Requests/IpAuthorizationRequest.php
@@ -0,0 +1,62 @@
+request(), true));
+ }
+
+ /**
+ * @return \Generator
+ */
+ public function iterate(?int $maxPages = NULL): \Generator {
+ return PageIterator::items(function(int $pageIndex) {
+ $this->query()->withPage($pageIndex + 1);
+
+ return $this->list();
+ }, $maxPages);
+ }
+
+ public function retrieve(int $id): IpAuthorization {
+ return IpAuthorization::fromArray(json_decode($this->request(additionalEndpoint: (string) $id), true));
+ }
+
+ public function create(string $ipAddress): IpAuthorization {
+ $body = (new RequestBody())->withPostData('ip_address', $ipAddress);
+
+ return IpAuthorization::fromArray(json_decode($this->request(RequestMethod::POST, body: $body), true));
+ }
+
+ public function delete(int $id): void {
+ $this->request(RequestMethod::DELETE, (string) $id);
+ }
+
+ /**
+ * @return array{ip_address?: string}
+ */
+ public function whatsMyIp(): array {
+ $saved = $this->getEndpoint();
+ $this->withEndpoint('proxy/ipauthorization/whatsmyip');
+ try {
+ return json_decode($this->request(), true) ?? [];
+ } finally {
+ $this->withEndpoint($saved);
+ }
+ }
+
+}
diff --git a/src/Requests/NotificationRequest.php b/src/Requests/NotificationRequest.php
new file mode 100644
index 0000000..135b2f9
--- /dev/null
+++ b/src/Requests/NotificationRequest.php
@@ -0,0 +1,32 @@
+request(), true));
+ }
+
+ public function retrieve(int $id): Notification {
+ return Notification::fromArray(json_decode($this->request(additionalEndpoint: (string) $id), true));
+ }
+
+ public function dismiss(int $id): Notification {
+ return Notification::fromArray(json_decode($this->request(RequestMethod::POST, "$id/dismiss/"), true));
+ }
+
+ public function restore(int $id): Notification {
+ return Notification::fromArray(json_decode($this->request(RequestMethod::POST, "$id/restore/"), true));
+ }
+}
\ No newline at end of file
diff --git a/src/Requests/ProfileRequest.php b/src/Requests/ProfileRequest.php
new file mode 100644
index 0000000..956e0d7
--- /dev/null
+++ b/src/Requests/ProfileRequest.php
@@ -0,0 +1,48 @@
+request(), true));
+ }
+
+ public function update(Profile $profile): Profile {
+ $body = new RequestBody();
+ foreach(array_filter($profile->toArray(), static fn($v) => $v !== NULL) as $key => $value) {
+ $body->withPostData($key, $value);
+ }
+
+ return Profile::fromArray(json_decode($this->request(RequestMethod::PATCH, NULL, $body), true));
+ }
+
+ public function retrievePreferences(): ProfilePreference {
+ return ProfilePreference::fromArray(json_decode($this->request(RequestMethod::GET, 'preferences'), true));
+ }
+
+ public function updatePreferences(ProfilePreference $preferences): ProfilePreference {
+ $body = new RequestBody();
+ foreach(array_filter($preferences->toArray(), static fn($v) => $v !== NULL) as $key => $value) {
+ $body->withPostData($key, $value);
+ }
+
+ return ProfilePreference::fromArray(json_decode($this->request(RequestMethod::PATCH, 'preferences', $body), true));
+ }
+
+}
\ No newline at end of file
diff --git a/src/Requests/ProxyConfigRequest.php b/src/Requests/ProxyConfigRequest.php
new file mode 100644
index 0000000..2a8e9d1
--- /dev/null
+++ b/src/Requests/ProxyConfigRequest.php
@@ -0,0 +1,75 @@
+query()->withFilter('plan_id', $planId);
+
+ return $this;
+ }
+
+ public function retrieve(): ProxyConfig {
+ return ProxyConfig::fromArray(json_decode($this->request(apiVersion: 'v3'), true));
+ }
+
+ public function update(array $fields): ProxyConfig {
+ $body = new RequestBody();
+ foreach($fields as $key => $value) {
+ $body->withPostData((string) $key, $value);
+ }
+
+ // Docs: PATCH /api/v2/proxy/config/ (get/status/stats use v3).
+ return ProxyConfig::fromArray(json_decode($this->request(RequestMethod::PATCH, body: $body, apiVersion: 'v2'), true));
+ }
+
+ public function status(): ProxyStatus {
+ return ProxyStatus::fromArray(json_decode($this->requestAt('proxy/list/status', apiVersion: 'v3'), true));
+ }
+
+ public function listStats(): ProxyListStats {
+ return ProxyListStats::fromArray(json_decode($this->requestAt('proxy/list/stats', apiVersion: 'v3'), true));
+ }
+
+ public function assignUnallocatedCountries(array $countries): ProxyConfig {
+ $body = (new RequestBody())->withPostData('countries', $countries);
+
+ return ProxyConfig::fromArray(json_decode(
+ $this->requestAt('proxy/config/assign_unallocated_countries', RequestMethod::POST, body: $body, apiVersion: 'v3'),
+ true,
+ ));
+ }
+
+ private function requestAt(
+ string $endpoint,
+ RequestMethod $method = RequestMethod::GET,
+ ?RequestBody $body = NULL,
+ ?string $apiVersion = NULL,
+ ): string {
+ $savedEndpoint = $this->getEndpoint();
+ $this->withEndpoint($endpoint);
+ try {
+ return $this->request($method, body: $body, apiVersion: $apiVersion);
+ } finally {
+ $this->withEndpoint($savedEndpoint);
+ }
+ }
+
+}
diff --git a/src/Requests/ProxyReplacementRequest.php b/src/Requests/ProxyReplacementRequest.php
new file mode 100644
index 0000000..1a5fbbe
--- /dev/null
+++ b/src/Requests/ProxyReplacementRequest.php
@@ -0,0 +1,44 @@
+request(apiVersion: 'v3'), true));
+ }
+
+ /**
+ * @throws \JsonException
+ */
+ public function retrieve(int $id): ProxyReplacement {
+ return ProxyReplacement::fromArray(json_decode($this->request(additionalEndpoint: (string) $id, apiVersion: 'v3'), true));
+ }
+
+ /**
+ * @throws \JsonException
+ */
+ public function create(array $to_replace, array $replace_with, bool $dry_run = false): ProxyReplacement {
+ $body = (new RequestBody())->withPostData('to_replace', $to_replace)
+ ->withPostData('replace_with', $replace_with)
+ ->withPostData('dry_run', $dry_run);
+ return ProxyReplacement::fromArray(json_decode($this->request(RequestMethod::POST, body: $body, apiVersion: 'v3'), true));
+ }
+
+}
\ No newline at end of file
diff --git a/src/Requests/ProxyRequest.php b/src/Requests/ProxyRequest.php
new file mode 100644
index 0000000..39253c1
--- /dev/null
+++ b/src/Requests/ProxyRequest.php
@@ -0,0 +1,44 @@
+request(), true));
+ }
+
+ /**
+ * @return \Generator
+ */
+ public function iterate(?int $maxPages = NULL): \Generator {
+ return PageIterator::items(function(int $pageIndex) {
+ $this->query()->withPage($pageIndex + 1);
+
+ return $this->getList();
+ }, $maxPages);
+ }
+
+ public function downloadList(string $token, string $countryCodes = '-', string $authMethod = 'username', string $mode = 'direct', string $search = '-'): string {
+ return $this->request(RequestMethod::GET, sprintf('download/%s/%s/any/%s/%s/%s/', $token, $countryCodes, $authMethod, $mode, urlencode($search)));
+ }
+
+ public function refresh(): void {
+ $this->request(RequestMethod::POST, 'refresh/');
+ }
+
+}
diff --git a/src/Requests/ProxyStatsRequest.php b/src/Requests/ProxyStatsRequest.php
new file mode 100644
index 0000000..6a2081e
--- /dev/null
+++ b/src/Requests/ProxyStatsRequest.php
@@ -0,0 +1,100 @@
+
+ */
+ public function list(): array {
+ $decoded = json_decode($this->request(), true) ?? [];
+ if(!is_array($decoded)) {
+ return [];
+ }
+
+ return array_map(
+ static fn(array $row) => ProxyStat::fromArray($row),
+ array_values(array_filter($decoded, 'is_array')),
+ );
+ }
+
+ public function aggregate(): ProxyStat {
+ $saved = $this->getEndpoint();
+ $this->withEndpoint('stats/aggregate');
+ try {
+ return ProxyStat::fromArray(json_decode($this->request(), true));
+ } finally {
+ $this->withEndpoint($saved);
+ }
+ }
+
+ public function listActivities(): ProxyActivityList {
+ $saved = $this->getEndpoint();
+ $this->withEndpoint('proxy/activity');
+ try {
+ return ProxyActivityList::fromArray(json_decode($this->request(), true));
+ } finally {
+ $this->withEndpoint($saved);
+ }
+ }
+
+ /**
+ * @return \Generator
+ */
+ public function iterateActivities(?int $maxPages = NULL): \Generator {
+ $lastTimestamp = NULL;
+
+ return PageIterator::items(function() use (&$lastTimestamp) {
+ if($lastTimestamp !== NULL) {
+ $this->query()->withStartingAfter($lastTimestamp);
+ }
+
+ $list = $this->listActivities();
+ if($list->results !== []) {
+ $last = $list->results[array_key_last($list->results)];
+ if($last instanceof ProxyActivity && $last->timestamp !== NULL) {
+ $lastTimestamp = $last->timestamp;
+ }
+ }
+
+ return $list;
+ }, $maxPages);
+ }
+
+ public function downloadActivities(): string {
+ $saved = $this->getEndpoint();
+ $this->withEndpoint('proxy/activity/download');
+ try {
+ return $this->request(RequestMethod::GET);
+ } finally {
+ $this->withEndpoint($saved);
+ }
+ }
+
+}
diff --git a/src/Requests/ReplacedProxyRequest.php b/src/Requests/ReplacedProxyRequest.php
new file mode 100644
index 0000000..1cb6319
--- /dev/null
+++ b/src/Requests/ReplacedProxyRequest.php
@@ -0,0 +1,26 @@
+request(), true));
+ }
+
+ public function download(string $token, string $countryCodes = '-', string $authMethod = 'username', string $mode = 'direct', string $search = '-'): string {
+ // ponytail: docs show download via GET with query params or path params, standardizing to path params to match existing ProxyRequest style.
+ return $this->request(RequestMethod::GET, sprintf('download/%s/%s/any/%s/%s/%s/', $token, $countryCodes, $authMethod, $mode, urlencode($search)));
+ }
+
+}
\ No newline at end of file
diff --git a/src/Requests/SubUserRequest.php b/src/Requests/SubUserRequest.php
new file mode 100644
index 0000000..daf1959
--- /dev/null
+++ b/src/Requests/SubUserRequest.php
@@ -0,0 +1,76 @@
+request(), true));
+ }
+
+ /**
+ * @return \Generator
+ */
+ public function iterate(?int $maxPages = NULL): \Generator {
+ return PageIterator::items(function(int $pageIndex) {
+ $this->query()->withPage($pageIndex + 1);
+
+ return $this->list();
+ }, $maxPages);
+ }
+
+ public function retrieve(int $id): SubUser {
+ return SubUser::fromArray(json_decode($this->request(additionalEndpoint: (string) $id), true));
+ }
+
+ public function create(string $label, ?float $proxyLimit = NULL, ?int $maxThreadCount = NULL): SubUser {
+ $body = (new RequestBody())->withPostData('label', $label);
+ if($proxyLimit !== NULL) {
+ $body->withPostData('proxy_limit', $proxyLimit);
+ }
+ if($maxThreadCount !== NULL) {
+ $body->withPostData('max_thread_count', $maxThreadCount);
+ }
+
+ return SubUser::fromArray(json_decode($this->request(RequestMethod::POST, body: $body), true));
+ }
+
+ public function update(int $id, array $fields): SubUser {
+ $body = new RequestBody();
+ foreach($fields as $key => $value) {
+ $body->withPostData((string) $key, $value);
+ }
+
+ return SubUser::fromArray(json_decode($this->request(RequestMethod::PATCH, (string) $id, $body), true));
+ }
+
+ public function delete(int $id): void {
+ $this->request(RequestMethod::DELETE, (string) $id);
+ }
+
+ public function refreshProxyList(int $id): SubUser {
+ return SubUser::fromArray(json_decode($this->request(RequestMethod::POST, $id . '/refresh/'), true));
+ }
+
+ /**
+ * Masquerade subsequent App::Client() calls as this sub-user (X-Subuser header).
+ */
+ public function masqueradeAs(int|string|null $subuserId): void {
+ App::Client()->withSubuser($subuserId === NULL ? NULL : (string) $subuserId);
+ }
+
+}
diff --git a/src/Responses/ApiKeyList.php b/src/Responses/ApiKeyList.php
new file mode 100644
index 0000000..d1f0bae
--- /dev/null
+++ b/src/Responses/ApiKeyList.php
@@ -0,0 +1,18 @@
+
+ */
+ #[ArrayOf(ApiKey::class)]
+ public array $results = [];
+
+}
diff --git a/src/Responses/IpAuthorizationList.php b/src/Responses/IpAuthorizationList.php
new file mode 100644
index 0000000..76854ce
--- /dev/null
+++ b/src/Responses/IpAuthorizationList.php
@@ -0,0 +1,18 @@
+
+ */
+ #[ArrayOf(IpAuthorization::class)]
+ public array $results = [];
+
+}
diff --git a/src/Responses/NotificationList.php b/src/Responses/NotificationList.php
new file mode 100644
index 0000000..c4c3dd5
--- /dev/null
+++ b/src/Responses/NotificationList.php
@@ -0,0 +1,15 @@
+
+ */
+ #[ArrayOf(Notification::class)]
+ public array $results = [];
+}
\ No newline at end of file
diff --git a/src/Responses/PaginatedResponse.php b/src/Responses/PaginatedResponse.php
new file mode 100644
index 0000000..9528b29
--- /dev/null
+++ b/src/Responses/PaginatedResponse.php
@@ -0,0 +1,39 @@
+
+ */
+ #[ArrayOf(ProxyActivity::class)]
+ public array $results = [];
+
+}
diff --git a/src/Responses/ProxyList.php b/src/Responses/ProxyList.php
new file mode 100644
index 0000000..7adf44c
--- /dev/null
+++ b/src/Responses/ProxyList.php
@@ -0,0 +1,18 @@
+
+ */
+ #[ArrayOf(Proxy::class)]
+ public array $results = [];
+
+}
\ No newline at end of file
diff --git a/src/Responses/ProxyReplacementList.php b/src/Responses/ProxyReplacementList.php
new file mode 100644
index 0000000..7f791fd
--- /dev/null
+++ b/src/Responses/ProxyReplacementList.php
@@ -0,0 +1,18 @@
+
+ */
+ #[ArrayOf(ProxyReplacement::class)]
+ public array $results = [];
+
+}
\ No newline at end of file
diff --git a/src/Responses/ReplacedProxyList.php b/src/Responses/ReplacedProxyList.php
new file mode 100644
index 0000000..5a7557e
--- /dev/null
+++ b/src/Responses/ReplacedProxyList.php
@@ -0,0 +1,18 @@
+
+ */
+ #[ArrayOf(ReplacedProxy::class)]
+ public array $results = [];
+
+}
\ No newline at end of file
diff --git a/src/Responses/SubUserList.php b/src/Responses/SubUserList.php
new file mode 100644
index 0000000..883f9df
--- /dev/null
+++ b/src/Responses/SubUserList.php
@@ -0,0 +1,18 @@
+
+ */
+ #[ArrayOf(SubUser::class)]
+ public array $results = [];
+
+}
diff --git a/tests/Classes/AppConfigTest.php b/tests/Classes/AppConfigTest.php
new file mode 100644
index 0000000..5c14661
--- /dev/null
+++ b/tests/Classes/AppConfigTest.php
@@ -0,0 +1,29 @@
+assertSame(7, App::iterateMaxPages());
+ $this->assertFalse(App::cacheEnabled());
+ $this->assertSame(120, App::cacheLifetime());
+ }
+
+ public function testRequestsDiscoversProxyRequest(): void {
+ $req = App::Requests('Proxy');
+ $this->assertInstanceOf(\Devcraft\Webshare\Requests\ProxyRequest::class, $req);
+ $this->assertNull(App::Requests('DoesNotExist'));
+ }
+
+}
diff --git a/tests/Classes/ClientTest.php b/tests/Classes/ClientTest.php
new file mode 100644
index 0000000..46df256
--- /dev/null
+++ b/tests/Classes/ClientTest.php
@@ -0,0 +1,130 @@
+cacheDir = sys_get_temp_dir() . '/webshare-test-cache-' . uniqid('', true);
+ mkdir($this->cacheDir);
+ $_ENV['API_KEY'] = 'test-key';
+ $_ENV['CACHE_DIRECTORY'] = $this->cacheDir;
+ $_ENV['CACHE_LIFETIME'] = '3600';
+ $_ENV['CACHE_ENABLED'] = '1';
+ $_ENV['DEBUG'] = '0';
+ App::resetForTests();
+ }
+
+ protected function tearDown(): void {
+ $this->removeDir($this->cacheDir);
+ }
+
+ public function testGetWithoutBodyDoesNotTypeError(): void {
+ $mock = new MockHandler([new Response(200, [], '{"ok":true}')]);
+ $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create($mock)]));
+
+ $this->assertSame('{"ok":true}', $client->send('profile'));
+ }
+
+ public function testGetResponseIsCached(): void {
+ $mock = new MockHandler([
+ new Response(200, [], '{"page":1}'),
+ new Response(200, [], '{"page":2}'),
+ ]);
+ $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create($mock)]));
+
+ $first = $client->send('profile');
+ $second = $client->send('profile');
+
+ $this->assertSame('{"page":1}', $first);
+ $this->assertSame('{"page":1}', $second);
+ $this->assertSame(1, $mock->count());
+ }
+
+ public function testForceRefreshBypassesCache(): void {
+ $mock = new MockHandler([
+ new Response(200, [], '{"page":1}'),
+ new Response(200, [], '{"page":2}'),
+ ]);
+ $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create($mock)]));
+
+ $client->send('profile');
+ $second = $client->send('profile', forceRefresh: true);
+
+ $this->assertSame('{"page":2}', $second);
+ }
+
+ public function testDownloadPathIsNotCached(): void {
+ $mock = new MockHandler([
+ new Response(200, [], 'download-1'),
+ new Response(200, [], 'download-2'),
+ ]);
+ $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create($mock)]));
+
+ $this->assertSame('download-1', $client->send('proxy/list/download/token/-/any/username/direct/-/'));
+ $this->assertSame('download-2', $client->send('proxy/list/download/token/-/any/username/direct/-/'));
+ }
+
+ public function testMutationInvalidatesCacheTag(): void {
+ $mock = new MockHandler([
+ new Response(200, [], '{"v":1}'),
+ new Response(204, [], ''),
+ new Response(200, [], '{"v":2}'),
+ ]);
+ $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create($mock)]));
+
+ $this->assertSame('{"v":1}', $client->send('notification'));
+ $client->send('notification/1/dismiss/', RequestMethod::POST);
+ $this->assertSame('{"v":2}', $client->send('notification'));
+ }
+
+ public function testParseRetryAfterTreatsUnixResetAsDeltaAndClamps(): void {
+ $client = new Client('test-key', 'https://example.com/api/', 'v2', new GuzzleClient(['handler' => HandlerStack::create(new MockHandler())]));
+ $method = new \ReflectionMethod(Client::class, 'parseRetryAfter');
+
+ $seconds = $method->invoke($client, [
+ 'x-ratelimit-reset' => [(string) (time() + 99999)],
+ ]);
+ $this->assertSame(120, $seconds);
+
+ $seconds = $method->invoke($client, [
+ 'retry-after' => ['5'],
+ 'x-ratelimit-reset' => [(string) (time() + 99999)],
+ ]);
+ $this->assertSame(5, $seconds);
+
+ $seconds = $method->invoke($client, [
+ 'x-ratelimit-reset' => ['30'],
+ ]);
+ $this->assertSame(30, $seconds);
+ }
+
+ private function removeDir(string $dir): void {
+ if(!is_dir($dir)) {
+ return;
+ }
+ foreach(scandir($dir) ?: [] as $item) {
+ if($item === '.' || $item === '..') {
+ continue;
+ }
+ $path = $dir . DIRECTORY_SEPARATOR . $item;
+ is_dir($path) ? $this->removeDir($path) : unlink($path);
+ }
+ rmdir($dir);
+ }
+
+}
diff --git a/tests/Classes/PageIteratorTest.php b/tests/Classes/PageIteratorTest.php
new file mode 100644
index 0000000..b3b3082
--- /dev/null
+++ b/tests/Classes/PageIteratorTest.php
@@ -0,0 +1,54 @@
+results = ['a', 'b'];
+ $page->next = NULL;
+
+ return $page;
+ }, 10));
+
+ $this->assertSame(['a', 'b'], $items);
+ $this->assertSame(1, $calls);
+ }
+
+ public function testThrowsWhenMaxPagesExceeded(): void {
+ $this->expectException(ApiException::class);
+ $this->expectExceptionMessage('PageIterator exceeded max pages (2)');
+
+ iterator_to_array(PageIterator::items(function(int $i) {
+ $page = new PaginatedResponse();
+ $page->results = [$i];
+ $page->next = 'https://example.com/next';
+
+ return $page;
+ }, 2));
+ }
+
+ public function testYieldsAcrossPagesUntilNextNull(): void {
+ $items = iterator_to_array(PageIterator::items(function(int $i) {
+ $page = new PaginatedResponse();
+ $page->results = ["p{$i}"];
+ $page->next = $i < 1 ? 'https://example.com/next' : NULL;
+
+ return $page;
+ }, 10));
+
+ $this->assertSame(['p0', 'p1'], $items);
+ }
+
+}
diff --git a/tests/Classes/QueryBuilderExtendedTest.php b/tests/Classes/QueryBuilderExtendedTest.php
new file mode 100644
index 0000000..6b0b997
--- /dev/null
+++ b/tests/Classes/QueryBuilderExtendedTest.php
@@ -0,0 +1,43 @@
+withOptionalFields(['status', 'amount', 'city', 'ordering', 'search'])
+ ->withFilter('status', ['a', 'b'], FilterOperator::IN)
+ ->withFilter('amount', 10, FilterOperator::GT)
+ ->withFilter('city', 'New', FilterOperator::CONTAINS)
+ ->withOrderBy('-created_at', 'status')
+ ->withSearch('Free')
+ ->withPage(2)
+ ->withPageSize(50);
+
+ $built = $query->build();
+ $this->assertStringContainsString('status__in=a%2Cb', $built);
+ $this->assertStringContainsString('amount__gt=10', $built);
+ $this->assertStringContainsString('city__contains=New', $built);
+ $this->assertStringContainsString('ordering=-created_at%2Cstatus', $built);
+ $this->assertStringContainsString('search=Free', $built);
+ $this->assertStringContainsString('page=2', $built);
+ $this->assertStringContainsString('page_size=50', $built);
+ }
+
+ public function testUnknownFieldThrows(): void {
+ $this->expectException(ApiException::class);
+ (new QueryBuilder())
+ ->withOptionalFields(['status'])
+ ->withFilter('nope', 1);
+ }
+
+}
diff --git a/tests/Classes/QueryBuilderTest.php b/tests/Classes/QueryBuilderTest.php
new file mode 100644
index 0000000..2edbcda
--- /dev/null
+++ b/tests/Classes/QueryBuilderTest.php
@@ -0,0 +1,37 @@
+withOptionalFields(['ordering']);
+ $this->assertSame('', $query->build());
+ }
+
+ public function testBuildRequiresAllRequiredFields(): void {
+ $query = (new QueryBuilder())
+ ->withOptionalFields(['mode', 'plan_id'])
+ ->withRequiredFields(['mode']);
+
+ $this->expectException(ApiException::class);
+ $this->expectExceptionMessage('Required fields are not set: mode');
+ $query->build();
+ }
+
+ public function testBuildPassesWhenRequiredFieldPresent(): void {
+ $query = (new QueryBuilder())
+ ->withOptionalFields(['mode'])
+ ->withRequiredFields(['mode'])
+ ->withFilter('mode', 'direct');
+
+ $this->assertStringContainsString('mode=direct', $query->build());
+ }
+
+}
diff --git a/tests/Entities/EntityHydrationTest.php b/tests/Entities/EntityHydrationTest.php
new file mode 100644
index 0000000..a9f1af7
--- /dev/null
+++ b/tests/Entities/EntityHydrationTest.php
@@ -0,0 +1,211 @@
+ '1',
+ 'username' => 'user',
+ 'password' => 'pass',
+ 'proxy_address' => '1.2.3.4',
+ 'port' => 80,
+ 'country_code' => 'US',
+ 'valid' => true,
+ ]);
+
+ $this->assertSame('user', $proxy->username);
+ $this->assertSame(80, $proxy->port);
+ $this->assertSame('http://user:pass@1.2.3.4:80', $proxy->getConnectionString());
+ $this->assertSame(
+ 'http://user-us-city_los_angeles-rotate:pass@p.webshare.io:80',
+ $proxy->getConnectionString(backbone: true, session: 'rotate', city: 'los_angeles'),
+ );
+ }
+
+ public function testProxyListFromArray(): void {
+ $list = ProxyList::fromArray([
+ 'count' => 1,
+ 'next' => NULL,
+ 'previous' => NULL,
+ 'results' => [
+ [
+ 'id' => '9',
+ 'proxy_address' => '10.0.0.1',
+ 'port' => 8080,
+ 'username' => 'u',
+ 'password' => 'p',
+ ],
+ ],
+ ]);
+
+ $this->assertSame(1, $list->count);
+ $this->assertCount(1, $list->results);
+ $this->assertInstanceOf(Proxy::class, $list->results[0]);
+ $this->assertSame('10.0.0.1', $list->results[0]->proxy_address);
+ }
+
+ public function testProxyConfigFromArray(): void {
+ $config = ProxyConfig::fromArray([
+ 'id' => 1,
+ 'state' => 'completed',
+ 'username' => 'username',
+ 'request_timeout' => 86400,
+ 'ip_authorization_country_codes' => ['US', 'FR'],
+ 'proxy_list_download_token' => 'tok',
+ 'auto_replace_invalid_proxies'=> true,
+ ]);
+
+ $this->assertSame(1, $config->id);
+ $this->assertSame('completed', $config->state);
+ $this->assertSame(['US', 'FR'], $config->ip_authorization_country_codes);
+ $this->assertSame('tok', $config->proxy_list_download_token);
+ }
+
+ public function testProxyStatusAndListStatsFromArray(): void {
+ $status = ProxyStatus::fromArray([
+ 'state' => 'completed',
+ 'countries' => ['US' => 5],
+ 'username' => 'u',
+ 'is_proxy_used' => false,
+ ]);
+ $stats = ProxyListStats::fromArray([
+ 'available_countries' => ['US' => 95],
+ 'ip_ranges_24' => ['10.1.1.0/24' => 5],
+ ]);
+
+ $this->assertSame('completed', $status->state);
+ $this->assertSame(95, $stats->available_countries['US']);
+ }
+
+ public function testIpAuthorizationListFromArray(): void {
+ $list = IpAuthorizationList::fromArray([
+ 'count' => 1,
+ 'next' => NULL,
+ 'previous'=> NULL,
+ 'results' => [
+ [
+ 'id' => 1337,
+ 'ip_address' => '10.1.2.3',
+ 'created_at' => '2022-06-14T11:58:10.246406-07:00',
+ 'last_used_at' => NULL,
+ ],
+ ],
+ ]);
+
+ $this->assertInstanceOf(IpAuthorization::class, $list->results[0]);
+ $this->assertSame(1337, $list->results[0]->id);
+ $this->assertSame('10.1.2.3', $list->results[0]->ip_address);
+ }
+
+ public function testProxyStatAndActivityFromArray(): void {
+ $stat = ProxyStat::fromArray([
+ 'timestamp' => '2022-08-11T17:00:00-07:00',
+ 'bandwidth_total' => 5000,
+ 'requests_total' => 5,
+ 'requests_successful' => 4,
+ 'error_reasons' => [
+ [
+ 'reason' => 'client_connect_forbidden_host',
+ 'type' => 'configuration',
+ 'how_to_fix' => 'fix it',
+ 'http_status' => 403,
+ 'count' => 1,
+ ],
+ ],
+ 'countries_used' => ['GB' => 1],
+ ]);
+
+ $activity = ProxyActivity::fromArray([
+ 'timestamp' => '2022-08-16T15:29:42.517523-07:00',
+ 'protocol' => 'http',
+ 'proxy_address' => '192.168.5.1',
+ 'bytes' => 0,
+ 'hostname' => 'ipv4.webshare.io',
+ 'port' => 443,
+ ]);
+
+ $list = ProxyActivityList::fromArray([
+ 'count' => 1,
+ 'next' => NULL,
+ 'previous'=> NULL,
+ 'results' => [
+ [
+ 'timestamp' => '2022-08-16T15:29:42.517523-07:00',
+ 'protocol' => 'http',
+ 'proxy_address' => '192.168.5.1',
+ ],
+ ],
+ ]);
+
+ $this->assertSame(5000, $stat->bandwidth_total);
+ $this->assertCount(1, $stat->error_reasons);
+ $this->assertSame('configuration', $stat->error_reasons[0]->type);
+ $this->assertSame('http', $activity->protocol);
+ $this->assertInstanceOf(ProxyActivity::class, $list->results[0]);
+ }
+
+ public function testDownloadTokenApiKeySubUserNotificationFromArray(): void {
+ $token = DownloadToken::fromArray([
+ 'id' => 56,
+ 'key' => 'abcdefghijklmnopqrstuvwxyz',
+ 'scope' => 'activity',
+ 'expire_at' => '2022-06-14T11:58:10.246406-07:00',
+ ]);
+ $key = ApiKey::fromArray([
+ 'id' => 1337,
+ 'key' => 'abc1234',
+ 'label' => 'server1 key',
+ ]);
+ $user = SubUser::fromArray([
+ 'id' => 7,
+ 'label' => 'Test User',
+ 'proxy_limit' => 10.0,
+ 'proxy_countries' => ['ZZ' => 1000],
+ ]);
+ $notification = Notification::fromArray([
+ 'id' => 1,
+ 'type' => 'info',
+ 'is_dismissable' => true,
+ 'context' => ['x' => 1],
+ ]);
+
+ $keys = ApiKeyList::fromArray([
+ 'count' => 1, 'next' => NULL, 'previous' => NULL,
+ 'results' => [['id' => 1, 'key' => 'k', 'label' => 'l']],
+ ]);
+ $users = SubUserList::fromArray([
+ 'count' => 1, 'next' => NULL, 'previous' => NULL,
+ 'results' => [['id' => 7, 'label' => 'u', 'proxy_limit' => 1.0]],
+ ]);
+
+ $this->assertSame('activity', $token->scope);
+ $this->assertSame('server1 key', $key->label);
+ $this->assertSame(7, $user->id);
+ $this->assertTrue($notification->is_dismissable);
+ $this->assertInstanceOf(ApiKey::class, $keys->results[0]);
+ $this->assertInstanceOf(SubUser::class, $users->results[0]);
+ }
+
+}
diff --git a/tests/Exceptions/ExceptionTest.php b/tests/Exceptions/ExceptionTest.php
new file mode 100644
index 0000000..3a38310
--- /dev/null
+++ b/tests/Exceptions/ExceptionTest.php
@@ -0,0 +1,39 @@
+ [
+ ['message' => 'This field is required.', 'code' => 'required'],
+ ],
+ ], JSON_THROW_ON_ERROR));
+
+ $this->assertSame(400, $ex->getCode());
+ $this->assertStringContainsString('mode: This field is required.', $ex->getMessage());
+ $errors = $ex->getValidationErrors();
+ $this->assertSame('required', $errors['mode'][0]['code']);
+ }
+
+ public function testFromValidationResponseInvalidJson(): void {
+ $ex = ApiException::fromValidationResponse('not-json');
+ $this->assertSame('Bad Request', $ex->getMessage());
+ $this->assertSame([], $ex->getValidationErrors());
+ }
+
+ public function testRateLimitExceptionMessage(): void {
+ $ex = new RateLimitException(45);
+ $this->assertSame(429, $ex->getCode());
+ $this->assertSame(45, $ex->retryAfterSeconds);
+ $this->assertStringContainsString('45s', $ex->getMessage());
+ }
+
+}
diff --git a/tests/Requests/RequestDecodeTest.php b/tests/Requests/RequestDecodeTest.php
new file mode 100644
index 0000000..319ca45
--- /dev/null
+++ b/tests/Requests/RequestDecodeTest.php
@@ -0,0 +1,218 @@
+ 1, 'next' => NULL, 'previous' => NULL,
+ 'results' => [[
+ 'id' => '1', 'username' => 'u', 'password' => 'p',
+ 'proxy_address' => '1.2.3.4', 'port' => 80, 'country_code' => 'US',
+ ]],
+ ], JSON_THROW_ON_ERROR);
+ $this->injectMockClient(new MockHandler([new Response(200, [], $payload)]));
+
+ $req = new ProxyRequest();
+ $req->query()->withFilter('mode', 'direct');
+ $list = $req->getList();
+
+ $this->assertCount(1, $list->results);
+ $this->assertInstanceOf(Proxy::class, $list->results[0]);
+ $this->assertSame('1.2.3.4', $list->results[0]->proxy_address);
+ }
+
+ public function testIpAuthorizationListAndCreate(): void {
+ $listBody = json_encode([
+ 'count' => 1, 'next' => NULL, 'previous' => NULL,
+ 'results' => [['id' => 1, 'ip_address' => '10.0.0.1', 'created_at' => 't', 'last_used_at' => NULL]],
+ ], JSON_THROW_ON_ERROR);
+ $createBody = json_encode([
+ 'id' => 2, 'ip_address' => '10.0.0.2', 'created_at' => 't', 'last_used_at' => NULL,
+ ], JSON_THROW_ON_ERROR);
+ $whatsMyIp = json_encode(['ip_address' => '9.9.9.9'], JSON_THROW_ON_ERROR);
+
+ $this->injectMockClient(new MockHandler([
+ new Response(200, [], $listBody),
+ new Response(200, [], $createBody),
+ new Response(200, [], $whatsMyIp),
+ ]));
+
+ $req = new IpAuthorizationRequest();
+ $list = $req->list();
+ $created = $req->create('10.0.0.2');
+ $mine = $req->whatsMyIp();
+
+ $this->assertInstanceOf(IpAuthorization::class, $list->results[0]);
+ $this->assertSame(2, $created->id);
+ $this->assertSame('9.9.9.9', $mine['ip_address']);
+ }
+
+ public function testNotificationListAndRetrieve(): void {
+ $listBody = json_encode([
+ 'count' => 1, 'next' => NULL, 'previous' => NULL,
+ 'results' => [['id' => 5, 'type' => 'info', 'is_dismissable' => true]],
+ ], JSON_THROW_ON_ERROR);
+ $one = json_encode(['id' => 5, 'type' => 'info', 'is_dismissable' => true], JSON_THROW_ON_ERROR);
+
+ $this->injectMockClient(new MockHandler([
+ new Response(200, [], $listBody),
+ new Response(200, [], $one),
+ ]));
+
+ $req = new NotificationRequest();
+ $list = $req->list();
+ $item = $req->retrieve(5);
+
+ $this->assertInstanceOf(Notification::class, $list->results[0]);
+ $this->assertSame(5, $item->id);
+ }
+
+ public function testProfileRetrieve(): void {
+ $body = json_encode([
+ 'id' => 1, 'email' => 'a@b.c', 'first_name' => 'A', 'last_name' => 'B',
+ ], JSON_THROW_ON_ERROR);
+ $this->injectMockClient(new MockHandler([new Response(200, [], $body)]));
+
+ $profile = (new ProfileRequest())->retrieve();
+ $this->assertInstanceOf(Profile::class, $profile);
+ }
+
+ public function testProxyStatsListAndAggregate(): void {
+ $listBody = json_encode([[
+ 'timestamp' => '2022-08-11T17:00:00-07:00',
+ 'bandwidth_total' => 5000,
+ 'requests_total' => 5,
+ 'error_reasons' => [],
+ ]], JSON_THROW_ON_ERROR);
+ $aggBody = json_encode([
+ 'bandwidth_total' => 5000,
+ 'requests_total' => 5,
+ 'error_reasons' => [],
+ ], JSON_THROW_ON_ERROR);
+
+ $this->injectMockClient(new MockHandler([
+ new Response(200, [], $listBody),
+ new Response(200, [], $aggBody),
+ ]));
+
+ $req = new ProxyStatsRequest();
+ $list = $req->list();
+ $agg = $req->aggregate();
+
+ $this->assertCount(1, $list);
+ $this->assertInstanceOf(ProxyStat::class, $list[0]);
+ $this->assertSame(5000, $agg->bandwidth_total);
+ }
+
+ public function testDownloadTokenGet(): void {
+ $body = json_encode([
+ 'id' => 56, 'key' => 'abc', 'scope' => 'activity', 'expire_at' => 't',
+ ], JSON_THROW_ON_ERROR);
+ $this->injectMockClient(new MockHandler([new Response(200, [], $body)]));
+
+ $token = (new DownloadTokenRequest())->get('activity');
+ $this->assertInstanceOf(DownloadToken::class, $token);
+ $this->assertSame('activity', $token->scope);
+ }
+
+ public function testApiKeyListAndCreate(): void {
+ $listBody = json_encode([
+ 'count' => 1, 'next' => NULL, 'previous' => NULL,
+ 'results' => [['id' => 1, 'key' => 'k', 'label' => 'l']],
+ ], JSON_THROW_ON_ERROR);
+ $createBody = json_encode([
+ 'id' => 2, 'key' => 'new', 'label' => 'server',
+ ], JSON_THROW_ON_ERROR);
+
+ $this->injectMockClient(new MockHandler([
+ new Response(200, [], $listBody),
+ new Response(200, [], $createBody),
+ ]));
+
+ $req = new ApiKeyRequest();
+ $list = $req->list();
+ $created = $req->create('server');
+
+ $this->assertInstanceOf(ApiKey::class, $list->results[0]);
+ $this->assertSame('server', $created->label);
+ }
+
+ public function testSubUserListAndCreate(): void {
+ $listBody = json_encode([
+ 'count' => 1, 'next' => NULL, 'previous' => NULL,
+ 'results' => [['id' => 7, 'label' => 'Test', 'proxy_limit' => 10.0]],
+ ], JSON_THROW_ON_ERROR);
+ $createBody = json_encode([
+ 'id' => 8, 'label' => 'new', 'proxy_limit' => 5.0, 'max_thread_count' => 100,
+ ], JSON_THROW_ON_ERROR);
+
+ $this->injectMockClient(new MockHandler([
+ new Response(200, [], $listBody),
+ new Response(200, [], $createBody),
+ ]));
+
+ $req = new SubUserRequest();
+ $list = $req->list();
+ $created = $req->create('new', 5.0, 100);
+
+ $this->assertInstanceOf(SubUser::class, $list->results[0]);
+ $this->assertSame(8, $created->id);
+ }
+
+ public function testProxyConfigRetrieveUsesV3Client(): void {
+ $body = json_encode([
+ 'id' => 1,
+ 'state' => 'completed',
+ 'username' => 'u',
+ 'proxy_list_download_token' => 'tok',
+ ], JSON_THROW_ON_ERROR);
+
+ // retrieve() forces apiVersion v3 → newClient; inject v3 as singleton by matching version.
+ $_ENV['API_VERSION'] = 'v3';
+ \Devcraft\Webshare\Classes\App::resetForTests();
+ $this->injectMockClient(new MockHandler([new Response(200, [], $body)]), 'v3');
+
+ $req = new ProxyConfigRequest();
+ $req->withPlanId(1);
+ $config = $req->retrieve();
+
+ $this->assertInstanceOf(ProxyConfig::class, $config);
+ $this->assertSame('tok', $config->proxy_list_download_token);
+ }
+
+ public function testSubUserMasqueradeSetsClientHeaderState(): void {
+ $this->injectMockClient(new MockHandler([]));
+ $req = new SubUserRequest();
+ $req->masqueradeAs(42);
+ $this->assertSame('42', \Devcraft\Webshare\Classes\App::Client()->getSubuser());
+ $req->masqueradeAs(NULL);
+ $this->assertNull(\Devcraft\Webshare\Classes\App::Client()->getSubuser());
+ }
+
+}
diff --git a/tests/TestCaseWithApp.php b/tests/TestCaseWithApp.php
new file mode 100644
index 0000000..80354d4
--- /dev/null
+++ b/tests/TestCaseWithApp.php
@@ -0,0 +1,66 @@
+cacheDir = sys_get_temp_dir() . '/webshare-test-cache-' . uniqid('', true);
+ mkdir($this->cacheDir);
+ $_ENV['API_KEY'] = 'test-key';
+ $_ENV['API_URL'] = 'https://example.com/api/';
+ $_ENV['API_VERSION'] = 'v2';
+ $_ENV['CACHE_DIRECTORY'] = $this->cacheDir;
+ $_ENV['CACHE_LIFETIME'] = '3600';
+ $_ENV['CACHE_ENABLED'] = '1';
+ $_ENV['ITERATE_MAX_PAGES'] = '100';
+ $_ENV['DEBUG'] = '0';
+ App::resetForTests();
+ }
+
+ protected function tearDown(): void {
+ $this->removeDir($this->cacheDir);
+ parent::tearDown();
+ }
+
+ protected function injectMockClient(MockHandler $mock, string $version = 'v2'): Client {
+ $client = new Client(
+ 'test-key',
+ 'https://example.com/api/',
+ $version,
+ new GuzzleClient(['handler' => HandlerStack::create($mock)]),
+ );
+
+ $property = new \ReflectionProperty(App::class, 'client');
+ $property->setValue(NULL, $client);
+
+ return $client;
+ }
+
+ protected function removeDir(string $dir): void {
+ if(!is_dir($dir)) {
+ return;
+ }
+ foreach(scandir($dir) ?: [] as $item) {
+ if($item === '.' || $item === '..') {
+ continue;
+ }
+ $path = $dir . DIRECTORY_SEPARATOR . $item;
+ is_dir($path) ? $this->removeDir($path) : unlink($path);
+ }
+ rmdir($dir);
+ }
+
+}