lib/async/http/cache/general.rb



# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2020-2024, by Samuel Williams.
# Copyright, 2022, by Colin Kelley.

require "set"
require "protocol/http/middleware"

require_relative "body"
require_relative "response"
require_relative "store"

module Async
	module HTTP
		module Cache
			# Implements a general shared cache according to https://www.rfc-editor.org/rfc/rfc9111
			class General < ::Protocol::HTTP::Middleware
				CACHE_CONTROL  = "cache-control"
				
				CONTENT_TYPE = "content-type"
				AUTHORIZATION = "authorization"
				COOKIE = "cookie"
				SET_COOKIE = "set-cookie"
				
				# Status codes of responses that MAY be stored by a cache or used in reply
				# to a subsequent request.
				#
				# http://tools.ietf.org/html/rfc2616#section-13.4
				CACHEABLE_RESPONSE_CODES = {
					200 => true, # OK
					203 => true, # Non-Authoritative Information
					300 => true, # Multiple Choices
					301 => true, # Moved Permanently
					302 => true, # Found
					404 => true, # Not Found
					410 => true  # Gone
				}.freeze
				
				def initialize(app, store: Store.default)
					super(app)
					
					@count = 0
					
					@store = store
				end
				
				attr :count
				attr :store
				
				def close
					@store.close
				ensure
					super
				end
				
				def key(request)
					@store.normalize(request)
					
					[request.authority, request.method, request.path]
				end
				
				def cacheable_request?(request)
					# We don't support caching requests which have a request body:
					if request.body
						return false
					end
					
					# We can't cache upgraded requests:
					if request.protocol
						return false
					end
					
					# We only support caching GET and HEAD requests:
					unless request.method == "GET" || request.method == "HEAD"
						return false
					end
					
					if request.headers[AUTHORIZATION]
						return false
					end
					
					if request.headers[COOKIE]
						return false
					end
					
					# Otherwise, we can cache it:
					return true
				end
				
				def cacheable_response_headers?(headers)
					if cache_control = headers[CACHE_CONTROL]
						if cache_control.no_store? || cache_control.private?
							Console.logger.debug(self, cache_control: cache_control) {"Cannot cache response with cache-control header!"}
							return false
						end
					end
					
					if set_cookie = headers[SET_COOKIE]
						Console.logger.debug(self) {"Cannot cache response with set-cookie header!"}
						return false
					end
								
					return true
				end
				
				def cacheable_response?(response)
					# At this point, we know response.status and response.headers.
					# But we don't know response.body or response.headers.trailer.
					unless CACHEABLE_RESPONSE_CODES.include?(response.status)
						Console.logger.debug(self, status: response.status) {"Cannot cache response with status code!"}
						return false
					end
					
					unless cacheable_response_headers?(response.headers)
						Console.logger.debug(self) {"Cannot cache response with uncacheable headers!"}
						return false
					end
					
					return true
				end
				
				# Semantically speaking, it is possible for trailers to result in an uncacheable response, so we need to check for that.
				def proceed_with_response_cache?(response)
					if response.headers.trailer?
						unless cacheable_response_headers?(response.headers)
							Console.logger.debug(self, trailer: trailer.keys) {"Cannot cache response with trailer header!"}
							return false
						end
					end
					
					return true
				end
				
				# Potentially wrap the response so that it updates the cache, if caching is possible.
				def wrap(key, request, response)
					if request.head? and body = response.body
						unless body.empty?
							Console.logger.warn(self) {"HEAD request resulted in non-empty body!"}
							
							return response
						end
					end
					
					unless cacheable_request?(request)
						Console.logger.debug(self) {"Cannot cache request!"}
						return response
					end
					
					unless cacheable_response?(response)
						Console.logger.debug(self) {"Cannot cache response!"}
						return response
					end
					
					return Body.wrap(response) do |response, body|
						if proceed_with_response_cache?(response)
							key ||= self.key(request)
							
							Console.logger.debug(self, key: key) {"Updating miss!"}
							@store.insert(key, request, Response.new(response, body))
						end
					end
				end
				
				def call(request)
					cache_control = request.headers[CACHE_CONTROL]
					
					unless cache_control&.no_cache?
						key = self.key(request)
						
						if response = @store.lookup(key, request)
							Console.logger.debug(self, key: key) {"Cache hit!"}
							@count += 1
							
							# Return the cached response:
							return response
						end
					end
					
					unless cache_control&.no_store?
						return wrap(key, request, super)
					end
					
					return super
				end
			end
		end
	end
end