summaryrefslogtreecommitdiffstats
path: root/src/cepis_http.f90
blob: 2c178a13edfdfd06fc39f94bafe4c043419c4cf6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
!> The transport, bound to libcurl through `iso_c_binding`.
!>
!> Fortran has neither an HTTP client nor sockets, so the only honest answer is
!> a foreign-function binding. The interface bodies below are written by hand
!> against the documented libcurl ABI; nothing is vendored.
!>
!> `curl_easy_setopt` and `curl_easy_getinfo` are variadic in C. They are bound
!> here with a single pointer-sized third argument and integers are passed
!> through `transfer`, which is what the SysV and AAPCS64 calling conventions
!> ask for. This is the reason the library is built and tested on the platform
!> it runs on rather than assumed to be portable to every ABI.
!>
!> One attempt per call: retrying, backoff and `Retry-After` policy live in the
!> client, because they are decisions about the API and not about the socket.
module cepis_http
    use, intrinsic :: iso_c_binding
    use, intrinsic :: iso_fortran_env, only: real64, int64
    use cepis_strings, only: string_buffer, to_c_chars, from_c_string, strip, &
                             lower, starts_with, is_digits, parse_real
    implicit none
    private

    public :: http_response
    public :: http_get
    public :: http_sleep
    public :: http_monotonic
    public :: http_parse_retry_after

    integer(c_int), parameter :: CURLOPT_WRITEDATA = 10001
    integer(c_int), parameter :: CURLOPT_URL = 10002
    integer(c_int), parameter :: CURLOPT_ERRORBUFFER = 10010
    integer(c_int), parameter :: CURLOPT_WRITEFUNCTION = 20011
    integer(c_int), parameter :: CURLOPT_LOW_SPEED_LIMIT = 19
    integer(c_int), parameter :: CURLOPT_LOW_SPEED_TIME = 20
    integer(c_int), parameter :: CURLOPT_HTTPHEADER = 10023
    integer(c_int), parameter :: CURLOPT_USERAGENT = 10018
    integer(c_int), parameter :: CURLOPT_FOLLOWLOCATION = 52
    integer(c_int), parameter :: CURLOPT_MAXREDIRS = 68
    integer(c_int), parameter :: CURLOPT_HTTPGET = 80
    integer(c_int), parameter :: CURLOPT_NOSIGNAL = 99
    integer(c_int), parameter :: CURLOPT_ACCEPT_ENCODING = 10102
    integer(c_int), parameter :: CURLOPT_TIMEOUT_MS = 155
    integer(c_int), parameter :: CURLOPT_CONNECTTIMEOUT_MS = 156
    integer(c_int), parameter :: CURLOPT_HEADERFUNCTION = 20079
    integer(c_int), parameter :: CURLOPT_HEADERDATA = 10029

    integer(c_int), parameter :: CURLINFO_RESPONSE_CODE = 2097154
    integer(c_long), parameter :: CURL_GLOBAL_ALL = 3_c_long
    integer, parameter :: CURL_ERROR_SIZE = 256

    !> What one HTTP attempt produced.
    type :: http_response
        !> The HTTP status, or zero when the request never completed.
        integer :: status = 0
        !> The body, always allocated.
        character(len=:), allocatable :: body
        !> Seconds the server asked the caller to wait, when it sent
        !> `Retry-After`. Negative when it did not.
        real(real64) :: retry_after = -1.0_real64
    end type http_response

    !> Where the header callback accumulates what it understood.
    type :: header_state
        real(real64) :: retry_after = -1.0_real64
    end type header_state

    logical, save :: global_ready = .false.

    type, bind(c) :: c_timespec
        integer(c_long) :: tv_sec
        integer(c_long) :: tv_nsec
    end type c_timespec

    interface
        function curl_global_init(flags) bind(c, name='curl_global_init') result(rc)
            import :: c_int, c_long
            integer(c_long), value :: flags
            integer(c_int) :: rc
        end function curl_global_init

        function curl_easy_init() bind(c, name='curl_easy_init') result(handle)
            import :: c_ptr
            type(c_ptr) :: handle
        end function curl_easy_init

        subroutine curl_easy_cleanup(handle) bind(c, name='curl_easy_cleanup')
            import :: c_ptr
            type(c_ptr), value :: handle
        end subroutine curl_easy_cleanup

        function curl_easy_setopt(handle, option, argument) &
            bind(c, name='curl_easy_setopt') result(rc)
            import :: c_ptr, c_int
            type(c_ptr), value :: handle
            integer(c_int), value :: option
            type(c_ptr), value :: argument
            integer(c_int) :: rc
        end function curl_easy_setopt

        function curl_easy_getinfo(handle, info, argument) &
            bind(c, name='curl_easy_getinfo') result(rc)
            import :: c_ptr, c_int
            type(c_ptr), value :: handle
            integer(c_int), value :: info
            type(c_ptr), value :: argument
            integer(c_int) :: rc
        end function curl_easy_getinfo

        function curl_easy_perform(handle) bind(c, name='curl_easy_perform') result(rc)
            import :: c_ptr, c_int
            type(c_ptr), value :: handle
            integer(c_int) :: rc
        end function curl_easy_perform

        function curl_easy_strerror(rc) bind(c, name='curl_easy_strerror') result(text)
            import :: c_ptr, c_int
            integer(c_int), value :: rc
            type(c_ptr) :: text
        end function curl_easy_strerror

        function curl_slist_append(list, text) bind(c, name='curl_slist_append') result(out)
            import :: c_ptr, c_char
            type(c_ptr), value :: list
            character(kind=c_char), intent(in) :: text(*)
            type(c_ptr) :: out
        end function curl_slist_append

        subroutine curl_slist_free_all(list) bind(c, name='curl_slist_free_all')
            import :: c_ptr
            type(c_ptr), value :: list
        end subroutine curl_slist_free_all

        function c_time(destination) bind(c, name='time') result(seconds)
            import :: c_ptr, c_long
            type(c_ptr), value :: destination
            integer(c_long) :: seconds
        end function c_time

        function c_nanosleep(request, remaining) bind(c, name='nanosleep') result(rc)
            import :: c_ptr, c_int, c_timespec
            type(c_timespec), intent(in) :: request
            type(c_ptr), value :: remaining
            integer(c_int) :: rc
        end function c_nanosleep
    end interface

contains

    !> Perform one `GET`.
    !>
    !> `ok` is `.false.` only when the request never completed — a DNS failure,
    !> a refused connection, a timeout, a TLS error. Every HTTP status,
    !> including 5xx, is a completed request and comes back in
    !> `response%status`.
    !>
    !> `timeout` bounds each socket operation: it caps the connect phase and
    !> aborts a transfer that stalls for that long. It is not a cap on the whole
    !> call.
    subroutine http_get(url, user_agent, accept, timeout, response, ok, message)
        character(len=*), intent(in) :: url
        character(len=*), intent(in) :: user_agent
        character(len=*), intent(in) :: accept
        real(real64), intent(in) :: timeout
        type(http_response), intent(out) :: response
        logical, intent(out) :: ok
        character(len=:), allocatable, intent(out) :: message

        type(c_ptr) :: handle, headers
        type(string_buffer), target :: body
        type(header_state), target :: header
        character(kind=c_char), allocatable, target :: c_url(:), c_agent(:), &
                                                       c_accept(:), c_identity(:)
        character(kind=c_char), target :: error_buffer(CURL_ERROR_SIZE)
        integer(c_long), target :: status_code
        integer(c_int) :: rc
        integer(c_long) :: milliseconds, stall_seconds

        response%body = ''
        ok = .false.
        message = ''

        if (.not. global_ready) then
            rc = curl_global_init(CURL_GLOBAL_ALL)
            if (rc /= 0) then
                message = 'could not initialise libcurl'
                return
            end if
            global_ready = .true.
        end if

        handle = curl_easy_init()
        if (.not. c_associated(handle)) then
            message = 'could not create a libcurl handle'
            return
        end if

        error_buffer = c_null_char

        c_url = to_c_chars(url)
        c_agent = to_c_chars(user_agent)
        c_accept = to_c_chars('Accept: '//accept)
        c_identity = to_c_chars('identity')

        headers = curl_slist_append(c_null_ptr, c_accept)

        milliseconds = max(1_c_long, int(timeout*1000.0_real64, c_long))
        stall_seconds = max(1_c_long, int(ceiling(timeout), c_long))

        rc = set_pointer(handle, CURLOPT_URL, c_loc(c_url(1)))
        rc = set_long(handle, CURLOPT_HTTPGET, 1_c_long)
        rc = set_long(handle, CURLOPT_NOSIGNAL, 1_c_long)
        rc = set_long(handle, CURLOPT_FOLLOWLOCATION, 1_c_long)
        rc = set_long(handle, CURLOPT_MAXREDIRS, 5_c_long)
        rc = set_pointer(handle, CURLOPT_USERAGENT, c_loc(c_agent(1)))
        rc = set_pointer(handle, CURLOPT_ACCEPT_ENCODING, c_loc(c_identity(1)))
        rc = set_pointer(handle, CURLOPT_HTTPHEADER, headers)
        rc = set_long(handle, CURLOPT_CONNECTTIMEOUT_MS, milliseconds)
        rc = set_long(handle, CURLOPT_LOW_SPEED_LIMIT, 1_c_long)
        rc = set_long(handle, CURLOPT_LOW_SPEED_TIME, stall_seconds)
        rc = set_function(handle, CURLOPT_WRITEFUNCTION, c_funloc(write_body))
        rc = set_pointer(handle, CURLOPT_WRITEDATA, c_loc(body))
        rc = set_function(handle, CURLOPT_HEADERFUNCTION, c_funloc(read_header))
        rc = set_pointer(handle, CURLOPT_HEADERDATA, c_loc(header))
        rc = set_pointer(handle, CURLOPT_ERRORBUFFER, c_loc(error_buffer(1)))

        rc = curl_easy_perform(handle)

        if (rc /= 0) then
            message = detail(error_buffer, rc)
            call curl_slist_free_all(headers)
            call curl_easy_cleanup(handle)
            return
        end if

        status_code = 0_c_long
        rc = curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, c_loc(status_code))

        response%status = int(status_code)
        response%body = body%text()
        response%retry_after = header%retry_after
        ok = .true.

        call curl_slist_free_all(headers)
        call curl_easy_cleanup(handle)
    end subroutine http_get

    function detail(error_buffer, rc) result(text)
        character(kind=c_char), intent(in) :: error_buffer(CURL_ERROR_SIZE)
        integer(c_int), intent(in) :: rc
        character(len=:), allocatable :: text
        integer :: i

        text = ''
        do i = 1, CURL_ERROR_SIZE
            if (error_buffer(i) == c_null_char) exit
            text = text//error_buffer(i)
        end do
        if (len(text) == 0) text = from_c_string(curl_easy_strerror(rc))
        if (len(text) == 0) text = 'libcurl error'
    end function detail

    function set_long(handle, option, value) result(rc)
        type(c_ptr), intent(in) :: handle
        integer(c_int), intent(in) :: option
        integer(c_long), intent(in) :: value
        integer(c_int) :: rc

        rc = curl_easy_setopt(handle, option, transfer(int(value, c_intptr_t), c_null_ptr))
    end function set_long

    function set_pointer(handle, option, value) result(rc)
        type(c_ptr), intent(in) :: handle
        integer(c_int), intent(in) :: option
        type(c_ptr), intent(in) :: value
        integer(c_int) :: rc

        rc = curl_easy_setopt(handle, option, value)
    end function set_pointer

    function set_function(handle, option, value) result(rc)
        type(c_ptr), intent(in) :: handle
        integer(c_int), intent(in) :: option
        type(c_funptr), intent(in) :: value
        integer(c_int) :: rc

        rc = curl_easy_setopt(handle, option, transfer(value, c_null_ptr))
    end function set_function

    function write_body(chunk, element, count, userdata) &
        bind(c, name='cepis_write_body') result(taken)
        type(c_ptr), value :: chunk
        integer(c_size_t), value :: element
        integer(c_size_t), value :: count
        type(c_ptr), value :: userdata
        integer(c_size_t) :: taken
        type(string_buffer), pointer :: buffer

        taken = element*count
        if (taken <= 0_c_size_t) return
        if (.not. c_associated(chunk) .or. .not. c_associated(userdata)) return
        call c_f_pointer(userdata, buffer)
        call buffer%append(bytes_at(chunk, int(taken)))
    end function write_body

    function read_header(chunk, element, count, userdata) &
        bind(c, name='cepis_read_header') result(taken)
        type(c_ptr), value :: chunk
        integer(c_size_t), value :: element
        integer(c_size_t), value :: count
        type(c_ptr), value :: userdata
        integer(c_size_t) :: taken
        type(header_state), pointer :: state
        character(len=:), allocatable :: line
        real(real64) :: seconds
        logical :: understood

        taken = element*count
        if (taken <= 0_c_size_t) return
        if (.not. c_associated(chunk) .or. .not. c_associated(userdata)) return

        line = bytes_at(chunk, int(taken))
        if (.not. starts_with(lower(line), 'retry-after:')) return

        call http_parse_retry_after(line(13:), seconds, understood)
        if (.not. understood) return

        call c_f_pointer(userdata, state)
        state%retry_after = seconds
    end function read_header

    function bytes_at(address, length) result(text)
        type(c_ptr), intent(in) :: address
        integer, intent(in) :: length
        character(len=:), allocatable :: text
        character(kind=c_char), pointer :: raw(:)
        integer :: i

        allocate (character(len=length) :: text)
        call c_f_pointer(address, raw, [length])
        do i = 1, length
            text(i:i) = raw(i)
        end do
    end function bytes_at

    !> Read a `Retry-After` value in either RFC 9110 form.
    !>
    !> It arrives as a number of seconds (`120`) or as an HTTP date
    !> (`Wed, 21 Oct 2026 07:28:00 GMT`). Handling only the first is the common
    !> bug, and it makes a client ignore exactly the wait request that matters
    !> most. A date already in the past yields zero, never a negative wait.
    subroutine http_parse_retry_after(text, seconds, ok)
        character(len=*), intent(in) :: text
        real(real64), intent(out) :: seconds
        logical, intent(out) :: ok
        character(len=:), allocatable :: value
        integer(int64) :: target_epoch

        seconds = 0.0_real64
        ok = .false.
        value = strip(text)
        if (len(value) == 0) return

        if (is_digits(value)) then
            call parse_real(value, seconds, ok)
            if (ok) seconds = max(0.0_real64, seconds)
            return
        end if

        call parse_http_date(value, target_epoch, ok)
        if (.not. ok) return
        seconds = max(0.0_real64, real(target_epoch - int(c_time(c_null_ptr), int64), real64))
    end subroutine http_parse_retry_after

    subroutine parse_http_date(text, epoch, ok)
        character(len=*), intent(in) :: text
        integer(int64), intent(out) :: epoch
        logical, intent(out) :: ok
        character(len=*), parameter :: months = 'JanFebMarAprMayJunJulAugSepOctNovDec'
        character(len=:), allocatable :: value
        integer :: comma, month, i
        integer :: day, year, hour, minute, second
        integer :: ios

        epoch = 0_int64
        ok = .false.

        value = strip(text)
        comma = index(value, ',')
        if (comma > 0) value = strip(value(comma + 1:))
        if (len(value) < 20) return

        read (value(1:2), '(I2)', iostat=ios) day
        if (ios /= 0) return

        month = 0
        do i = 1, 12
            if (value(4:6) == months(3*i - 2:3*i)) then
                month = i
                exit
            end if
        end do
        if (month == 0) return

        read (value(8:11), '(I4)', iostat=ios) year
        if (ios /= 0) return
        read (value(13:14), '(I2)', iostat=ios) hour
        if (ios /= 0) return
        read (value(16:17), '(I2)', iostat=ios) minute
        if (ios /= 0) return
        read (value(19:20), '(I2)', iostat=ios) second
        if (ios /= 0) return

        epoch = days_from_civil(year, month, day)*86400_int64 + &
                int(hour, int64)*3600_int64 + int(minute, int64)*60_int64 + &
                int(second, int64)
        ok = .true.
    end subroutine parse_http_date

    pure function days_from_civil(year, month, day) result(days)
        integer, intent(in) :: year
        integer, intent(in) :: month
        integer, intent(in) :: day
        integer(int64) :: days
        integer(int64) :: y, era, year_of_era, day_of_year, day_of_era
        integer(int64) :: m, d

        y = int(year, int64)
        m = int(month, int64)
        d = int(day, int64)
        if (m <= 2_int64) y = y - 1_int64

        if (y >= 0_int64) then
            era = y/400_int64
        else
            era = (y - 399_int64)/400_int64
        end if
        year_of_era = y - era*400_int64

        if (m > 2_int64) then
            day_of_year = (153_int64*(m - 3_int64) + 2_int64)/5_int64 + d - 1_int64
        else
            day_of_year = (153_int64*(m + 9_int64) + 2_int64)/5_int64 + d - 1_int64
        end if

        day_of_era = year_of_era*365_int64 + year_of_era/4_int64 - &
                     year_of_era/100_int64 + day_of_year
        days = era*146097_int64 + day_of_era - 719468_int64
    end function days_from_civil

    !> Sleep for `seconds`, sub-second resolution included.
    subroutine http_sleep(seconds)
        real(real64), intent(in) :: seconds
        type(c_timespec) :: request
        integer(c_int) :: rc

        if (seconds <= 0.0_real64) return
        request%tv_sec = int(seconds, c_long)
        request%tv_nsec = int((seconds - real(request%tv_sec, real64))*1.0e9_real64, c_long)
        if (request%tv_nsec < 0_c_long) request%tv_nsec = 0_c_long
        if (request%tv_nsec > 999999999_c_long) request%tv_nsec = 999999999_c_long
        rc = c_nanosleep(request, c_null_ptr)
    end subroutine http_sleep

    !> Seconds on a monotonic clock. Only differences are meaningful.
    function http_monotonic() result(seconds)
        real(real64) :: seconds
        integer(int64) :: ticks, rate

        call system_clock(ticks, rate)
        if (rate <= 0_int64) then
            seconds = 0.0_real64
        else
            seconds = real(ticks, real64)/real(rate, real64)
        end if
    end function http_monotonic

end module cepis_http