jm

Odin for scripts: small packages and a runner, on core: only
Log | Files | Refs | README

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 }