diff --git a/spec/dsl_spec.cr b/spec/dsl_spec.cr deleted file mode 100644 index a7e86f3..0000000 --- a/spec/dsl_spec.cr +++ /dev/null @@ -1,18 +0,0 @@ -require "./spec_helper" - -describe "Grip::Dsl::Macros" do - it "Tests the HTTP verb macro" do - app = HttpApplication.new - app.run - end - - it "Tests the WebSocket verb macro" do - app = WebSocketApplication.new - app.run - end - - it "Tests the error macro" do - app = ErrorApplication.new - app.run - end -end diff --git a/src/grip/handlers/exception.cr b/src/grip/handlers/exception.cr index ca34935..e628dfb 100644 --- a/src/grip/handlers/exception.cr +++ b/src/grip/handlers/exception.cr @@ -3,6 +3,9 @@ module Grip class Exception < Base alias ExceptionHandler = ::HTTP::Handler + # Pre-allocated header for default error response + CONTENT_TYPE_HTML = {"Content-Type" => "text/html; charset=UTF-8"} + property handlers : Hash(String, ExceptionHandler) def initialize @@ -22,6 +25,7 @@ module Grip Radix::Result(Route).new end + @[AlwaysInline] def call(context : ::HTTP::Server::Context) : ::HTTP::Server::Context call_next(context) || context rescue ex : ::Exception @@ -29,27 +33,16 @@ module Grip handle_exception(context, ex) end + @[AlwaysInline] private def handle_exception(context : ::HTTP::Server::Context, exception : ::Exception) : ::HTTP::Server::Context - status_code = determine_status_code(exception, context) - process_exception(context, exception, status_code) - end - - private def determine_status_code(exception : ::Exception, context : ::HTTP::Server::Context) : Int32 - case exception - when Grip::Exceptions::Base - exception.status_code.value - else - context.response.status_code - end - end - - private def process_exception( - context : ::HTTP::Server::Context, - exception : ::Exception, - status_code : Int32, - ) : ::HTTP::Server::Context return context if context.response.closed? + status_code = if exception.is_a?(Grip::Exceptions::Base) + exception.status_code.value + else + context.response.status_code + end + if handler = @handlers[exception.class.name]? execute_custom_handler(context, handler, exception, status_code) else @@ -57,6 +50,7 @@ module Grip end end + @[AlwaysInline] private def execute_custom_handler( context : ::HTTP::Server::Context, handler : ExceptionHandler, @@ -66,22 +60,25 @@ module Grip context.response.status_code = status_code context.exception = exception - updated_context = handler.call(context) + handler.call(context) context.response.close - - updated_context || context + context end + @[AlwaysInline] private def render_default_error( context : ::HTTP::Server::Context, exception : ::Exception, status_code : Int32, ) : ::HTTP::Server::Context - context.response.status_code = status_code.clamp(400, 599) - context.response.headers.merge!({"Content-Type" => "text/html; charset=UTF-8"}) - context.response.print(Grip::Minuscule::ExceptionPage.new(context, exception)) + response = context.response + + # Clamp status code to valid error range + response.status_code = status_code.clamp(400, 599) + response.headers.merge!(CONTENT_TYPE_HTML) + response.print(Grip::Minuscule::ExceptionPage.new(context, exception)) + response.close - context.response.close context end end diff --git a/src/grip/handlers/http.cr b/src/grip/handlers/http.cr index e1c3325..0f8141f 100644 --- a/src/grip/handlers/http.cr +++ b/src/grip/handlers/http.cr @@ -1,16 +1,25 @@ module Grip module Handlers class HTTP < Base - CACHE_LIMIT = 1024 + CACHE_SIZE = 4096 + CACHE_MASK = CACHE_SIZE - 1 + + VERB_IDS = { + "GET" => 0_u8, "POST" => 1_u8, "PUT" => 2_u8, "DELETE" => 3_u8, + "PATCH" => 4_u8, "HEAD" => 5_u8, "OPTIONS" => 6_u8, "ALL" => 255_u8, + } getter routes : Radix::Tree(Route) - getter cache : Hash(String, Radix::Result(Route)) + + @cache : Array(Tuple(UInt64, Radix::Result(Route)?)) + @has_all_routes : Bool = false def initialize @routes = Radix::Tree(Route).new - @cache = Hash(String, Radix::Result(Route)).new + @cache = Array(Tuple(UInt64, Radix::Result(Route)?)).new(CACHE_SIZE) { {0_u64, nil} } end + # Unchanged signature def add_route( verb : String, path : String, @@ -19,22 +28,27 @@ module Grip override : Proc(::HTTP::Server::Context, ::HTTP::Server::Context)? = nil, ) : Nil route = Route.new(verb, path, handler, via, override) - add_to_radix_tree(verb, path, route) + + @has_all_routes = true if verb == "ALL" + @routes.add(radix_path(verb, path), route) end + # Unchanged signature - returns Radix::Result(Route) def find_route(verb : String, path : String) : Radix::Result(Route) - lookup_path = build_radix_path(verb, path) + hash = route_hash(verb, path) - if cached_route = @cache[lookup_path]? - return cached_route + # Check cache + if cached = cache_lookup(hash) + return cached end - route = @routes.find(lookup_path) - cache_route(lookup_path, route) if route.found? - - route + # Radix lookup + result = @routes.find(radix_path(verb, path)) + cache_store(hash, result) if result.found? + result end + # Unchanged signature def call(context : ::HTTP::Server::Context) : ::HTTP::Server::Context return context if context.response.closed? @@ -43,34 +57,111 @@ module Grip context.parameters ||= Grip::Parsers::ParameterBox.new(context.request, route.params) execute_route(route.payload, context) - context end private def resolve_route(verb : String, path : String) : Radix::Result(Route) - route = find_route(verb, path) - route.found? ? route : find_route("ALL", path) + hash = route_hash(verb, path) + + # Check cache first + if cached = cache_lookup(hash) + return cached + end + + # Try exact verb + result = @routes.find(radix_path(verb, path)) + + if result.found? + cache_store(hash, result) + return result + end + + # HEAD -> GET fallback + if verb == "HEAD" + get_result = @routes.find(radix_path("GET", path)) + + if get_result.found? + cache_store(hash, get_result) + return get_result + end + end + + # ALL fallback + if @has_all_routes + all_result = @routes.find(radix_path("ALL", path)) + + if all_result.found? + cache_store(hash, all_result) + return all_result + end + end + + # Return the not-found result + result end - private def execute_route(route : Route, context : ::HTTP::Server::Context) : Nil - if route.override - route.execute_override(context) - else - route.handler.call(context) + @[AlwaysInline] + private def cache_lookup(hash : UInt64) : Radix::Result(Route)? + slot = hash & CACHE_MASK + + 4.times do |i| + idx = (slot + i) & CACHE_MASK + entry = @cache.unsafe_fetch(idx) + return entry[1] if entry[0] == hash && entry[1] + break if entry[0] == 0_u64 && i > 0 + end + + nil + end + + @[AlwaysInline] + private def cache_store(hash : UInt64, result : Radix::Result(Route)) : Nil + slot = hash & CACHE_MASK + + 4.times do |i| + idx = (slot + i) & CACHE_MASK + entry = @cache.unsafe_fetch(idx) + + if entry[0] == 0_u64 || entry[0] == hash + @cache[idx] = {hash, result} + return + end end + + @cache[slot] = {hash, result} end - private def build_radix_path(verb : String, path : String) : String - "/#{verb}#{path}" + @[AlwaysInline] + private def route_hash(verb : String, path : String) : UInt64 + verb_id = VERB_IDS[verb]? || 128_u8 + hash = verb_id.to_u64 << 56 + fnv_prime = 0x100000001b3_u64 + hash ^= 0xcbf29ce484222325_u64 + + path.each_byte do |byte| + hash ^= byte.to_u64 + hash &*= fnv_prime + end + + hash end - private def add_to_radix_tree(verb : String, path : String, route : Route) : Nil - @routes.add(build_radix_path(verb, path), route) + @[AlwaysInline] + private def radix_path(verb : String, path : String) : String + String.build(verb.bytesize + path.bytesize + 1) do |io| + io << '/' + io << verb + io << path + end end - private def cache_route(lookup_path : String, route : Radix::Result(Route)) : Nil - @cache.clear if @cache.size >= CACHE_LIMIT - @cache[lookup_path] = route + @[AlwaysInline] + private def execute_route(route : Route, context : ::HTTP::Server::Context) : Nil + if route.override + route.execute_override(context) + else + route.handler.call(context) + end end end end diff --git a/src/grip/handlers/log.cr b/src/grip/handlers/log.cr index 56e6299..269bde2 100644 --- a/src/grip/handlers/log.cr +++ b/src/grip/handlers/log.cr @@ -14,33 +14,41 @@ module Grip Radix::Result(Route).new end + @[AlwaysInline] def call(context : ::HTTP::Server::Context) : ::HTTP::Server::Context - elapsed_time = measure_request_time(context) - log_request(context, elapsed_time) - context - end + start = Time.monotonic + call_next(context) + elapsed = Time.monotonic - start - private def measure_request_time(context : ::HTTP::Server::Context) : Time::Span - Time.measure { call_next(context) } + log_request(context, elapsed) + context end + @[AlwaysInline] private def log_request(context : ::HTTP::Server::Context, elapsed : Time::Span) : Nil ::Log.info do - [ - context.response.status_code, - context.request.method, - context.request.resource, - format_elapsed_time(elapsed), - ].join(" ") + String.build(64) do |io| + io << context.response.status_code + io << ' ' + io << context.request.method + io << ' ' + io << context.request.resource + io << ' ' + format_elapsed_time(io, elapsed) + end end end - private def format_elapsed_time(elapsed : Time::Span) : String + @[AlwaysInline] + private def format_elapsed_time(io : IO, elapsed : Time::Span) : Nil millis = elapsed.total_milliseconds + if millis >= 1 - "#{millis.round(2)}ms" + io << millis.round(2) + io << "ms" else - "#{(millis * 1000).round(2)}µs" + io << (millis * 1000).round(2) + io << "µs" end end end diff --git a/src/grip/handlers/pipeline.cr b/src/grip/handlers/pipeline.cr index 0350edb..cb4e08f 100644 --- a/src/grip/handlers/pipeline.cr +++ b/src/grip/handlers/pipeline.cr @@ -1,7 +1,8 @@ module Grip module Handlers class Pipeline < Base - CACHED_PIPES = {} of Array(Symbol) => Array(::HTTP::Handler) + # Use instance cache instead of class constant for thread safety + @pipe_cache : Hash(Array(Symbol), Array(::HTTP::Handler)) property pipeline : Hash(Symbol, Array(::HTTP::Handler)) property http_handler : ::HTTP::Handler? @@ -12,6 +13,7 @@ module Grip @websocket_handler = nil, ) @pipeline = Hash(Symbol, Array(HTTP::Handler)).new + @pipe_cache = Hash(Array(Symbol), Array(::HTTP::Handler)).new end def add_route( @@ -27,11 +29,22 @@ module Grip Radix::Result(Route).new end + @[AlwaysInline] def call(context : ::HTTP::Server::Context) - if (websocket_handler && match_via_websocket(context)) || - (http_handler && match_via_http(context)) - return call_next(context) + # Try WebSocket first if handler exists + if ws = @websocket_handler + if match_via_websocket(context, ws.as(WebSocket)) + return call_next(context) + end end + + # Try HTTP if handler exists + if http = @http_handler + if match_via_http(context, http.as(HTTP)) + return call_next(context) + end + end + call_next(context) end @@ -47,57 +60,67 @@ module Grip handlers = @pipeline[valve] ||= Array(::HTTP::Handler).new handlers << pipe handlers[-2]?.try &.next = pipe + + # Invalidate cache when pipes change + @pipe_cache.clear end + @[AlwaysInline] def get(valve : Symbol) : Array(::HTTP::Handler)? @pipeline[valve]? end def get(valves : Array(Symbol)) : Array(::HTTP::Handler) - return CACHED_PIPES[valves] if CACHED_PIPES.has_key?(valves) + # Check cache first + if cached = @pipe_cache[valves]? + return cached + end - pipes = Array(::HTTP::Handler).new + # Build pipe array + pipes = Array(::HTTP::Handler).new(valves.size * 2) # Size hint valves.each do |valve| - @pipeline[valve]?.try &.each { |pipe| pipes << pipe } + if valve_pipes = @pipeline[valve]? + valve_pipes.each { |pipe| pipes << pipe } + end end - CACHED_PIPES[valves] = pipes + @pipe_cache[valves] = pipes pipes end + @[AlwaysInline] def get(valve : Nil) : Nil nil end - def match_via_websocket(context : ::HTTP::Server::Context) : Bool - return false unless websocket_handler = @websocket_handler - ws_handler = websocket_handler.as(WebSocket) + @[AlwaysInline] + private def match_via_websocket(context : ::HTTP::Server::Context, ws_handler : WebSocket) : Bool + # Check upgrade first (cheaper than route lookup) + return false unless ws_handler.websocket_upgrade_request?(context) route = ws_handler.find_route("", context.request.path) - return false unless route.found? && ws_handler.websocket_upgrade_request?(context) + return false unless route.found? context.parameters = Parsers::ParameterBox.new(context.request, route.params) route.payload.process_pipeline(context, self) true end - def match_via_http(context : ::HTTP::Server::Context) : Bool - return false unless http_handler = @http_handler - http = http_handler.as(HTTP) + @[AlwaysInline] + private def match_via_http(context : ::HTTP::Server::Context, http : HTTP) : Bool + route = http.find_route(context.request.method, context.request.path) - route = find_http_route(http, context) - return false unless route.found? + # Try ALL fallback if not found + unless route.found? + route = http.find_route("ALL", context.request.path) + return false unless route.found? + end context.parameters = Parsers::ParameterBox.new(context.request, route.params) route.payload.process_pipeline(context, self) true end - - private def find_http_route(http : HTTP, context : ::HTTP::Server::Context) : Radix::Result(Route) - route = http.find_route(context.request.method, context.request.path) - route.found? ? route : http.find_route("ALL", context.request.path) - end end end end diff --git a/src/grip/handlers/route.cr b/src/grip/handlers/route.cr index c7af6ad..3445272 100644 --- a/src/grip/handlers/route.cr +++ b/src/grip/handlers/route.cr @@ -1,12 +1,20 @@ module Grip module Handlers struct Route + # Pre-computed empty array to avoid allocations + EMPTY_VIA = [] of Symbol + getter method : String getter path : String getter handler : ::HTTP::Handler getter via : Array(Symbol) getter override : Proc(::HTTP::Server::Context, ::HTTP::Server::Context)? + # Pre-computed flags for fast path decisions + getter? has_override : Bool + getter? has_pipeline : Bool + getter? is_static : Bool + def initialize( @method : String, @path : String, @@ -15,21 +23,30 @@ module Grip @override : Proc(::HTTP::Server::Context, ::HTTP::Server::Context)? = nil, ) @via = normalize_via(via) + @has_override = !@override.nil? + @has_pipeline = !@via.empty? + @is_static = !@path.includes?(':') && !@path.includes?('*') end + @[AlwaysInline] def process_pipeline( context : ::HTTP::Server::Context, pipeline_handler : Grip::Handlers::Pipeline, ) : ::HTTP::Server::Context + # Skip pipeline lookup entirely if no via + return context unless has_pipeline? execute_pipeline(context, pipeline_handler) context end + @[AlwaysInline] def execute_override(context : ::HTTP::Server::Context) : ::HTTP::Server::Context + # Direct call without .try since we check has_override? first @override.try(&.call(context)) context end + @[AlwaysInline] private def normalize_via(via : Symbol | Array(Symbol) | Nil) : Array(Symbol) case via when Symbol @@ -37,15 +54,22 @@ module Grip when Array(Symbol) via else - [] of Symbol + EMPTY_VIA end end + @[AlwaysInline] private def execute_pipeline( context : ::HTTP::Server::Context, pipeline_handler : Grip::Handlers::Pipeline, ) : Nil - pipeline_handler.get(@via).each(&.call(context)) + # Avoid iterator allocation with manual loop + pipes = pipeline_handler.get(@via) + i = 0 + while i < pipes.size + pipes.unsafe_fetch(i).call(context) + i += 1 + end end end end diff --git a/src/grip/handlers/websocket.cr b/src/grip/handlers/websocket.cr index 80e17ed..9b74aac 100644 --- a/src/grip/handlers/websocket.cr +++ b/src/grip/handlers/websocket.cr @@ -1,14 +1,16 @@ module Grip module Handlers class WebSocket < Base - CACHE_LIMIT = 1024 + CACHE_SIZE = 4096 + CACHE_MASK = CACHE_SIZE - 1 getter routes : Radix::Tree(Route) - getter cache : Hash(String, Radix::Result(Route)) + + @cache : Array(Tuple(UInt64, Radix::Result(Route)?)) def initialize @routes = Radix::Tree(Route).new - @cache = Hash(String, Radix::Result(Route)).new + @cache = Array(Tuple(UInt64, Radix::Result(Route)?)).new(CACHE_SIZE) { {0_u64, nil} } end def add_route( @@ -19,27 +21,29 @@ module Grip override : Proc(::HTTP::Server::Context, ::HTTP::Server::Context)? = nil, ) : Nil route = Route.new("", path, handler, via, nil) - add_to_radix_tree(path, route) + @routes.add(radix_path(path), route) end def find_route(verb : String, path : String) : Radix::Result(Route) - lookup_path = "/ws#{path}" - - return @cache[lookup_path] if @cache.has_key?(lookup_path) - - route = @routes.find(lookup_path) + hash = path_hash(path) - if route.found? - @cache.clear if @cache.size >= CACHE_LIMIT - @cache[lookup_path] = route + # Check cache + if cached = cache_lookup(hash) + return cached end - route + # Radix lookup + result = @routes.find(radix_path(path)) + cache_store(hash, result) if result.found? + result end def call(context : ::HTTP::Server::Context) : ::HTTP::Server::Context + # Fast path: check WebSocket upgrade first (cheaper than route lookup) + return call_next(context) || context unless websocket_upgrade_request?(context) + route = find_route("", context.request.path) - return call_next(context) || context unless route.found? && websocket_upgrade_request?(context) + return call_next(context) || context unless route.found? context.parameters ||= Grip::Parsers::ParameterBox.new(context.request, route.params) route.payload.handler.call(context) @@ -47,20 +51,78 @@ module Grip context end + @[AlwaysInline] def websocket_upgrade_request?(context : ::HTTP::Server::Context) : Bool - return false unless upgrade = context.request.headers["Upgrade"]? - return false unless upgrade.compare("websocket", case_insensitive: true) == 0 + headers = context.request.headers + + # Check Upgrade header exists and is "websocket" + upgrade = headers["Upgrade"]? + return false unless upgrade + return false unless upgrade_is_websocket?(upgrade) + + # Check Connection header contains "Upgrade" + headers.includes_word?("Connection", "Upgrade") + end + + @[AlwaysInline] + private def upgrade_is_websocket?(upgrade : String) : Bool + return false unless upgrade.bytesize == 9 # "websocket".size + + # Case-insensitive compare without allocation + upgrade.compare("websocket", case_insensitive: true) == 0 + end + + @[AlwaysInline] + private def cache_lookup(hash : UInt64) : Radix::Result(Route)? + slot = hash & CACHE_MASK + + 4.times do |i| + idx = (slot + i) & CACHE_MASK + entry = @cache.unsafe_fetch(idx) - context.request.headers.includes_word?("Connection", "Upgrade") + return entry[1] if entry[0] == hash && entry[1] + break if entry[0] == 0_u64 && i > 0 + end + + nil + end + + @[AlwaysInline] + private def cache_store(hash : UInt64, result : Radix::Result(Route)) : Nil + slot = hash & CACHE_MASK + + 4.times do |i| + idx = (slot + i) & CACHE_MASK + entry = @cache.unsafe_fetch(idx) + + if entry[0] == 0_u64 || entry[0] == hash + @cache[idx] = {hash, result} + return + end + end + + @cache[slot] = {hash, result} end - private def add_to_radix_tree(path : String, websocket : Route) : Nil - node = build_radix_path(path) - @routes.add(node, websocket) + @[AlwaysInline] + private def path_hash(path : String) : UInt64 + hash = 0xcbf29ce484222325_u64 # FNV offset basis + fnv_prime = 0x100000001b3_u64 + + path.each_byte do |byte| + hash ^= byte.to_u64 + hash &*= fnv_prime + end + + hash end - private def build_radix_path(path : String) : String - "/ws#{path}" + @[AlwaysInline] + private def radix_path(path : String) : String + String.build(3 + path.bytesize) do |io| + io << "/ws" + io << path + end end end end diff --git a/src/grip/parsers/parameter_box.cr b/src/grip/parsers/parameter_box.cr index e2e5b4a..03111e3 100644 --- a/src/grip/parsers/parameter_box.cr +++ b/src/grip/parsers/parameter_box.cr @@ -4,109 +4,226 @@ module Grip URL_ENCODED_FORM = "application/x-www-form-urlencoded" APPLICATION_JSON = "application/json" MULTIPART_FORM = "multipart/form-data" - PARTS = %w(url query body json file) - # :nodoc: + alias AllParamTypes = Nil | String | Int64 | Float64 | Bool | Hash(String, JSON::Any) | Array(JSON::Any) - getter file - - def initialize(@request : HTTP::Request, @url : Hash(String, String) = {} of String => String) - @query = HTTP::Params.new({} of String => Array(String)) - @body = HTTP::Params.new({} of String => Array(String)) - @json = {} of String => AllParamTypes - @file = {} of String => FileUpload - @url_parsed = false - @query_parsed = false - @body_parsed = false - @json_parsed = false - @file_parsed = false - end - - private def unescape_url_param(value : String) - value.empty? ? value : URI.decode(value) - rescue - value - end - - {% for method in PARTS %} - def {{method.id}} - # check memoization - return @{{method.id}} if @{{method.id}}_parsed - - parse_{{method.id}} - # memoize - @{{method.id}}_parsed = true - @{{method.id}} + + # Use class-level empty instances to avoid allocations + EMPTY_STRING_HASH = {} of String => String + EMPTY_PARAMS = HTTP::Params.new({} of String => Array(String)) + EMPTY_JSON = {} of String => AllParamTypes + EMPTY_FILE = {} of String => FileUpload + + @url : Hash(String, String) + @query : HTTP::Params? + @body : HTTP::Params? + @json : Hash(String, AllParamTypes)? + @file : Hash(String, FileUpload)? + @request : HTTP::Request + + # Flags packed into single byte for cache efficiency + @parsed_flags : UInt8 = 0_u8 + + FLAG_URL = 0x01_u8 + FLAG_QUERY = 0x02_u8 + FLAG_BODY = 0x04_u8 + FLAG_JSON = 0x08_u8 + FLAG_FILE = 0x10_u8 + + def initialize(@request : HTTP::Request, @url : Hash(String, String) = EMPTY_STRING_HASH) + end + + @[AlwaysInline] + private def parsed?(flag : UInt8) : Bool + (@parsed_flags & flag) != 0 + end + + @[AlwaysInline] + private def mark_parsed(flag : UInt8) : Nil + @parsed_flags |= flag + end + + # URL params - most frequently accessed, optimize heavily + @[AlwaysInline] + def url : Hash(String, String) + return @url if parsed?(FLAG_URL) + parse_url + mark_parsed(FLAG_URL) + @url + end + + @[AlwaysInline] + def query : HTTP::Params + if parsed?(FLAG_QUERY) + @query || EMPTY_PARAMS + else + parse_query + mark_parsed(FLAG_QUERY) + @query || EMPTY_PARAMS end - {% end %} + end - private def parse_body - content_type = @request.headers["Content-Type"]? + def body : HTTP::Params + if parsed?(FLAG_BODY) + @body || EMPTY_PARAMS + else + parse_body + mark_parsed(FLAG_BODY) + @body || EMPTY_PARAMS + end + end - return unless content_type + def json : Hash(String, AllParamTypes) + if parsed?(FLAG_JSON) + @json || EMPTY_JSON + else + parse_json + mark_parsed(FLAG_JSON) + @json || EMPTY_JSON + end + end - if content_type.try(&.starts_with?(URL_ENCODED_FORM)) - @body = parse_part(@request.body) + def file : Hash(String, FileUpload) + if parsed?(FLAG_FILE) + @file || EMPTY_FILE + else + parse_file + mark_parsed(FLAG_FILE) + @file || EMPTY_FILE + end + end + + @[AlwaysInline] + private def unescape_url_param(value : String) : String + return value if value.empty? + + # Fast path: check if decoding is needed at all + needs_decode = false + value.each_byte do |byte| + if byte === '%' || byte === '+' + needs_decode = true + break + end + end + + return value unless needs_decode + URI.decode(value) rescue value + end + + private def parse_url : Nil + return if @url.empty? + + @url.each_key do |key| + value = @url[key] + decoded = unescape_url_param(value) + @url[key] = decoded if decoded != value + end + end + + private def parse_query : Nil + query_string = @request.query + @query = if query_string && !query_string.empty? + HTTP::Params.parse(query_string) + else + EMPTY_PARAMS + end + end + + private def parse_body : Nil + content_type = @request.headers["Content-Type"]? + + unless content_type + @body = EMPTY_PARAMS return end - if content_type.try(&.starts_with?(MULTIPART_FORM)) + # Avoid starts_with? which creates substrings - use byte comparison + if content_type_matches?(content_type, URL_ENCODED_FORM) + @body = parse_part(@request.body) + elsif content_type_matches?(content_type, MULTIPART_FORM) + @body = EMPTY_PARAMS parse_file + else + @body = EMPTY_PARAMS end end - private def parse_query - @query = parse_part(@request.query) - end + @[AlwaysInline] + private def content_type_matches?(content_type : String, expected : String) : Bool + return false if content_type.bytesize < expected.bytesize - private def parse_url - @url.each { |key, value| @url[key] = unescape_url_param(value) } + expected.bytesize.times do |i| + return false if content_type.byte_at(i) != expected.byte_at(i) + end + true end - private def parse_file - return if @file_parsed + private def parse_file : Nil + return if parsed?(FLAG_FILE) + + file_hash = {} of String => FileUpload HTTP::FormData.parse(@request) do |upload| next unless upload - filename = upload.filename - - if !filename.nil? - @file[upload.name] = FileUpload.new(upload) + if upload.filename + file_hash[upload.name] = FileUpload.new(upload) else - @body.add(upload.name, upload.body.gets_to_end) + @body ||= HTTP::Params.new({} of String => Array(String)) + if body = @body + body.add(upload.name, upload.body.gets_to_end) + end end end - @file_parsed = true + @file = file_hash + mark_parsed(FLAG_FILE) end - # Parses JSON request body if Content-Type is `application/json`. - # - # - If request body is a JSON `Hash` then all the params are parsed and added into `params`. - # - If request body is a JSON `Array` it's added into `params` as `_json` and can be accessed like `params["_json"]`. - private def parse_json - return unless @request.body && @request.headers["Content-Type"]?.try(&.starts_with?(APPLICATION_JSON)) + private def parse_json : Nil + body_io = @request.body + content_type = @request.headers["Content-Type"]? + + unless body_io && content_type && content_type_matches?(content_type, APPLICATION_JSON) + @json = EMPTY_JSON + return + end + + body_str = body_io.gets_to_end - body = @request.body.try(&.gets_to_end) || String.new + if body_str.empty? + @json = EMPTY_JSON + return + end - case json = JSON.parse(body).raw + json_hash = {} of String => AllParamTypes + + case json = JSON.parse(body_str).raw when Hash json.each do |key, value| - @json[key] = value.raw + json_hash[key] = value.raw end when Array - @json["_json"] = json - else - # Ignore non Array or Hash json values + json_hash["_json"] = json end + + @json = json_hash + rescue JSON::ParseException + @json = EMPTY_JSON end - private def parse_part(part : IO?) - HTTP::Params.parse(part ? part.gets_to_end : "") + @[AlwaysInline] + private def parse_part(part : IO?) : HTTP::Params + if part + content = part.gets_to_end + content.empty? ? EMPTY_PARAMS : HTTP::Params.parse(content) + else + EMPTY_PARAMS + end end - private def parse_part(part : String?) - HTTP::Params.parse part.to_s + @[AlwaysInline] + private def parse_part(part : String?) : HTTP::Params + part && !part.empty? ? HTTP::Params.parse(part) : EMPTY_PARAMS end end end