A batteries included HTTP/1.1 client in OCaml
1(*---------------------------------------------------------------------------
2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3 SPDX-License-Identifier: ISC
4 ---------------------------------------------------------------------------*)
5
6(** HTTP response parsing using Eio.Buf_read combinators
7
8 This module provides efficient HTTP/1.1 response parsing using Eio's
9 buffered read API with parser combinators for clean, composable parsing.
10
11 Example:
12 {[
13 let buf_read = Http_read.of_flow ~max_size:max_int flow in
14 let (status, headers, body) = Http_read.response ~limits buf_read
15 ]} *)
16
17(** {1 Response Limits}
18
19 This module uses {!Response_limits.t} for size limit configuration. *)
20
21type limits = Response_limits.t
22(** Alias for {!Response_limits.t}. See {!Response_limits} for documentation. *)
23
24(** {1 HTTP Version Type}
25
26 Per Recommendation #26: Expose HTTP version used for the response. *)
27
28type http_version =
29 | HTTP_1_0 (** HTTP/1.0 *)
30 | HTTP_1_1 (** HTTP/1.1 *)
31(** HTTP protocol version. Useful for debugging protocol negotiation
32 and monitoring HTTP/2 adoption (when supported). *)
33
34val http_version_to_string : http_version -> string
35(** [http_version_to_string v] returns "HTTP/1.0" or "HTTP/1.1". *)
36
37(** {1 Low-level Parsers} *)
38
39val http_version : Eio.Buf_read.t -> string
40(** [http_version r] parses HTTP version string (e.g., "HTTP/1.1"). *)
41
42val status_code : Eio.Buf_read.t -> int
43(** [status_code r] parses a 3-digit HTTP status code.
44 @raise Error if the status code is invalid. *)
45
46val status_line : Eio.Buf_read.t -> http_version * int
47(** [status_line r] parses a complete HTTP status line and returns
48 the HTTP version and status code as a tuple.
49 Validates that the HTTP version is 1.0 or 1.1.
50 @raise Error if the status line is invalid. *)
51
52(** {1 Header Parsing} *)
53
54val header_line : Eio.Buf_read.t -> string * string
55(** [header_line r] parses a single header line.
56 Returns [(name, value)] where name is lowercase.
57 Returns [("", "")] for the empty line that terminates headers. *)
58
59val headers : limits:limits -> Eio.Buf_read.t -> Headers.t
60(** [headers ~limits r] parses all headers until the terminating blank line.
61 Enforces header count and size limits.
62 @raise Error.Headers_too_large if limits are exceeded. *)
63
64(** {1 Body Parsing} *)
65
66val fixed_body : limits:limits -> length:int64 -> Eio.Buf_read.t -> string
67(** [fixed_body ~limits ~length r] reads exactly [length] bytes as the body.
68 @raise Error.Body_too_large if length exceeds limit.
69 @raise Error.Content_length_mismatch if EOF occurs before all bytes read. *)
70
71val chunked_body : limits:limits -> Eio.Buf_read.t -> string
72(** [chunked_body ~limits r] reads a chunked transfer-encoded body.
73 Handles chunk sizes, extensions, and trailers.
74 @raise Error.Body_too_large if total body size exceeds limit. *)
75
76(** {1 Transfer-Encoding Validation} *)
77
78val parse_transfer_encoding : string option -> string list
79(** [parse_transfer_encoding header] parses Transfer-Encoding header value
80 into a list of codings (all lowercase, in order). *)
81
82val validate_transfer_encoding : string list ->
83 [ `Chunked | `None | `Unsupported of string list ]
84(** [validate_transfer_encoding codings] validates Transfer-Encoding per RFC 9112 Section 6.1.
85 Returns [`Chunked] if chunked encoding should be used, [`None] if no body,
86 or [`Unsupported codings] for unsupported encodings without chunked.
87 @raise Error if chunked is not final encoding (RFC violation). *)
88
89val validate_no_transfer_encoding :
90 method_:Method.t option -> status:int -> string option -> bool
91(** [validate_no_transfer_encoding ~method_ ~status te] validates that
92 Transfer-Encoding is not present in responses that MUST NOT have it.
93 Per RFC 9112 Section 6.1, these include responses to HEAD, 1xx, 204, and 304.
94 If present, this logs a warning about the RFC violation.
95 @return true if Transfer-Encoding is present (violation), false otherwise *)
96
97(** {1 Trailer Header Parsing} *)
98
99val forbidden_trailer_headers : string list
100(** Headers that MUST NOT appear in trailers per RFC 9110 Section 6.5.1.
101 Includes: transfer-encoding, content-length, host, content-encoding,
102 content-type, content-range, trailer. *)
103
104val parse_trailers : limits:limits -> Eio.Buf_read.t -> Headers.t
105(** [parse_trailers ~limits r] parses trailer headers after final chunk.
106 Forbidden headers are logged and ignored. *)
107
108(** {1 Streaming Body Sources} *)
109
110val fixed_body_stream : limits:limits -> length:int64 ->
111 Eio.Buf_read.t -> Eio.Flow.source_ty Eio.Resource.t
112(** [fixed_body_stream ~limits ~length r] creates a flow source that reads
113 [length] bytes from [r]. Useful for large bodies to avoid loading
114 everything into memory at once. *)
115
116val chunked_body_stream : limits:limits ->
117 Eio.Buf_read.t -> Eio.Flow.source_ty Eio.Resource.t
118(** [chunked_body_stream ~limits r] creates a flow source that reads
119 chunked transfer-encoded data from [r]. Decodes chunks on-the-fly. *)
120
121(** {1 High-level Response Parsing} *)
122
123val response : limits:limits -> ?method_:Method.t -> Eio.Buf_read.t -> http_version * int * Headers.t * string
124(** [response ~limits ?method_ r] parses a complete HTTP response including:
125 - HTTP version
126 - Status code
127 - Headers
128 - Body (based on Transfer-Encoding or Content-Length)
129
130 Returns [(http_version, status, headers, body)].
131
132 @param method_ The HTTP method of the request. Per
133 {{:https://datatracker.ietf.org/doc/html/rfc9110#section-6.4.1}RFC 9110 Section 6.4.1},
134 certain responses have no body:
135 {ul
136 {- [`HEAD] - body is always empty regardless of Content-Length}
137 {- [`CONNECT] with 2xx - switches to tunnel mode, no body}
138 {- 1xx, 204, 304 - no content responses}}
139
140 This reads the entire body into memory. For large responses,
141 use {!response_stream} instead. *)
142
143(** {1 Streaming Response} *)
144
145type stream_response = {
146 http_version : http_version; (** HTTP protocol version *)
147 status : int;
148 headers : Headers.t;
149 body : [ `String of string
150 | `Stream of Eio.Flow.source_ty Eio.Resource.t
151 | `None ]
152}
153(** A parsed response with optional streaming body.
154 Per Recommendation #26: Includes HTTP version for debugging/monitoring. *)
155
156val response_stream : limits:limits -> ?method_:Method.t -> Eio.Buf_read.t -> stream_response
157(** [response_stream ~limits ?method_ r] parses status line and headers, then
158 returns a streaming body source instead of reading the body into memory.
159 Use this for large responses.
160
161 @param method_ The HTTP method of the request. Used to validate
162 that Transfer-Encoding is not present in responses that shouldn't have it
163 (HEAD requests). *)
164
165(** {1 Convenience Functions} *)
166
167val of_flow : ?initial_size:int -> max_size:int -> _ Eio.Flow.source -> Eio.Buf_read.t
168(** [of_flow ~max_size flow] creates a buffered reader from [flow].
169 This is a thin wrapper around {!Eio.Buf_read.of_flow}. *)