diff --git a/.github/workflows/handshake-idric.yml b/.github/workflows/handshake-idric.yml new file mode 100644 index 0000000..bb2c6b3 --- /dev/null +++ b/.github/workflows/handshake-idric.yml @@ -0,0 +1,135 @@ +name: Handshake Idric checkpoint + +on: + pull_request: + paths: + - 'Handshake.idric' + - 'checkpoints/handshake/**' + - '.github/workflows/handshake-idric.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + handshake: + runs-on: ubuntu-latest + steps: + - name: Checkout idric-cli + uses: actions/checkout@v4 + + - name: Checkout current Idric + uses: actions/checkout@v4 + with: + repository: isomorphisms/Idric + ref: Idriç + path: .tools/Idric + + - name: Checkout ICU credential-header stack + uses: actions/checkout@v4 + with: + repository: dilapidated-shed/icu + ref: credential-sensitive-headers + path: .tools/icu + + - name: Record tested source heads + run: | + printf 'Idric\t%s\n' "$(git -C "$GITHUB_WORKSPACE/.tools/Idric" rev-parse HEAD)" + printf 'ICU\t%s\n' "$(git -C "$GITHUB_WORKSPACE/.tools/icu" rev-parse HEAD)" + + - name: Install host build tools + run: | + sudo apt-get update + sudo apt-get install -y build-essential curl libssl-dev + + - name: Bootstrap Idric + run: .tools/Idric/_/edric bootstrap + + - name: Expose pinned Chez + run: echo "$GITHUB_WORKSPACE/.tools/Idric/_/.tools/bin" >> "$GITHUB_PATH" + + - name: Build and check ICU transport + env: + IDRIS2_PREFIX: ${{ github.workspace }}/.tools/Idric/_/bootstrap-build + run: | + make -C "$GITHUB_WORKSPACE/.tools/icu" check-native + make -C "$GITHUB_WORKSPACE/.tools/icu" \ + IDRIC="$GITHUB_WORKSPACE/.tools/Idric/_/build/exec/idris2" + + - name: Start deterministic credential redirect servers + env: + HANDSHAKE_JOBS_FIXTURE: ${{ github.workspace }}/checkpoints/handshake/fixture/jobs.json + run: | + cat > "$RUNNER_TEMP/handshake-redirect.py" <<'PY' + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from pathlib import Path + import os + import threading + + fixture = Path(os.environ["HANDSHAKE_JOBS_FIXTURE"]).read_bytes() + + class FirstOrigin(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + + def log_message(self, format, *args): + pass + + def redirect(self, location): + self.send_response(302) + self.send_header("Location", location) + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self): + if self.headers.get("x-api-key") != "synthetic": + self.send_error(400, "x-api-key missing on same-origin hop") + return + if self.path == "/v1/jobs?page_size=100": + self.redirect("/v1/jobs/continued") + return + if self.path == "/v1/jobs/continued": + self.redirect("http://127.0.0.1:18084/final") + return + self.send_error(404) + + class SecondOrigin(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + + def log_message(self, format, *args): + pass + + def do_GET(self): + if self.path != "/final": + self.send_error(404) + return + if self.headers.get("x-api-key") is not None: + self.send_error(400, "x-api-key leaked across origin") + return + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(fixture))) + self.end_headers() + self.wfile.write(fixture) + + first = ThreadingHTTPServer(("127.0.0.1", 18083), FirstOrigin) + second = ThreadingHTTPServer(("127.0.0.1", 18084), SecondOrigin) + threading.Thread(target=first.serve_forever, daemon=True).start() + second.serve_forever() + PY + python3 "$RUNNER_TEMP/handshake-redirect.py" \ + > "$RUNNER_TEMP/handshake-redirect.log" 2>&1 & + echo $! > "$RUNNER_TEMP/handshake-redirect.pid" + sleep 1 + + - name: Run Handshake checkpoint + env: + IDRIC: ${{ github.workspace }}/.tools/Idric/_/build/exec/idris2 + IDRIS2_PREFIX: ${{ github.workspace }}/.tools/Idric/_/bootstrap-build + IDRIS2_PATH: ${{ github.workspace }}/.tools/Idric/_/libs/contrib/build/ttc:${{ github.workspace }}/.tools/icu/build/ttc + LD_LIBRARY_PATH: ${{ github.workspace }}/.tools/icu + HANDSHAKE_TEST_EDU_BASE_URL: http://127.0.0.1:18083/v1 + run: bash checkpoints/handshake/check + + - name: Show redirect server log on failure + if: failure() + run: cat "$RUNNER_TEMP/handshake-redirect.log" 2>/dev/null || true diff --git a/Handshake.idric b/Handshake.idric new file mode 120000 index 0000000..f271826 --- /dev/null +++ b/Handshake.idric @@ -0,0 +1 @@ +checkpoints/handshake/idric/Handshake.idric \ No newline at end of file diff --git a/README.md b/README.md index abe7022..2745da6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This repository is the consolidation point for the CLI/API-access programs that - `Economist.idric` — Economist API checkpoint. - `Ft.idric` — Financial Times API checkpoint. - `Guardian.idric` — Guardian API checkpoint. +- `Handshake.idric` — Handshake EDU API checkpoint for jobs and job-role classifications, plus public job URLs. - `Nyt.idric` — New York Times API checkpoint. - `Reddit.idric` — Reddit Data API checkpoint, with a synthetic fixture and manual receipt. - `Reuters.idric` — Reuters GraphQL checkpoint. @@ -23,10 +24,10 @@ Some Idriç clients intentionally contain named holes for compiler/library bound ## Networking -Where these clients need networking, ICU/Idric-Net remains the intended transport boundary. ICU is deliberately **not** a Git submodule here and is not pinned by this repository. +Where these clients need networking, ICU/Idric-Net remains the intended transport boundary. ICU is deliberately **not** a Git submodule here. While ICU's caller-header/capture stack is still pending, the Handshake compiler checkpoint checks out that current stack explicitly so its transport dependency is executable rather than replaced by curl or Python. ## Tests -`make test` runs the existing Amazon and AbeBooks smoke tests. Reddit has a separate manual compiler checkpoint at `checkpoints/reddit/check`; it is not part of `make test` while named Idriç holes remain. +`make test` runs the existing Amazon and AbeBooks smoke tests. Reddit and Handshake have separate compiler checkpoints under `checkpoints/`. Handshake now uses ICU's caller-declared credential-header surface from ICU #20: `x-api-key` is retained across same-origin redirects and stripped before a cross-origin request. The deterministic Handshake checkpoint exercises that redirect chain with a synthetic key and fixture response. See `PROVENANCE.md` for the source branches copied into this repository. diff --git a/checkpoints/handshake/README.md b/checkpoints/handshake/README.md new file mode 100644 index 0000000..cd643e5 --- /dev/null +++ b/checkpoints/handshake/README.md @@ -0,0 +1,131 @@ +# Handshake CLI compiler checkpoint + +This directory is a Handshake EDU API command-line client checkpoint in Idriç. + +The slice is deliberately narrow: use Handshake's documented read-only EDU API for `jobs` and `job_role_groups`, keep the API-key/header boundary explicit, and expose public job URLs separately. Do not treat Handshake's authenticated student web application as a public API and do not make this checkpoint green by replaying browser cookies or substituting curl/Python. + +## Why these two endpoints first + +The immediate analytical use is to compare what a posting actually says with the occupational/job-role classification Handshake attaches to it. The official EDU API documents: + +- `/jobs` — job id, title, employer id, employment type, job type, pay rate, remote/on-site/hybrid flags, salary type, external URL, timestamps, and related fields; +- `/job_role_groups` — job id, Handshake job-role-group id, job-role-group name, and timestamps. + +Keeping those tables separate preserves Handshake's own data model. A later slice can join them by `job_id` and measure classification disagreements without silently rewriting the source data. + +Official documentation, checked 2026-09-02: + +- https://support.joinhandshake.com/hc/en-us/articles/31061076506391-Getting-Started-with-EDU-API +- https://support.joinhandshake.com/hc/en-us/articles/35762729693719-EDU-API-Endpoint-Definitions + +## Access boundary + +Handshake describes the EDU API as read-only and institution-scoped. Access requires an EDU API subscription approved by Handshake Support and requests use an `x-api-key` header. + +This checkpoint therefore expects: + +```text +HANDSHAKE_EDU_API_KEY=... +``` + +That credential is not assumed to exist. Missing and empty credentials are explicit refusal cases. + +The public job catalog is a separate surface. Public job detail pages currently use URLs such as: + +```text +https://app.joinhandshake.com/public/jobs/JOB_ID +``` + +`handshake public JOB_ID` only constructs that public URL. It does not claim that the public catalog has the same API contract as the institution-scoped EDU API. + +## Command contract + +```text +handshake url jobs +handshake url roles +handshake public JOB_ID +handshake fixture-jobs FILE +handshake fixture-roles FILE +handshake jobs +handshake roles +``` + +### `url jobs` / `url roles` + +No network. Print the documented EDU API endpoint with a page size of 100. + +### `public JOB_ID` + +No network. Validate a nonempty ASCII decimal Handshake job id and print its public job-detail URL. + +### `fixture-jobs` / `fixture-roles` + +No network. Decode a synthetic EDU-API-shaped JSON response and emit TSV. + +Committed fixtures: + +```text +fixture/jobs.json +fixture/jobs.expected.tsv +fixture/job_role_groups.json +fixture/job_role_groups.expected.tsv +``` + +The jobs fixture includes escaped tab/newline characters in a title while its expected TSV contains spaces, so the receipt exercises field cleanup as well as JSON decoding. + +### `jobs` / `roles` + +Read `HANDSHAKE_EDU_API_KEY`, build `x-api-key` with ICU's caller-declared credential-header surface, perform one ICU GET with file-backed response capture, decode the response, and emit the same TSV shape as the fixtures. + +The credential tag does not change the header's wire representation. Its only policy effect is on redirects: same-origin redirects retain `x-api-key`; a change of scheme, host, or port strips it before the redirected request is sent. + +This first slice intentionally does not paginate. Handshake documents cursor pagination (`next_cursor` / `page_cursor`); pagination should follow after a single live page is accepted. + +`HANDSHAKE_EDU_BASE_URL` exists so deterministic acceptance can direct the live command at a local fixture server. The `url jobs` and `url roles` commands continue to report the documented production URLs. + +## Surface audit + +The original draft made seven named holes. They are now either implemented through current Idriç surfaces/small client-local logic or covered by the pending ICU stack: + +| Original hole | Present surface | Current treatment | +| --- | --- | --- | +| TSV field cleanup | `String`, `pack`, `unpack` | local replacement of tab/newline/carriage-return with spaces | +| fixture input | `System.File.readFile : ... → Either FileError String` | implemented with explicit file-error text | +| jobs JSON decoding | `Language.JSON.parse`, `JSON`, `lookup` | typed `/jobs` decoder over Idriç contrib JSON | +| role-group JSON decoding | same | typed `/job_role_groups` decoder over the same parsed JSON | +| environment access | `System.getEnv : ... → Maybe String` | direct wrapper for fixed valid names | +| decimal job-id validation | ordinary character/list operations | nonempty ASCII decimal check | +| ICU GET with `x-api-key` | ICU #13 header/capture mechanics plus ICU #20 caller-declared credential headers | implemented; pending that ICU stack landing | + +`Language.JSON` is already part of current Idriç contrib. It parses a string to `Maybe JSON`, with structural `JNull`, `JBoolean`, `JNumber`, `JString`, `JArray`, and `JObject` values plus object-field lookup. The Handshake checkpoint therefore does not carry its own JSON grammar. Only fields needed for the two raw tables are decoded into Handshake records; unrelated response fields remain parsed JSON and are ignored. + +`Language.JSON` represents JSON numbers as `Double`. Required Handshake identifier fields are accepted only when the parsed number converts back to the same integral value. If Handshake ever documents or emits identifiers outside the exactly representable integer range of that JSON surface, that becomes a real decoding-surface limitation rather than something this client should hide. + +Idriç PR #67 is separately restoring the stricter project-level `environment_value : String → IO (Maybe String)` wrapper after the source-layout rewrite. Handshake does not need to block on it: this client queries fixed valid names and current `System.getEnv` already returns `Maybe String`. + +## Credential redirect boundary + +ICU #13 supplies validated caller headers and `fetch_to_files_with_headers`. ICU #20, stacked directly on #13, adds `make_credential_header` and carries only the declared credential header names into native redirect handling. + +For Handshake the request is therefore constructed as a normal GET with a credential-tagged `x-api-key`. On same-origin redirects the full request header survives. On cross-origin redirects ICU removes `Authorization`, `Cookie`, and all caller-declared credential headers while preserving unrelated custom headers and rewriting `Host`. + +Handshake does not invent a private transport. It does not substitute curl, Python, browser cookies, or a Handshake-specific socket path. + +## Checkpoint ladder + +1. source parses/checks against current Idriç, contrib, and the current ICU header/capture stack; +2. `url jobs` and `url roles` print the documented endpoints; +3. `public JOB_ID` accepts decimal ids and rejects malformed ids; +4. jobs fixture decodes, flattens embedded TSV-breaking whitespace, and matches its TSV receipt; +5. job-role-group fixture decodes and matches its TSV receipt; +6. process environment distinguishes missing and empty API keys; +7. ICU proves caller-declared credential headers survive same-origin redirects and are stripped cross-origin; +8. the Handshake executable traverses a deterministic same-origin then cross-origin redirect chain with synthetic `x-api-key` and returns the jobs fixture only if both header conditions hold; +9. one actual `/jobs` page can be accepted with an authorized EDU key; +10. one actual `/job_role_groups` page can be accepted through the same boundary; +11. cursor pagination is added without changing the one-page decoder contract; +12. a separate analysis command joins jobs and role groups by `job_id` and reports classification evidence without altering raw observations. + +## Public-catalog follow-up + +The public `find-jobs` catalog is useful to an individual job seeker even without institutional EDU API credentials. It should be investigated as a separate checkpoint. Only add a machine-readable public-catalog client if there is a stable public interface whose use can be documented; do not infer a private student API from browser internals. diff --git a/checkpoints/handshake/check b/checkpoints/handshake/check new file mode 100755 index 0000000..c6085a7 --- /dev/null +++ b/checkpoints/handshake/check @@ -0,0 +1,194 @@ +#!/usr/bin/env ysh + +# Idriç Handshake checkpoint runner. +# Offline surfaces are always executable. When a deterministic EDU base URL is +# supplied, the live command is exercised through ICU with a synthetic key. + +set -u + +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +IDRIC=${IDRIC:-idris2} + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM + +status=0 + +available() { + case "$1" in + */*) test -x "$1" ;; + *) command -v "$1" >/dev/null 2>&1 ;; + esac +} + +pass() { + printf 'PASS\t%s\n' "$1" +} + +fail() { + printf 'FAIL\t%s\n' "$1" + status=1 +} + +skip() { + printf 'SKIP\t%s\n' "$1" +} + +show_diagnostics() { + for path in "$@"; do + if test -s "$path"; then + sed 's/^/ /' "$path" + fi + done +} + +expect_success_output() { + label=$1 + expected=$2 + shift 2 + + if "$@" >"$TMP/actual" 2>"$TMP/actual.err"; then + printf '%s\n' "$expected" >"$TMP/expected" + if cmp -s "$TMP/expected" "$TMP/actual"; then + pass "$label" + else + fail "$label" + diff -u "$TMP/expected" "$TMP/actual" || true + show_diagnostics "$TMP/actual.err" + fi + else + fail "$label" + show_diagnostics "$TMP/actual" "$TMP/actual.err" + fi +} + +expect_failure_output() { + label=$1 + expected=$2 + shift 2 + + if "$@" >"$TMP/actual" 2>"$TMP/actual.err"; then + fail "$label" + show_diagnostics "$TMP/actual" "$TMP/actual.err" + else + printf '%s\n' "$expected" >"$TMP/expected" + if cmp -s "$TMP/expected" "$TMP/actual"; then + pass "$label" + else + fail "$label" + diff -u "$TMP/expected" "$TMP/actual" || true + show_diagnostics "$TMP/actual.err" + fi + fi +} + +if ! available "$IDRIC"; then + skip "idric (not found: $IDRIC)" + exit 0 +fi + +# Current Idriç checks the declared module name against the source path. Copy +# the exact checkpoint source into an isolated module root so `module Handshake` +# is checked as Handshake.idric rather than as a nested absolute path. +cp "$HERE/idric/Handshake.idric" "$TMP/Handshake.idric" + +if ( + cd "$TMP" + "$IDRIC" --check Handshake.idric \ + >"$TMP/idric" 2>"$TMP/idric.err" +); then + pass 'idric/check' +else + fail 'idric/check' + show_diagnostics "$TMP/idric" "$TMP/idric.err" + exit "$status" +fi + +if ( + cd "$TMP" + "$IDRIC" Handshake.idric -o handshake \ + >"$TMP/build.log" 2>"$TMP/build.err" +); then + pass 'idric/build' +else + fail 'idric/build' + show_diagnostics "$TMP/build.log" "$TMP/build.err" + exit "$status" +fi + +PROGRAM="$TMP/build/exec/handshake" +if test ! -x "$PROGRAM"; then + fail 'idric/executable' + find "$TMP" -maxdepth 4 -type f -print | sed 's/^/ /' + exit "$status" +fi +pass 'idric/executable' + +expect_success_output \ + 'url/jobs' \ + 'https://edu-api.joinhandshake.com/v1/jobs?page_size=100' \ + "$PROGRAM" url jobs + +expect_success_output \ + 'url/roles' \ + 'https://edu-api.joinhandshake.com/v1/job_role_groups?page_size=100' \ + "$PROGRAM" url roles + +expect_success_output \ + 'public/decimal' \ + 'https://app.joinhandshake.com/public/jobs/123456' \ + "$PROGRAM" public 123456 + +expect_failure_output \ + 'public/reject-nondecimal' \ + 'handshake: JOB_ID must contain decimal digits only' \ + "$PROGRAM" public 12x + +if "$PROGRAM" fixture-jobs "$HERE/fixture/jobs.json" \ + >"$TMP/jobs.tsv" 2>"$TMP/jobs.err" && + cmp -s "$HERE/fixture/jobs.expected.tsv" "$TMP/jobs.tsv"; then + pass 'fixture/jobs' +else + fail 'fixture/jobs' + diff -u "$HERE/fixture/jobs.expected.tsv" "$TMP/jobs.tsv" || true + show_diagnostics "$TMP/jobs.err" +fi + +if "$PROGRAM" fixture-roles "$HERE/fixture/job_role_groups.json" \ + >"$TMP/roles.tsv" 2>"$TMP/roles.err" && + cmp -s "$HERE/fixture/job_role_groups.expected.tsv" "$TMP/roles.tsv"; then + pass 'fixture/roles' +else + fail 'fixture/roles' + diff -u "$HERE/fixture/job_role_groups.expected.tsv" "$TMP/roles.tsv" || true + show_diagnostics "$TMP/roles.err" +fi + +expect_failure_output \ + 'environment/missing' \ + 'handshake: missing HANDSHAKE_EDU_API_KEY' \ + env -u HANDSHAKE_EDU_API_KEY "$PROGRAM" jobs + +expect_failure_output \ + 'environment/empty' \ + 'handshake: HANDSHAKE_EDU_API_KEY is empty' \ + env HANDSHAKE_EDU_API_KEY= "$PROGRAM" jobs + +if test -n "${HANDSHAKE_TEST_EDU_BASE_URL:-}"; then + if env \ + HANDSHAKE_EDU_API_KEY=synthetic \ + HANDSHAKE_EDU_BASE_URL="$HANDSHAKE_TEST_EDU_BASE_URL" \ + "$PROGRAM" jobs \ + >"$TMP/live-jobs.tsv" 2>"$TMP/live-jobs.err" && + cmp -s "$HERE/fixture/jobs.expected.tsv" "$TMP/live-jobs.tsv"; then + pass 'transport/credential-redirects' + else + fail 'transport/credential-redirects' + diff -u "$HERE/fixture/jobs.expected.tsv" "$TMP/live-jobs.tsv" || true + show_diagnostics "$TMP/live-jobs.err" + fi +else + skip 'transport/credential-redirects (HANDSHAKE_TEST_EDU_BASE_URL not set)' +fi + +exit "$status" diff --git a/checkpoints/handshake/fixture/job_role_groups.expected.tsv b/checkpoints/handshake/fixture/job_role_groups.expected.tsv new file mode 100644 index 0000000..7f41131 --- /dev/null +++ b/checkpoints/handshake/fixture/job_role_groups.expected.tsv @@ -0,0 +1,2 @@ +job_id job_role_group_id job_role_group_name updated_at +1001 3001 Synthetic Model-Training Role 2026-09-01T12:00:00Z diff --git a/checkpoints/handshake/fixture/job_role_groups.json b/checkpoints/handshake/fixture/job_role_groups.json new file mode 100644 index 0000000..5496a96 --- /dev/null +++ b/checkpoints/handshake/fixture/job_role_groups.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "job_id": 1001, + "job_role_group_id": 3001, + "job_role_group_name": "Synthetic Model-Training Role", + "created_at": "2026-08-31T12:00:00Z", + "updated_at": "2026-09-01T12:00:00Z" + } + ], + "meta": { + "next_cursor": null, + "prev_cursor": null + } +} diff --git a/checkpoints/handshake/fixture/jobs.expected.tsv b/checkpoints/handshake/fixture/jobs.expected.tsv new file mode 100644 index 0000000..282a7d6 --- /dev/null +++ b/checkpoints/handshake/fixture/jobs.expected.tsv @@ -0,0 +1,2 @@ +id title employer_id employment_type type pay_rate remote on_site hybrid salary_type external_url updated_at +1001 Synthetic Remote Research Task 2001 part-time Job $45/hour True False False paid 2026-09-01T12:00:00Z diff --git a/checkpoints/handshake/fixture/jobs.json b/checkpoints/handshake/fixture/jobs.json new file mode 100644 index 0000000..ea6e146 --- /dev/null +++ b/checkpoints/handshake/fixture/jobs.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "id": 1001, + "title": "Synthetic\tRemote\nResearch Task", + "employer_id": 2001, + "start_date_time": "2026-09-01T00:00:00Z", + "employment_type": "part-time", + "type": "Job", + "pay_rate": "$45/hour", + "remote": true, + "external_url": null, + "on_site": false, + "hybrid": false, + "archived": false, + "duration": "Temporary/Seasonal", + "salary_type": "paid", + "created_at": "2026-08-31T12:00:00Z", + "updated_at": "2026-09-01T12:00:00Z" + } + ], + "meta": { + "next_cursor": null, + "prev_cursor": null + } +} diff --git a/checkpoints/handshake/idric/Handshake.idric b/checkpoints/handshake/idric/Handshake.idric new file mode 100644 index 0000000..e9b3c5c --- /dev/null +++ b/checkpoints/handshake/idric/Handshake.idric @@ -0,0 +1,450 @@ +module Handshake + +import Data.String +import Http +import Language.JSON +import System +import System.File +import Transport + + +edu_base : String +edu_base = "https://edu-api.joinhandshake.com/v1" + +public_job_base : String +public_job_base = "https://app.joinhandshake.com/public/jobs/" + +jobs_header : String +jobs_header = "id\ttitle\temployer_id\temployment_type\ttype\tpay_rate\tremote\ton_site\thybrid\tsalary_type\texternal_url\tupdated_at" + +roles_header : String +roles_header = "job_id\tjob_role_group_id\tjob_role_group_name\tupdated_at" + +usage : String +usage = "usage: handshake {url jobs | url roles | public JOB_ID | fixture-jobs FILE | fixture-roles FILE | jobs | roles}" + + +choice endpoint one_of + jobs_endpoint + roles_endpoint + +choice command one_of + show_url endpoint + public_job String + fixture_jobs String + fixture_roles String + fetch_jobs + fetch_roles + + +record Job where + constructor Make_Job + id : Integer + title : String + employer_id : Integer + employment_type : Maybe String + job_type : Maybe String + pay_rate : Maybe String + remote : Bool + on_site : Bool + hybrid : Bool + salary_type : Maybe String + external_url : Maybe String + updated_at : String + +record Job_Role_Group where + constructor Make_Job_Role_Group + job_id : Integer + job_role_group_id : Integer + job_role_group_name : String + updated_at : String + + +clean_field_chars : List Char → List Char +clean_field_chars [] = [] +clean_field_chars (value :: rest) = + if value == '\t' || value == '\n' || value == '\r' + then ' ' :: clean_field_chars rest + else value :: clean_field_chars rest + +clean_field : String → String +clean_field text = pack (clean_field_chars (unpack text)) + + +read_fixture : String → IO (Either String String) +read_fixture path = do + outcome ← readFile path + case outcome of + Left problem => + pure (Left ("could not read fixture " ++ path ++ ": " ++ show problem)) + Right body => + pure (Right body) + + +json_field : String → JSON → Maybe JSON +json_field name object = Language.JSON.lookup name object + +required_integer : String → JSON → Either String Integer +required_integer name object = + case json_field name object of + Just (JNumber value) => + let integer : Integer = cast value + in if cast integer == value + then Right integer + else Left ("field " ++ name ++ " must be an integer") + Just _ => + Left ("field " ++ name ++ " must be an integer") + Nothing => + Left ("missing field " ++ name) + +required_string : String → JSON → Either String String +required_string name object = + case json_field name object of + Just (JString value) => + Right value + Just _ => + Left ("field " ++ name ++ " must be a string") + Nothing => + Left ("missing field " ++ name) + +required_boolean : String → JSON → Either String Bool +required_boolean name object = + case json_field name object of + Just (JBoolean value) => + Right value + Just _ => + Left ("field " ++ name ++ " must be a boolean") + Nothing => + Left ("missing field " ++ name) + +optional_string : String → JSON → Either String (Maybe String) +optional_string name object = + case json_field name object of + Nothing => + Right Nothing + Just JNull => + Right Nothing + Just (JString value) => + Right (Just value) + Just _ => + Left ("field " ++ name ++ " must be a string or null") + +response_data : JSON → Either String (List JSON) +response_data document = + case json_field "data" document of + Just (JArray values) => + Right values + Just _ => + Left "field data must be an array" + Nothing => + Left "missing field data" + + +decode_job : JSON → Either String Job +decode_job object@(JObject _) = do + identifier ← required_integer "id" object + title ← required_string "title" object + employer_identifier ← required_integer "employer_id" object + employment ← optional_string "employment_type" object + kind ← optional_string "type" object + pay ← optional_string "pay_rate" object + is_remote ← required_boolean "remote" object + is_on_site ← required_boolean "on_site" object + is_hybrid ← required_boolean "hybrid" object + salary ← optional_string "salary_type" object + external ← optional_string "external_url" object + updated ← required_string "updated_at" object + pure + (Make_Job + identifier + title + employer_identifier + employment + kind + pay + is_remote + is_on_site + is_hybrid + salary + external + updated) +decode_job _ = Left "each data item in /jobs must be a JSON object" + +decode_jobs_values : List JSON → Either String (List Job) +decode_jobs_values [] = Right [] +decode_jobs_values (value :: rest) = do + job ← decode_job value + jobs ← decode_jobs_values rest + pure (job :: jobs) + +decode_jobs_response : String → Either String (List Job) +decode_jobs_response body = + case Language.JSON.parse body of + Nothing => + Left "invalid JSON response" + Just document => do + values ← response_data document + decode_jobs_values values + + +decode_role : JSON → Either String Job_Role_Group +decode_role object@(JObject _) = do + job_identifier ← required_integer "job_id" object + role_identifier ← required_integer "job_role_group_id" object + role_name ← required_string "job_role_group_name" object + updated ← required_string "updated_at" object + pure + (Make_Job_Role_Group + job_identifier + role_identifier + role_name + updated) +decode_role _ = + Left "each data item in /job_role_groups must be a JSON object" + +decode_roles_values : List JSON → Either String (List Job_Role_Group) +decode_roles_values [] = Right [] +decode_roles_values (value :: rest) = do + role ← decode_role value + roles ← decode_roles_values rest + pure (role :: roles) + +decode_roles_response : String → Either String (List Job_Role_Group) +decode_roles_response body = + case Language.JSON.parse body of + Nothing => + Left "invalid JSON response" + Just document => do + values ← response_data document + decode_roles_values values + + +-- System.getEnv already has exactly the Maybe-valued lookup needed here. +-- Idriç's stricter environment_value wrapper is being restored separately, +-- but this client asks only for fixed, valid environment-variable names. +environment_value : String → IO (Maybe String) +environment_value name = getEnv name + + +environment_value_or : String → String → IO String +environment_value_or name fallback = do + value ← environment_value name + case value of + Nothing => pure fallback + Just "" => pure fallback + Just text => pure text + + +decimal_digit : Char → Bool +decimal_digit value = value >= '0' && value <= '9' + +decimal_digits : List Char → Bool +decimal_digits [] = True +decimal_digits (value :: rest) = + decimal_digit value && decimal_digits rest + +valid_job_id : String → Bool +valid_job_id text = + case unpack text of + [] => + False + values => + decimal_digits values + + +cleanup_file : String → IO () +cleanup_file path = do + outcome ← removeFile path + case outcome of + Left _ => pure () + Right () => pure () + +cleanup_files : String → String → IO () +cleanup_files body_path metadata_path = do + cleanup_file body_path + cleanup_file metadata_path + + +edu_icu_get : String → String → IO (Either String String) +edu_icu_get api_key url = + case parse_url url of + Left problem => + pure (Left ("invalid Handshake EDU URL: " ++ problem)) + Right target_url => + case make_credential_header "x-api-key" api_key of + Left _ => + pure (Left "HANDSHAKE_EDU_API_KEY contains an invalid HTTP header character") + Right key_header => do + pid ← getPID + temp_root ← environment_value_or "TMPDIR" "/tmp" + let prefix = temp_root ++ "/idric-handshake-" ++ show pid + let body_path = prefix ++ ".json" + let metadata_path = prefix ++ ".meta" + fetch_result ← + fetch_to_files_with_headers + (get target_url) + [key_header] + body_path + metadata_path + case fetch_result of + Left problem => do + cleanup_files body_path metadata_path + pure (Left problem) + Right transport_code => do + body_result ← readFile body_path + cleanup_files body_path metadata_path + if transport_code /= 0 + then pure + (Left + ("ICU request failed: transport_code=" ++ show transport_code)) + else case body_result of + Left _ => pure (Left "could not read Handshake EDU response body") + Right body => pure (Right body) + + +endpoint_url_from : String → endpoint → String +endpoint_url_from base jobs_endpoint = base ++ "/jobs?page_size=100" +endpoint_url_from base roles_endpoint = base ++ "/job_role_groups?page_size=100" + +endpoint_url : endpoint → String +endpoint_url = endpoint_url_from edu_base + +live_endpoint_url : endpoint → IO String +live_endpoint_url which = do + base ← environment_value_or "HANDSHAKE_EDU_BASE_URL" edu_base + pure (endpoint_url_from base which) + + +parse_command : List String → Either String command +parse_command ["url", "jobs"] = Right (show_url jobs_endpoint) +parse_command ["url", "roles"] = Right (show_url roles_endpoint) +parse_command ["public", job_id] = Right (public_job job_id) +parse_command ["fixture-jobs", path] = Right (fixture_jobs path) +parse_command ["fixture-roles", path] = Right (fixture_roles path) +parse_command ["jobs"] = Right fetch_jobs +parse_command ["roles"] = Right fetch_roles +parse_command _ = Left usage + + +maybe_field : Maybe String → String +maybe_field Nothing = "" +maybe_field (Just text) = clean_field text + +render_job : Job → String +render_job job = + show job.id ++ "\t" ++ + clean_field job.title ++ "\t" ++ + show job.employer_id ++ "\t" ++ + maybe_field job.employment_type ++ "\t" ++ + maybe_field job.job_type ++ "\t" ++ + maybe_field job.pay_rate ++ "\t" ++ + show job.remote ++ "\t" ++ + show job.on_site ++ "\t" ++ + show job.hybrid ++ "\t" ++ + maybe_field job.salary_type ++ "\t" ++ + maybe_field job.external_url ++ "\t" ++ + clean_field job.updated_at + +render_role : Job_Role_Group → String +render_role role = + show role.job_id ++ "\t" ++ + show role.job_role_group_id ++ "\t" ++ + clean_field role.job_role_group_name ++ "\t" ++ + clean_field role.updated_at + +print_jobs_rows : List Job → IO () +print_jobs_rows [] = pure () +print_jobs_rows (job :: rest) = do + putStrLn (render_job job) + print_jobs_rows rest + +print_role_rows : List Job_Role_Group → IO () +print_role_rows [] = pure () +print_role_rows (role :: rest) = do + putStrLn (render_role role) + print_role_rows rest + +print_jobs_response : String → IO () +print_jobs_response body = + case decode_jobs_response body of + Left problem => do + putStrLn ("handshake: " ++ problem) + exitFailure + Right jobs => do + putStrLn jobs_header + print_jobs_rows jobs + +print_roles_response : String → IO () +print_roles_response body = + case decode_roles_response body of + Left problem => do + putStrLn ("handshake: " ++ problem) + exitFailure + Right roles => do + putStrLn roles_header + print_role_rows roles + +read_and_print : (String → IO ()) → String → IO () +read_and_print printer path = do + outcome ← read_fixture path + case outcome of + Left problem => do + putStrLn ("handshake: " ++ problem) + exitFailure + Right body => + printer body + +fetch_and_print : (String → IO ()) → String → IO () +fetch_and_print printer url = do + maybe_key ← environment_value "HANDSHAKE_EDU_API_KEY" + case maybe_key of + Nothing => do + putStrLn "handshake: missing HANDSHAKE_EDU_API_KEY" + exitFailure + Just "" => do + putStrLn "handshake: HANDSHAKE_EDU_API_KEY is empty" + exitFailure + Just api_key => do + outcome ← edu_icu_get api_key url + case outcome of + Left problem => do + putStrLn ("handshake: " ++ problem) + exitFailure + Right body => + printer body + + +run : command → IO () +run (show_url which) = putStrLn (endpoint_url which) + +run (public_job job_id) = + if valid_job_id job_id + then putStrLn (public_job_base ++ job_id) + else do + putStrLn "handshake: JOB_ID must contain decimal digits only" + exitFailure + +run (fixture_jobs path) = read_and_print print_jobs_response path +run (fixture_roles path) = read_and_print print_roles_response path +run fetch_jobs = do + url ← live_endpoint_url jobs_endpoint + fetch_and_print print_jobs_response url +run fetch_roles = do + url ← live_endpoint_url roles_endpoint + fetch_and_print print_roles_response url + + +main : IO () +main = do + arguments ← getArgs + case arguments of + _ :: rest => + case parse_command rest of + Left problem => do + putStrLn problem + exitFailure + Right value => + run value + [] => do + putStrLn usage + exitFailure