http.odin (7230B)
1 /* 2 Package http is a small client over vendor:curl. The foreign import block in 3 vendor/curl/curl.odin links system libcurl on Linux and macOS and the bundled 4 lib/libcurl.lib on Windows, so TLS, proxies and redirects come from whatever 5 curl build is linked rather than from here. 6 7 res := must(http.get("https://api.example.com/items")) 8 if !res.ok { die("HTTP %d: %s", res.status, res.body) } 9 10 items: []Item 11 must(http.get_json("https://api.example.com/items", &items)) 12 13 res = must(http.post_json(url, Payload{name = "x"})) 14 must(http.download("https://example.com/big.tar.gz", "build/big.tar.gz")) 15 16 Every call returns (Response, Error). Error is set only when the transfer 17 could not complete; an HTTP 4xx or 5xx is a Response with ok == false, so 18 the caller decides whether a status is fatal. 19 */ 20 package http 21 22 import "base:runtime" 23 import "core:c" 24 import "core:encoding/json" 25 import "core:os" 26 import "core:strings" 27 import "core:sync" 28 import "core:time" 29 30 import curl "vendor:curl" 31 32 Response :: struct { 33 status: int, 34 // 2xx. 35 ok: bool, 36 body: string, 37 // Raw response headers, one per line, as the server sent them. 38 headers: string, 39 } 40 41 Error :: enum { 42 None, 43 Init_Failed, 44 Transfer_Failed, 45 Write_Failed, 46 Encode_Failed, 47 Decode_Failed, 48 } 49 50 Opts :: struct { 51 // Extra request headers as "Name: value". 52 headers: []string, 53 // Whole-request timeout. 0 leaves CURLOPT_TIMEOUT_MS unset, whose libcurl 54 // default is 0, no limit. 55 timeout: time.Duration, 56 // Do not follow redirects. They are followed by default. 57 no_follow: bool, 58 // Skip TLS certificate verification. For local development only. 59 insecure: bool, 60 user_agent: string, 61 // Request body; content_type names it. Used by request. 62 body: string, 63 // Called with libcurl's error text when the transfer fails. 64 on_error: proc(msg: string), 65 } 66 67 DEFAULT_USER_AGENT :: "jm-odin-http/1" 68 69 // get performs a GET. 70 get :: proc(url: string, opts := Opts{}, allocator := context.allocator) -> (Response, Error) { 71 return request("GET", url, opts, allocator) 72 } 73 74 // post sends body with the given content type. 75 post :: proc(url, body: string, content_type := "application/octet-stream", opts := Opts{}, allocator := context.allocator) -> (Response, Error) { 76 o := opts 77 o.body = body 78 o.headers = with_header(opts.headers, "Content-Type", content_type) 79 return request("POST", url, o, allocator) 80 } 81 82 // post_json marshals v and posts it as application/json. 83 post_json :: proc(url: string, v: any, opts := Opts{}, allocator := context.allocator) -> (Response, Error) { 84 data, err := json.marshal(v, {}, context.temp_allocator) 85 if err != nil { 86 return {}, .Encode_Failed 87 } 88 return post(url, string(data), "application/json", opts, allocator) 89 } 90 91 // get_json fetches url and unmarshals the body into out. A non-2xx status is 92 // returned as the Response with Error.None, and out is left untouched. 93 get_json :: proc(url: string, out: ^$T, opts := Opts{}, allocator := context.allocator) -> (Response, Error) { 94 o := opts 95 o.headers = with_header(opts.headers, "Accept", "application/json") 96 res, err := request("GET", url, o, allocator) 97 if err != .None || !res.ok { 98 return res, err 99 } 100 if jerr := json.unmarshal_string(res.body, out, json.DEFAULT_SPECIFICATION, allocator); jerr != nil { 101 return res, .Decode_Failed 102 } 103 return res, .None 104 } 105 106 // download streams url into the file at dest, replacing it. A non-2xx 107 // status still writes whatever the server sent, so check res.ok. 108 download :: proc(url, dest: string, opts := Opts{}, allocator := context.allocator) -> (Response, Error) { 109 f, ferr := os.create(dest) 110 if ferr != nil { 111 return {}, .Write_Failed 112 } 113 defer os.close(f) 114 return perform("GET", url, opts, f, allocator) 115 } 116 117 // request performs an arbitrary method with the body from opts. 118 request :: proc(method, url: string, opts := Opts{}, allocator := context.allocator) -> (Response, Error) { 119 return perform(method, url, opts, nil, allocator) 120 } 121 122 // ---- internals ---------------------------------------------------------- 123 124 Sink :: struct { 125 ctx: runtime.Context, 126 buf: [dynamic]byte, 127 file: ^os.File, 128 failed: bool, 129 } 130 131 global_once: sync.Once 132 133 perform :: proc(method, url: string, opts: Opts, file: ^os.File, allocator: runtime.Allocator) -> (res: Response, err: Error) { 134 sync.once_do(&global_once, proc() { 135 curl.global_init(curl.GLOBAL_DEFAULT) 136 }) 137 h := curl.easy_init() 138 if h == nil { 139 return {}, .Init_Failed 140 } 141 defer curl.easy_cleanup(h) 142 143 body_sink := Sink{ctx = context, file = file} 144 body_sink.buf.allocator = allocator 145 header_sink := Sink{ctx = context} 146 header_sink.buf.allocator = allocator 147 148 curl.easy_setopt(h, .URL, cstr(url)) 149 curl.easy_setopt(h, .NOSIGNAL, c.long(1)) 150 curl.easy_setopt(h, .WRITEFUNCTION, curl.write_callback(write_cb)) 151 curl.easy_setopt(h, .WRITEDATA, &body_sink) 152 curl.easy_setopt(h, .HEADERFUNCTION, curl.write_callback(write_cb)) 153 curl.easy_setopt(h, .HEADERDATA, &header_sink) 154 curl.easy_setopt(h, .USERAGENT, cstr(opts.user_agent if opts.user_agent != "" else DEFAULT_USER_AGENT)) 155 if !opts.no_follow { 156 curl.easy_setopt(h, .FOLLOWLOCATION, c.long(1)) 157 } 158 if opts.timeout > 0 { 159 curl.easy_setopt(h, .TIMEOUT_MS, c.long(opts.timeout / time.Millisecond)) 160 } 161 if opts.insecure { 162 curl.easy_setopt(h, .SSL_VERIFYPEER, c.long(0)) 163 curl.easy_setopt(h, .SSL_VERIFYHOST, c.long(0)) 164 } 165 switch method { 166 case "GET": 167 case "POST": 168 curl.easy_setopt(h, .POST, c.long(1)) 169 case "HEAD": 170 curl.easy_setopt(h, .NOBODY, c.long(1)) 171 case: 172 curl.easy_setopt(h, .CUSTOMREQUEST, cstr(method)) 173 } 174 if opts.body != "" || method == "POST" { 175 curl.easy_setopt(h, .POSTFIELDS, raw_data(opts.body)) 176 curl.easy_setopt(h, .POSTFIELDSIZE_LARGE, curl.off_t(len(opts.body))) 177 } 178 179 headers: ^curl.slist 180 defer if headers != nil { 181 curl.slist_free_all(headers) 182 } 183 for hdr in opts.headers { 184 headers = curl.slist_append(headers, cstr(hdr)) 185 } 186 if headers != nil { 187 curl.easy_setopt(h, .HTTPHEADER, headers) 188 } 189 190 code := curl.easy_perform(h) 191 if code != .E_OK { 192 if opts.on_error != nil { 193 opts.on_error(string(curl.easy_strerror(code))) 194 } 195 return {}, .Transfer_Failed 196 } 197 if body_sink.failed { 198 return {}, .Write_Failed 199 } 200 201 status: c.long 202 curl.easy_getinfo(h, .RESPONSE_CODE, &status) 203 res.status = int(status) 204 res.ok = status >= 200 && status < 300 205 res.body = string(body_sink.buf[:]) 206 res.headers = string(header_sink.buf[:]) 207 return res, .None 208 } 209 210 write_cb :: proc "c" (buffer: [^]byte, size, nitems: c.size_t, userdata: rawptr) -> c.size_t { 211 sink := (^Sink)(userdata) 212 context = sink.ctx 213 n := int(size * nitems) 214 if sink.file != nil { 215 written, err := os.write(sink.file, buffer[:n]) 216 if err != nil || written != n { 217 sink.failed = true 218 return curl.WRITEFUNC_ERROR 219 } 220 return c.size_t(n) 221 } 222 if _, err := append(&sink.buf, ..buffer[:n]); err != nil { 223 sink.failed = true 224 return curl.WRITEFUNC_ERROR 225 } 226 return c.size_t(n) 227 } 228 229 cstr :: proc(s: string) -> cstring { 230 return strings.clone_to_cstring(s, context.temp_allocator) 231 } 232 233 with_header :: proc(headers: []string, name, value: string) -> []string { 234 out := make([]string, len(headers) + 1, context.temp_allocator) 235 copy(out, headers) 236 out[len(headers)] = strings.concatenate({name, ": ", value}, context.temp_allocator) 237 return out 238 }