diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index e078671e4..0f35c2c4d 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -10,8 +10,8 @@ jobs: build: timeout-minutes: 20 - name: 'Generate & upload documentation' - runs-on: 'ubuntu-latest' + name: "Generate & upload documentation" + runs-on: "ubuntu-latest" continue-on-error: true steps: - name: Checkout @@ -24,14 +24,15 @@ jobs: - name: Install mdBook and preprocessors run: | - cargo binstall mdbook@0.4.36 \ + cargo binstall mdbook@0.4.51 \ mdbook-toc@0.14.1 \ mdbook-open-on-gh@2.4.1 \ - mdbook-admonish@1.14.0 + mdbook-admonish@1.14.0 \ + mdbook-shiftinclude@0.1.0 - uses: jiro4989/setup-nim-action@v1 with: - nim-version: '1.6.20' + nim-version: "1.6.20" - name: Generate doc run: | diff --git a/chronos.nimble b/chronos.nimble index 0613c3e06..d187527e1 100644 --- a/chronos.nimble +++ b/chronos.nimble @@ -47,6 +47,12 @@ proc run(args, path: string) = build args, path exec "build/" & path.splitPath[1] +proc tryExec(cmd: string) = + try: + exec cmd + except Exception as e: + echo e.msg + task examples, "Build examples": # Build book examples for file in listFiles("docs/examples"): @@ -82,4 +88,9 @@ task test_libbacktrace, "test with libbacktrace": task docs, "Generate API documentation": exec "mdbook build docs" - exec nimc & " doc " & "--git.url:https://github.com/status-im/nim-chronos --git.commit:master --outdir:docs/book/api --project chronos" + tryExec nimc & " doc " & "--git.url:https://github.com/status-im/nim-chronos --git.commit:master --outdir:docs/book/api --project chronos" + + # Build the docs for modules that aren't part of the main module. + for item in walkDir("chronos/apps/http"): + if item.kind == pcFile and item.path.splitFile().ext == ".nim": + tryExec nimc & " doc " & "--git.url:https://github.com/status-im/nim-chronos --git.commit:master --outdir:docs/book/api/chronos/apps/http " & item.path diff --git a/docs/book.toml b/docs/book.toml index 570b8f48a..4c4c8b9d4 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -1,5 +1,5 @@ [book] -authors = ["Jacek Sieka"] +authors = ["Jacek Sieka", "Constantine Molchanov"] language = "en" multilingual = false src = "src" @@ -13,8 +13,14 @@ max-level = 2 [preprocessor.open-on-gh] command = "mdbook-open-on-gh" renderer = ["html"] +git-branch = "master" + +[preprocessor.admonish] +command = "mdbook-admonish" +assets_version = "3.1.0" # do not edit: managed by `mdbook-admonish install` + +[preprocessor.shiftinclude] [output.html] git-repository-url = "https://github.com/status-im/nim-chronos/" -git-branch = "master" -additional-css = ["open-in.css"] +additional-css = ["open-in.css", "mdbook-admonish.css"] diff --git a/docs/examples/http_client/chapter1/src/uptimemon.nim b/docs/examples/http_client/chapter1/src/uptimemon.nim new file mode 100644 index 000000000..83ed99e49 --- /dev/null +++ b/docs/examples/http_client/chapter1/src/uptimemon.nim @@ -0,0 +1,37 @@ +# ANCHOR: all +# ANCHOR: import +import chronos/apps/http/httpclient +# ANCHOR_END: import + +# ANCHOR: check +proc check(uri: string) {.async: (raises: [CancelledError]).} = +# ANCHOR_END: check +# ANCHOR: session + let session = HttpSessionRef.new() +# ANCHOR_END: session + +# ANCHOR: response + try: + let response = await session.fetch(parseUri(uri)) +# ANCHOR_END: response + +# ANCHOR: status + if response.status == 200: + echo "[OK] " & uri + else: + echo "[NOK] " & uri & ": " & $response.status +# ANCHOR_END: status +# ANCHOR: except + except HttpError: + echo "[ERR] " & uri & ": " & getCurrentExceptionMsg() +# ANCHOR_END: except +# ANCHOR: finally + finally: + await noCancel(session.closeWait()) +# ANCHOR_END: finally + +# ANCHOR: isMainModule +when isMainModule: + waitFor check("https://google.com") +# ANCHOR_END: isMainModule +# ANCHOR_END: all diff --git a/docs/examples/http_client/chapter1/uptimemon.nimble b/docs/examples/http_client/chapter1/uptimemon.nimble new file mode 100644 index 000000000..14e165956 --- /dev/null +++ b/docs/examples/http_client/chapter1/uptimemon.nimble @@ -0,0 +1,8 @@ +version = "0.1.0" +author = "Your Name" +description = "HTTP Client Tutorial - Chapter 1" +license = "MIT" +srcDir = "src" +bin = @["uptimemon"] + +requires "nim >= 1.6.0", "chronos" diff --git a/docs/examples/http_client/chapter2/src/uptimemon.nim b/docs/examples/http_client/chapter2/src/uptimemon.nim new file mode 100644 index 000000000..8dfca15e6 --- /dev/null +++ b/docs/examples/http_client/chapter2/src/uptimemon.nim @@ -0,0 +1,39 @@ +# ANCHOR: all +import chronos/apps/http/httpclient + +# ANCHOR: uris +const uris = @[ + "https://duckduckgo.com/?q=chronos", "https://mock.codes/403", "http://123.456.78.90" +] +# ANCHOR_END: uris + +# ANCHOR: check_uri +proc check(session: HttpSessionRef, uri: string) {.async: (raises: [CancelledError]).} = + try: + let response = await session.fetch(parseUri(uri)) + + if response.status == 200: + echo "[OK] " & uri + else: + echo "[NOK] " & uri & ": " & $response.status + except HttpError: + echo "[ERR] " & uri & ": " & getCurrentExceptionMsg() +# ANCHOR_END: check_uri + +# ANCHOR: check_uris +proc check(uris: seq[string]) {.async: (raises: [CancelledError]).} = + let session = HttpSessionRef.new() + var futures: seq[Future[void]] + + for uri in uris: + futures.add(session.check(uri)) + + await allFutures(futures) + await noCancel(session.closeWait()) +# ANCHOR_END: check_uris + +# ANCHOR: isMainModule +when isMainModule: + waitFor check(uris) +# ANCHOR_END: isMainModule +# ANCHOR_END: all diff --git a/docs/examples/http_client/chapter2/uptimemon.nimble b/docs/examples/http_client/chapter2/uptimemon.nimble new file mode 100644 index 000000000..e37ce9e5d --- /dev/null +++ b/docs/examples/http_client/chapter2/uptimemon.nimble @@ -0,0 +1,8 @@ +version = "0.1.0" +author = "Your Name" +description = "HTTP Client Tutorial - Chapter 2" +license = "MIT" +srcDir = "src" +bin = @["uptimemon"] + +requires "nim >= 1.6.0", "chronos" diff --git a/docs/examples/http_client/chapter3/src/uptimemon.nim b/docs/examples/http_client/chapter3/src/uptimemon.nim new file mode 100644 index 000000000..886f34cb4 --- /dev/null +++ b/docs/examples/http_client/chapter3/src/uptimemon.nim @@ -0,0 +1,39 @@ +# ANCHOR: all +import chronos/apps/http/httpclient + +const uris = @[ + "https://duckduckgo.com/?q=chronos", "https://mock.codes/403", "http://123.456.78.90", + "http://10.255.255.1", +] + +# ANCHOR: check +proc check(session: HttpSessionRef, uri: string) {.async: (raises: [CancelledError]).} = + try: + let responseFuture = session.fetch(parseUri(uri)) + + if await responseFuture.withTimeout(5.seconds): + let response = responseFuture.read() + + if response.status == 200: + echo "[OK] " & uri + else: + echo "[NOK] " & uri & ": " & $response.status + else: + raise newException(AsyncTimeoutError, "Connection timed out") + except HttpError, FuturePendingError, AsyncTimeoutError: + echo "[ERR] " & uri & ": " & getCurrentExceptionMsg() +# ANCHOR_END: check + +proc check(uris: seq[string]) {.async: (raises: [CancelledError]).} = + let session = HttpSessionRef.new() + var futures: seq[Future[void]] + + for uri in uris: + futures.add(session.check(uri)) + + await allFutures(futures) + await noCancel(session.closeWait()) + +when isMainModule: + waitFor check(uris) +# ANCHOR_END: all diff --git a/docs/examples/http_client/chapter3/uptimemon.nimble b/docs/examples/http_client/chapter3/uptimemon.nimble new file mode 100644 index 000000000..eed69e556 --- /dev/null +++ b/docs/examples/http_client/chapter3/uptimemon.nimble @@ -0,0 +1,8 @@ +version = "0.1.0" +author = "Your Name" +description = "HTTP Client Tutorial - Chapter 3" +license = "MIT" +srcDir = "src" +bin = @["uptimemon"] + +requires "nim >= 1.6.0", "chronos" diff --git a/docs/examples/http_client/chapter4_1/src/uptimemon.nim b/docs/examples/http_client/chapter4_1/src/uptimemon.nim new file mode 100644 index 000000000..46f870be2 --- /dev/null +++ b/docs/examples/http_client/chapter4_1/src/uptimemon.nim @@ -0,0 +1,50 @@ +# ANCHOR: all +import chronos/apps/http/httpclient + +const uris = @[ + "https://duckduckgo.com/?q=chronos", "https://mock.codes/403", "http://123.456.78.90", + "http://10.255.255.1", "https://html.spec.whatwg.org/", +] + +proc check(session: HttpSessionRef, uri: string) {.async: (raises: [CancelledError]).} = + try: + let +# ANCHOR: request + request = HttpClientRequestRef.new(session, uri) +# ANCHOR_END: request + +# ANCHOR: error_check + if request.isErr: + raise newException(HttpRequestError, request.error) + + let +# ANCHOR_END: error_check + +# ANCHOR: response + responseFuture = request.value.send() +# ANCHOR_END: response + if await responseFuture.withTimeout(5.seconds): + let response = responseFuture.read() + + if response.status == 200: + echo "[OK] " & uri + else: + echo "[NOK] " & uri & ": " & $response.status + else: + raise newException(AsyncTimeoutError, "Connection timed out") + except HttpError, FuturePendingError, AsyncTimeoutError: + echo "[ERR] " & uri & ": " & getCurrentExceptionMsg() + +proc check(uris: seq[string]) {.async: (raises: [CancelledError]).} = + let session = HttpSessionRef.new() + var futures: seq[Future[void]] + + for uri in uris: + futures.add(session.check(uri)) + + await allFutures(futures) + await noCancel(session.closeWait()) + +when isMainModule: + waitFor check(uris) +# ANCHOR_END: all diff --git a/docs/examples/http_client/chapter4_1/uptimemon.nimble b/docs/examples/http_client/chapter4_1/uptimemon.nimble new file mode 100644 index 000000000..034ad0a6c --- /dev/null +++ b/docs/examples/http_client/chapter4_1/uptimemon.nimble @@ -0,0 +1,8 @@ +version = "0.1.0" +author = "Your Name" +description = "HTTP Client Tutorial - Chapter 4.1" +license = "MIT" +srcDir = "src" +bin = @["uptimemon"] + +requires "nim >= 1.6.0", "chronos" diff --git a/docs/examples/http_client/chapter4_2/src/uptimemon.nim b/docs/examples/http_client/chapter4_2/src/uptimemon.nim new file mode 100644 index 000000000..5750acece --- /dev/null +++ b/docs/examples/http_client/chapter4_2/src/uptimemon.nim @@ -0,0 +1,90 @@ +# ANCHOR: all +import chronos/apps/http/httpclient + +# ANCHOR: urls +const uris = @[ + "https://duckduckgo.com/?q=chronos", "https://mock.codes/403", "http://123.456.78.90", + "http://10.255.255.1", "https://html.spec.whatwg.org/", "https://mock.codes/200", +] +# ANCHOR_END: urls + +# ANCHOR: findMarker +proc findMarker( + response: HttpClientResponseRef +): Future[bool] {. + async: (raises: [HttpUseClosedError, AsyncStreamError, CancelledError]) +.} = +# ANCHOR_END: findMarker +# ANCHOR: bodyReader + let bodyReader = response.getBodyReader() +# ANCHOR_END: bodyReader + +# ANCHOR: vars + var + buffer = newSeq[byte](1024) + fetchedBytes: seq[byte] +# ANCHOR_END: vars + +# ANCHOR: while + while not result and len(fetchedBytes) <= 10 * 1024: +# ANCHOR_END: while +# ANCHOR: read_bytes + let bytesRead = await bodyReader.readOnce(addr(buffer[0]), len(buffer)) +# ANCHOR_END: read_bytes + +# ANCHOR: bytes_check + if bytesRead == 0: + break +# ANCHOR_END: bytes_check + +# ANCHOR: fetchedBytes + fetchedBytes &= buffer +# ANCHOR_END: fetchedBytes + +# ANCHOR: result + result = "= 1.6.0", "chronos" diff --git a/docs/examples/http_client/chapter5/src/uptimemon.nim b/docs/examples/http_client/chapter5/src/uptimemon.nim new file mode 100644 index 000000000..5768d75ec --- /dev/null +++ b/docs/examples/http_client/chapter5/src/uptimemon.nim @@ -0,0 +1,112 @@ +# ANCHOR: all +import chronos/apps/http/httpclient + +# ANCHOR: ntfy_topic +const + ntfyTopic = "" +# ANCHOR_END: ntfy_topic + uris = @[ + "https://duckduckgo.com/?q=chronos", "https://mock.codes/403", + "http://123.456.78.90", "http://10.255.255.1", "https://html.spec.whatwg.org/", + "https://mock.codes/200", + ] + +# ANCHOR: sendAlert +proc sendAlert( + session: HttpSessionRef, message: string, priority = 3 +) {.async: (raises: [CancelledError]).} = + let +# ANCHOR_END: sendAlert +# ANCHOR: headers + headers = {"Title": "Chronos Uptime Monitor", "Priority": $priority} +# ANCHOR_END: headers +# ANCHOR: body + body = message.stringToBytes() +# ANCHOR_END: body +# ANCHOR: request + request = HttpClientRequestRef.new( + session, + "https://ntfy.sh/" & ntfyTopic, + meth = MethodPost, + headers = headers, + body = body, + ) +# ANCHOR_END: request + +# ANCHOR: response + if request.isOk: + try: + let response = await request.get.send() + await response.closeWait() + except HttpError: + echo "[WRN] Failed to send alert: " & getCurrentExceptionMsg() +# ANCHOR_END: response + +proc findMarker( + response: HttpClientResponseRef +): Future[bool] {. + async: (raises: [HttpUseClosedError, AsyncStreamError, CancelledError]) +.} = + let bodyReader = response.getBodyReader() + + var + buffer = newSeq[byte](1024) + fetchedBytes: seq[byte] + + while not result and len(fetchedBytes) <= 10 * 1024: + let bytesRead = await bodyReader.readOnce(addr(buffer[0]), len(buffer)) + + if bytesRead == 0: + break + + fetchedBytes &= buffer + + result = "= 1.6.0", "chronos" diff --git a/docs/examples/http_client/chapter6/src/uptimemon.nim b/docs/examples/http_client/chapter6/src/uptimemon.nim new file mode 100644 index 000000000..93d3bce22 --- /dev/null +++ b/docs/examples/http_client/chapter6/src/uptimemon.nim @@ -0,0 +1,135 @@ +# ANCHOR: all +import chronos/apps/http/httpclient + +# ANCHOR: maxConcurrency +const + maxConcurrency = 5 +# ANCHOR_END: maxConcurrency + ntfyTopic = "X3JIaLZSrFqBJXfJ" +# ANCHOR: uris + uris = @[ + "https://duckduckgo.com/?q=chronos", "https://mock.codes/403", + "http://123.456.78.90", "http://10.255.255.1", "https://html.spec.whatwg.org", + "https://mock.codes/200", "https://github.com", "https://archive.org", + "https://nim-lang.org", "https://w3.org", "https://free.technology", + "https://codeberg.org", "https://nimble.directory", "https://status.app", + "https://keycard.tech", "https://stackoverflow.com", "https://nimbus.team", + "https://logos.co", "https://forum.nim-lang.org", "https://acid.info", + "https://vac.dev", "https://expired.badssl.com", "http://10.255.255.2", + "http://10.255.255.3", + ] +# ANCHOR_END: uris + +proc sendAlert( + session: HttpSessionRef, message: string, priority = 3 +) {.async: (raises: [CancelledError]).} = + let + headers = {"Title": "Chronos Uptime Monitor", "Priority": $priority} + body = message.stringToBytes() + request = HttpClientRequestRef.new( + session, + "https://ntfy.sh/" & ntfyTopic, + meth = MethodPost, + headers = headers, + body = body, + ) + + if request.isOk: + try: + let response = await request.get.send() + await response.closeWait() + except HttpError: + echo "[WRN] Failed to send alert: " & getCurrentExceptionMsg() + +proc findMarker( + response: HttpClientResponseRef +): Future[bool] {. + async: (raises: [HttpUseClosedError, AsyncStreamError, CancelledError]) +.} = + let bodyReader = response.getBodyReader() + + var + buffer = newSeq[byte](1024) + fetchedBytes: seq[byte] + + while not result and len(fetchedBytes) <= 10 * 1024: + let bytesRead = await bodyReader.readOnce(addr(buffer[0]), len(buffer)) + + if bytesRead == 0: + break + + fetchedBytes &= buffer + + result = "= 1.6.0", "chronos" diff --git a/docs/mdbook-admonish.css b/docs/mdbook-admonish.css new file mode 100644 index 000000000..eebe4a5b3 --- /dev/null +++ b/docs/mdbook-admonish.css @@ -0,0 +1,356 @@ +@charset "UTF-8"; +:is(.admonition) { + display: flow-root; + margin: 1.5625em 0; + padding: 0 1.2rem; + color: var(--fg); + page-break-inside: avoid; + background-color: var(--bg); + border: 0 solid black; + border-inline-start-width: 0.4rem; + border-radius: 0.2rem; + box-shadow: 0 0.2rem 1rem rgba(0, 0, 0, 0.05), 0 0 0.1rem rgba(0, 0, 0, 0.1); +} +@media print { + :is(.admonition) { + box-shadow: none; + } +} +:is(.admonition) > * { + box-sizing: border-box; +} +:is(.admonition) :is(.admonition) { + margin-top: 1em; + margin-bottom: 1em; +} +:is(.admonition) > .tabbed-set:only-child { + margin-top: 0; +} +html :is(.admonition) > :last-child { + margin-bottom: 1.2rem; +} + +a.admonition-anchor-link { + display: none; + position: absolute; + left: -1.2rem; + padding-right: 1rem; +} +a.admonition-anchor-link:link, a.admonition-anchor-link:visited { + color: var(--fg); +} +a.admonition-anchor-link:link:hover, a.admonition-anchor-link:visited:hover { + text-decoration: none; +} +a.admonition-anchor-link::before { + content: "§"; +} + +:is(.admonition-title, summary.admonition-title) { + position: relative; + min-height: 4rem; + margin-block: 0; + margin-inline: -1.6rem -1.2rem; + padding-block: 0.8rem; + padding-inline: 4.4rem 1.2rem; + font-weight: 700; + background-color: rgba(68, 138, 255, 0.1); + print-color-adjust: exact; + -webkit-print-color-adjust: exact; + display: flex; +} +:is(.admonition-title, summary.admonition-title) p { + margin: 0; +} +html :is(.admonition-title, summary.admonition-title):last-child { + margin-bottom: 0; +} +:is(.admonition-title, summary.admonition-title)::before { + position: absolute; + top: 0.625em; + inset-inline-start: 1.6rem; + width: 2rem; + height: 2rem; + background-color: #448aff; + print-color-adjust: exact; + -webkit-print-color-adjust: exact; + mask-image: url('data:image/svg+xml;charset=utf-8,'); + -webkit-mask-image: url('data:image/svg+xml;charset=utf-8,'); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-size: contain; + content: ""; +} +:is(.admonition-title, summary.admonition-title):hover a.admonition-anchor-link { + display: initial; +} + +@media print { + details.admonition::details-content { + display: contents; + } +} +details.admonition > summary.admonition-title::after { + position: absolute; + top: 0.625em; + inset-inline-end: 1.6rem; + height: 2rem; + width: 2rem; + background-color: currentcolor; + mask-image: var(--md-details-icon); + -webkit-mask-image: var(--md-details-icon); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-size: contain; + content: ""; + transform: rotate(0deg); + transition: transform 0.25s; +} +details[open].admonition > summary.admonition-title::after { + transform: rotate(90deg); +} +summary.admonition-title::-webkit-details-marker { + display: none; +} + +:root { + --md-details-icon: url("data:image/svg+xml;charset=utf-8,"); +} + +:root { + --md-admonition-icon--admonish-note: url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--admonish-abstract: url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--admonish-info: url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--admonish-tip: url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--admonish-success: url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--admonish-question: url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--admonish-warning: url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--admonish-failure: url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--admonish-danger: url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--admonish-bug: url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--admonish-example: url("data:image/svg+xml;charset=utf-8,"); + --md-admonition-icon--admonish-quote: url("data:image/svg+xml;charset=utf-8,"); +} + +:is(.admonition):is(.admonish-note) { + border-color: #448aff; +} + +:is(.admonish-note) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(68, 138, 255, 0.1); +} +:is(.admonish-note) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #448aff; + mask-image: var(--md-admonition-icon--admonish-note); + -webkit-mask-image: var(--md-admonition-icon--admonish-note); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.admonish-abstract, .admonish-summary, .admonish-tldr) { + border-color: #00b0ff; +} + +:is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(0, 176, 255, 0.1); +} +:is(.admonish-abstract, .admonish-summary, .admonish-tldr) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #00b0ff; + mask-image: var(--md-admonition-icon--admonish-abstract); + -webkit-mask-image: var(--md-admonition-icon--admonish-abstract); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.admonish-info, .admonish-todo) { + border-color: #00b8d4; +} + +:is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(0, 184, 212, 0.1); +} +:is(.admonish-info, .admonish-todo) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #00b8d4; + mask-image: var(--md-admonition-icon--admonish-info); + -webkit-mask-image: var(--md-admonition-icon--admonish-info); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.admonish-tip, .admonish-hint, .admonish-important) { + border-color: #00bfa5; +} + +:is(.admonish-tip, .admonish-hint, .admonish-important) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(0, 191, 165, 0.1); +} +:is(.admonish-tip, .admonish-hint, .admonish-important) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #00bfa5; + mask-image: var(--md-admonition-icon--admonish-tip); + -webkit-mask-image: var(--md-admonition-icon--admonish-tip); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.admonish-success, .admonish-check, .admonish-done) { + border-color: #00c853; +} + +:is(.admonish-success, .admonish-check, .admonish-done) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(0, 200, 83, 0.1); +} +:is(.admonish-success, .admonish-check, .admonish-done) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #00c853; + mask-image: var(--md-admonition-icon--admonish-success); + -webkit-mask-image: var(--md-admonition-icon--admonish-success); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.admonish-question, .admonish-help, .admonish-faq) { + border-color: #64dd17; +} + +:is(.admonish-question, .admonish-help, .admonish-faq) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(100, 221, 23, 0.1); +} +:is(.admonish-question, .admonish-help, .admonish-faq) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #64dd17; + mask-image: var(--md-admonition-icon--admonish-question); + -webkit-mask-image: var(--md-admonition-icon--admonish-question); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.admonish-warning, .admonish-caution, .admonish-attention) { + border-color: #ff9100; +} + +:is(.admonish-warning, .admonish-caution, .admonish-attention) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(255, 145, 0, 0.1); +} +:is(.admonish-warning, .admonish-caution, .admonish-attention) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #ff9100; + mask-image: var(--md-admonition-icon--admonish-warning); + -webkit-mask-image: var(--md-admonition-icon--admonish-warning); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.admonish-failure, .admonish-fail, .admonish-missing) { + border-color: #ff5252; +} + +:is(.admonish-failure, .admonish-fail, .admonish-missing) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(255, 82, 82, 0.1); +} +:is(.admonish-failure, .admonish-fail, .admonish-missing) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #ff5252; + mask-image: var(--md-admonition-icon--admonish-failure); + -webkit-mask-image: var(--md-admonition-icon--admonish-failure); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.admonish-danger, .admonish-error) { + border-color: #ff1744; +} + +:is(.admonish-danger, .admonish-error) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(255, 23, 68, 0.1); +} +:is(.admonish-danger, .admonish-error) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #ff1744; + mask-image: var(--md-admonition-icon--admonish-danger); + -webkit-mask-image: var(--md-admonition-icon--admonish-danger); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.admonish-bug) { + border-color: #f50057; +} + +:is(.admonish-bug) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(245, 0, 87, 0.1); +} +:is(.admonish-bug) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #f50057; + mask-image: var(--md-admonition-icon--admonish-bug); + -webkit-mask-image: var(--md-admonition-icon--admonish-bug); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.admonish-example) { + border-color: #7c4dff; +} + +:is(.admonish-example) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(124, 77, 255, 0.1); +} +:is(.admonish-example) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #7c4dff; + mask-image: var(--md-admonition-icon--admonish-example); + -webkit-mask-image: var(--md-admonition-icon--admonish-example); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +:is(.admonition):is(.admonish-quote, .admonish-cite) { + border-color: #9e9e9e; +} + +:is(.admonish-quote, .admonish-cite) > :is(.admonition-title, summary.admonition-title) { + background-color: rgba(158, 158, 158, 0.1); +} +:is(.admonish-quote, .admonish-cite) > :is(.admonition-title, summary.admonition-title)::before { + background-color: #9e9e9e; + mask-image: var(--md-admonition-icon--admonish-quote); + -webkit-mask-image: var(--md-admonition-icon--admonish-quote); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-repeat: no-repeat; +} + +.navy :is(.admonition) { + background-color: var(--sidebar-bg); +} + +.ayu :is(.admonition), +.coal :is(.admonition) { + background-color: var(--theme-hover); +} + +.rust :is(.admonition) { + background-color: var(--sidebar-bg); + color: var(--sidebar-fg); +} +.rust .admonition-anchor-link:link, .rust .admonition-anchor-link:visited { + color: var(--sidebar-fg); +} diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 9bd22f675..181a68a26 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -1,6 +1,18 @@ - [Introduction](./introduction.md) - [Examples](./examples.md) +# Tutorials + +## Uptime Monitor + +- [HTTP Client: Uptime Monitor](./tutorials/http_client/intro.md) + - [Making an HTTP Request with Chronos](./tutorials/http_client/chapter1.md) + - [Making Many Requests Concurrently](./tutorials/http_client/chapter2.md) + - [Timeouts & Cancellation](./tutorials/http_client/chapter3.md) + - [Smarter Health Check with Streaming](./tutorials/http_client/chapter4.md) + - [Sending Alerts with POST Requests](./tutorials/http_client/chapter5.md) + - [Scaling & Finishing Touches](./tutorials/http_client/chapter6.md) + # User guide - [Core concepts](./concepts.md) diff --git a/docs/src/book.md b/docs/src/book.md new file mode 100644 index 000000000..5847f2f9f --- /dev/null +++ b/docs/src/book.md @@ -0,0 +1 @@ +# Updating this book diff --git a/docs/src/tutorials/http_client/chapter1.md b/docs/src/tutorials/http_client/chapter1.md new file mode 100644 index 000000000..a07d3026e --- /dev/null +++ b/docs/src/tutorials/http_client/chapter1.md @@ -0,0 +1,95 @@ +# Making an HTTP Request with Chronos + +**Goal:** Learn how to make an HTTP request and proccess its response with Chronos. + +**Source code:** [chapter1/src/uptimemon.nim](https://github.com/status-im/nim-chronos/blob/master/docs/examples/http_client/chapter1/src/uptimemon.nim) + +Create a new Nimble project: + +```shell +$ nimble init uptimemon +``` + +Copy and paste this code into `src/uptimemon.nim` (we'll go through each line in a moment): + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter1/src/uptimemon.nim:all}} +``` + +To execute the file, switch to the directory with this file in your terminal and run this command: + +```shell +$ nimble run +``` + +You should see the following message in you terminal: + +```shell +[OK] https://google.com +``` + +Now let's see what we're doing here line by line. + +## Line-by-Line Explanation + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter1/src/uptimemon.nim:import}} +``` + +[`httpclient`](/api/chronos/apps/http/httpclient.html) module, as the title suggests, implements the HTTP client capabilities, i.e. sending HTTP requests and dealing with the responses asynchronously. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter1/src/uptimemon.nim:check}} +``` + +We define a function that sends an HTTP request to a URL we provide, checks if this URL is available, and prints the result. + +Note that this function is annotated with `async` pragma because we won't call it directly but instead will "book" its execution from Chronos in an asynchronous way. + +Also note the `raises: [CancelledError]` part. This is Chronos's way of announcing the exceptions that are expected to the raised by this function. This mechanism is called [checked exceptions](../../error_handling.md#checked-exceptions). In this particular case, we tell the compiler that this function has cancellable things inside it and propagates the cancellation to its caller. No other exceptions should leak from it and if they do, it's a defect in the program. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter1/src/uptimemon.nim:session}} +``` + +Let's focus on this line for a moment. Here, we're creating an HTTP session. Why would we do it if we need to make only only request, why can't we just send it? The reason is, Chronos is designed for multitasking and a session is a more natural concept than a singular request in this context. While we're just starting, using a session may feel redundant but since our end goal is to send many requests, it will fit just right. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter1/src/uptimemon.nim:response}} +``` + +When dealing with the Web, we must always assume the connection can break. So it's a good idea to get wrap all web interactions in a `try-except` block. + +[`fetch`](/api/chronos/apps/http/httpclient.html#fetch,HttpSessionRef,Uri) is a shortcut for "create an HTTP GET request within the given session to the given URL." + +[`parseUri`](https://nim-lang.org/docs/uri.html#parseUri,string) is a function that parses a string into a structured URI object. + +Notice that when we are assigning a value to `response`, we do not just call `fetch` but put an `await` before it. This is because `fetch` returns a `Future`, i.e. a not-yet-ready-result. `await` signals to the runtime that this function is interested in this computation result but while it's waiting for it, some other routine can take control. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter1/src/uptimemon.nim:status}} +``` + +Once we've received our response, we can check its status. If it's 200, we mark this URL healthy (later in the tutorial, we'll improve this logic to handle empty and junk responses), otherwise—not healthy. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter1/src/uptimemon.nim:except}} +``` + +If the request fails (e.g. the connection is unstable or the host is unreachable), `fetch` would raise a `HttpError` exception. Since raising this exception is part of our business logic, we catch it and report the error with `getCurrentExceptionMsg`. + +Note that catching `HttpError` does not contadict the `raises` value at the function definition: since we handle the exception and not re-raise it, our promise that only `CancelledError` ever emits from `check` is held true. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter1/src/uptimemon.nim:finally}} +``` + +Regardless of how successful our check was, we must close the session after we're done with in and return the resources back to your computer. [`closeWait`](/api/chronos/apps/http/httpclient.html#closeWait,HttpSessionRef) is a function that schedules all open connections within this session to be closed. + +We added [`noCancel`](/api/chronos/internal/asyncfutures.html#noCancel,F) to make sure the closing procedure is not cancelled with a propagated `CancellationError` from another function. Use `noCancel` in resource-critical operations or atomic operation groups that must either all complete or all fail. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter1/src/uptimemon.nim:isMainModule}} +``` + +Finally, we call our function to check a particular URL. Google is probably up so you should get an `[OK]` message. However, you can try other URLs to see how the response changes if you use a non-existing URL or a forbidden one. diff --git a/docs/src/tutorials/http_client/chapter2.md b/docs/src/tutorials/http_client/chapter2.md new file mode 100644 index 000000000..8faa88ae0 --- /dev/null +++ b/docs/src/tutorials/http_client/chapter2.md @@ -0,0 +1,115 @@ +# Making Many Requests Concurrently + +**Goal:** Learn how to make arbitrarily many HTTP requests asynchronously. + +**Source code:** [chapter2/src/uptimemon.nim](https://github.com/status-im/nim-chronos/blob/master/docs/examples/http_client/chapter2/src/uptimemon.nim) + +OK, we have a working app that can check one URI at a time, which is not that much impressive. Let's update our project to do what Chronos was made for—concurrency! + +We'll take a somewhat unusual approach and **start with the wrong solution** before revealing the proper way of solving this problem. By highlighting the common mistakes, we'll help you avoid them in the future. + +## Wrong Solution: Naive Loop + +The most obvious to check multiple URIs instead of one would be to just call `check` in a loop: + +```nim +import chronos/apps/http/httpclient + +# Define a list of URIs to check +const uris = @[ + "https://duckduckgo.com/?q=chronos", "https://mock.codes/403", "http://123.456.78.90" +] + +proc check(uri: string) {.async: (raises: [CancelledError]).} = + let session = HttpSessionRef.new() + + try: + let response = await session.fetch(parseUri(uri)) + + if response.status == 200: + echo "[OK] " & uri + else: + echo "[NOK] " & uri & ": " & $response.status + except HttpError: + echo "[ERR] " & uri & ": " & getCurrentExceptionMsg() + finally: + await noCancel(session.closeWait()) + +when isMainModule: + # Loop over the URIs + for uri in uris: + waitFor check(uri) +``` + +If you run this code, you'll see that it works and does in fact check your URIs. + +So, why is it the wrong solution? Because we check URIs in a blocking, synchronous, and therefore slow loop. + +With this kind of solution, your app checks URIs one by one and the total time is the sum of the time spent getting a response for every single URI. This is hardly usable if you have as few as 20 URIs to check. + +## Correct Solution: Asynchronous Bulk Requests + +We want Chronos to start all the requests at the same time and each other's result as soon as it's available. + +To achieve that, we will: + +1. Introduce a new async function that will schedule the checks. We can't do that outside if a function because async calls are allowed only in async functions. +2. Create one HTTP session for all requests instead of creting a new session for each request. +3. Store all `Future`s that correspond to pending HTTP requests and await them all at once with Chronos's [`allFutures`](/api/chronos/internal/asyncfutures.html#allFutures,varargs[Future[T]]) helper. + +Here's the code: + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter2/src/uptimemon.nim:all}} +``` + +Run this code with `nimble run`, you should see something like this (the order of messages may be different): + +```shell +[ERR] http://123.456.78.90: Could not resolve address of remote server +[NOK] https://mock.codes/403: 403 +[OK] https://duckduckgo.com/?q=chronos +``` + +Notice that: + +1. The order of responses of different from the order of the URIs in the source code. That's because our requests are now asynchronous, as they should be. +2. The execution time has improved. Now, the program runs roughly as long as the its longest request, not as the sum of all requests. + You can measure the program's execution time to see the difference more clearly: + +```shell +# Compile the program in release mode first: +$ nimble build -d:release +# bash, zsh: +$ time {./uptimemon} +# PowerShell: +$ Measure-Command {./uptimemon.exe | Out-Default} +``` + +Let's examine the changes since the previous version. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter2/src/uptimemon.nim:uris}} +``` + +We define a list of URIs to check. We've put a diverse group to see different responses: DuckDuckGo should respond with `[OK]`, Mock returns a 403 status, i.e. `[NOK]`, and the last one is a non-existant location visiting which should return `[ERR]`. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter2/src/uptimemon.nim:check_uri}} +``` + +We add a new argument to our `check` function and remove the session closing part—session creation and destruction now happen in the caller function. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter2/src/uptimemon.nim:check_uris}} +``` + +We add another `check` function but this ones takes a list of URIs, not one URI. In this function, we create a session (and close it at the end), and populate a list of `Future`s by creating one for each URI. + +Then, we use `allFutures` to await all those `Future`s as if they were a single `Future` (in fact, `allFutures` does exactly that—it wraps all `Future`s passed to it with one `Future`). + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter2/src/uptimemon.nim:isMainModule}} +``` + +Finally, we `waitFor` the `check` to complete for all URIs. diff --git a/docs/src/tutorials/http_client/chapter3.md b/docs/src/tutorials/http_client/chapter3.md new file mode 100644 index 000000000..276f547ce --- /dev/null +++ b/docs/src/tutorials/http_client/chapter3.md @@ -0,0 +1,40 @@ +# Timeouts & Cancellation + +**Goal:** Learn how to prevent the program from freezing on slow responses. + +**Source code:** [chapter3/src/uptimemon.nim](https://github.com/status-im/nim-chronos/blob/master/docs/examples/http_client/chapter3/src/uptimemon.nim) + +Our current program works fine with the well-behaving URIs we've tested so far: all these locations either respond quickly or quickly return an error. + +However, not all requests will go smoothly when you face the real web. Poor connections, slow servers, anti-bot checks, and access restrictions result in responses that may take long to complete or even never complete. One "misbehaving" request can negatively affect the entire program. + +For example, try adding an IP address the never responds to the list: + +```nim +const uris = @[ + "https://duckduckgo.com/?q=chronos", "https://mock.codes/403", "http://123.456.78.90", + "http://10.255.255.1", +] +``` + +Run the program and you'll see that it'll run for 10+ seconds, stuck on this last IP. + +Let's add a timeout to our requests to cancel slow requests before they ruin our app: if a request takes longer than 5 seconds, we cancel it. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter3/src/uptimemon.nim:all}} +``` + +Here's the part that changed: + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter3/src/uptimemon.nim:check}} +``` + +1. We create a `Future` before awaiting on it. +2. Then we `await` it with the special [`withTimeout`](/api/chronos/internal/asyncfutures.html#withTimeout,Future[T],Duration) modifier. This modifier returns `true` if the `Future` passed to it completed before the timeout and `false` otherwise. +3. If the timeout exhausted before we got our response, we raise an [`AsyncTimeoutError`](/api/chronos/internal/errors.html#AsyncTimeoutError) exception that is caught downstream. +4. [`responseFuture.read()`]() can raise [`FuturePendingError`](/api/chronos/internal/asyncfutures.html#FuturePendingError) so we have to handle this exception. +5. Since we explicitly raise an `AsyncTimeoutError`, we need to handle that exception as well. + +Run the program again and you'll see it complete in roughly 5 seconds, i.e. our timeout. diff --git a/docs/src/tutorials/http_client/chapter4.md b/docs/src/tutorials/http_client/chapter4.md new file mode 100644 index 000000000..e265ae00c --- /dev/null +++ b/docs/src/tutorials/http_client/chapter4.md @@ -0,0 +1,174 @@ +# Smarter Health Check with Streaming + +**Goal:** Learn how to use streaming to check web page content without fully downloading it. + +**Source code:** + +- [chapter4_1/src/uptimemon.nim](https://github.com/status-im/nim-chronos/blob/master/docs/examples/http_client/chapter4_1/src/uptimemon.nim) +- [chapter4_2/src/uptimemon.nim](https://github.com/status-im/nim-chronos/blob/master/docs/examples/http_client/chapter4_2/src/uptimemon.nim) + +Currently, we're using `fetch` to make a GET request and check its result. However, this function doesn't give us just the response status, it gives us the full page content as well. + +While this is correct, it's not optimal: if a page is large, our program will consume unnecessary amount of memory to store that response and waste a lot of time downloading it. + +For example, try adding this URI to the list and running the program: https://html.spec.whatwg.org. This is a proper page but it's so heavy fetching it entirely would time out: + +```shell +[ERR] http://123.456.78.90: Could not resolve address of remote server +[NOK] https://mock.codes/403: 403 +[OK] https://duckduckgo.com/?q=chronos +[ERR] http://10.255.255.1: Connection timed out +[ERR] https://html.spec.whatwg.org/: Connection timed out +``` + +Let's optimize our check to handle large page like this one. + +## Getting Just the Status + +First, let's not download the page and just check the response status. + +To do that, instead of using `fetch`, we'll create the request manually: + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter4_1/src/uptimemon.nim:all}} +``` + +Here are the new lines that replace `let responseFuture = session.fetch(parseUri(uri))`: + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter4_1/src/uptimemon.nim:request}} +``` + +We explicitly create a GET request. + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter4_1/src/uptimemon.nim:error_check}} +``` + +Request creation can fail, so we need to check that before proceeding: if an error happened, raise a `HttpRequestError` to be caught downstream. We use [`request.error`](/api/chronos/apps/http/httpclient.html#HttpClientRequest) to get the message of the error. + +```admonish note +You may wonder why we do an explicit check while the entire block is already wrapped in `try`. + +That's because [`value`](https://github.com/arnetheduck/nim-results/blob/master/results.nim#L870), which is called later to get the actual request instance, raises `ResultDefect` if request creation failed which is not a `CatchableError` and would slip through our `except`. +``` + +```nim +{{#shiftinclude auto:../../../examples/http_client/chapter4_1/src/uptimemon.nim:response}} +``` + +We send the request with [`send`](/api/chronos/apps/http/httpclient.html#send,HttpClientRequestRef) and get a `Future`, which we can await on later just like before. + +Run the program and you'll see the correct `[OK]` result for https://html.spec.whatwg.org: + +```shell +[ERR] http://123.456.78.90: Could not resolve address of remote server +[NOK] https://mock.codes/403: 403 +[OK] https://duckduckgo.com/?q=chronos +[OK] https://html.spec.whatwg.org/ +[ERR] http://10.255.255.1: Connection timed out +``` + +## Streaming the Body + +Checking just the status speeds up our program but by ignoring the body entirely, we can miss URIs that do not serve valid HTML content. We need a way to check the content but do that without downloading the whole page. + +Chronos allows streaming response body, so let's use this feature to fetch content in chunks, check the collected data for a certain health marker (e.g. "