diff --git a/.github/workflows/handshake-idric.yml b/.github/workflows/handshake-idric.yml new file mode 100644 index 0000000..26c5c9f --- /dev/null +++ b/.github/workflows/handshake-idric.yml @@ -0,0 +1,43 @@ +name: Handshake Idric checkpoint + +on: + pull_request: + paths: + - '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: Install host build tools + run: | + sudo apt-get update + sudo apt-get install -y build-essential curl + + - name: Bootstrap Idric + run: .tools/Idric/_/edric bootstrap + + - name: Expose pinned Chez + run: echo "$GITHUB_WORKSPACE/.tools/Idric/_/.tools/bin" >> "$GITHUB_PATH" + + - 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 + run: bash checkpoints/handshake/check 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..5180f1c 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. @@ -27,6 +28,6 @@ Where these clients need networking, ICU/Idric-Net remains the intended transpor ## 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's offline path is implemented and exercised against current Idriç; only its live EDU request remains fail-closed on ICU credential-sensitive `x-api-key` redirect support (ICU #19). 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..defd06a --- /dev/null +++ b/checkpoints/handshake/README.md @@ -0,0 +1,135 @@ +# 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`, perform one ICU/Idric-Net GET with the required `x-api-key` header, decode the response, and emit the same TSV shape as the fixtures. + +The live transport call currently fails closed at the credential-policy boundary described below. + +This first slice intentionally does not paginate. Handshake documents cursor pagination (`next_cursor` / `page_cursor`); pagination should be the next network-level checkpoint after a single live page works. + +## Surface audit + +The original draft made seven named holes. Six do not require a new shared dependency and are now implemented through current Idriç surfaces or small client-local logic: + +| 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; fixed `HANDSHAKE_EDU_API_KEY` name | +| decimal job-id validation | ordinary character/list operations | nonempty ASCII decimal check | +| ICU GET with `x-api-key` | pending stack has header/capture mechanics; arbitrary credential-header redirect semantics are missing | genuine dependency/API gap, tracked by ICU #19 | + +`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. That wrapper rejects invalid environment-variable names and preserves unset versus empty. Handshake does not need to block on it: this client queries one fixed valid name and current `System.getEnv` already returns `Maybe String`. + +## Genuine remaining transport gap + +The current merged Idric-Net HTTP model already has typed HTTP header values, but its normal renderer supplies a fixed header set. Current merged ICU does not expose the response-body capture path this caller needs. + +The pending ICU stack is closer: + +- ICU #12 adds file-backed raw response capture; +- ICU #13 is stacked on #12 and adds validated caller-supplied request headers plus `fetch_to_files_with_headers`. + +So caller-header construction and response capture are not missing inventions. The remaining blocker is redirect credential semantics. ICU #13 strips `Authorization` and `Cookie` when a redirect changes scheme, host, or port, but preserves other custom headers. Current Idric-Net's header classifier makes the same name-based distinction. + +Handshake authenticates with `x-api-key`. On that pending generic path, `x-api-key` is therefore an ordinary custom header and could be forwarded to a cross-origin redirect target. ICU #19 tracks the needed shared surface: let the caller declare an arbitrary header credential-sensitive, or provide an equivalent policy, so same-origin redirects can retain it while cross-origin redirects remove it. + +Handshake therefore does **not** invent a private transport. `edu_icu_get` returns an explicit `Left` naming ICU #19, and live `jobs` and `roles` fail closed there. It does not substitute curl, Python, browser cookies, or a Handshake-specific socket path. + +## Checkpoint ladder + +1. source parses/checks against current Idriç plus contrib; +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/Idric-Net supports caller-declared credential-sensitive headers on its caller-header + response-capture path (ICU #19); +8. one live `/jobs` page travels through that API with `x-api-key`; +9. one live `/job_role_groups` page travels through the same boundary; +10. cursor pagination is added without changing the one-page decoder contract; +11. 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..ed950f5 --- /dev/null +++ b/checkpoints/handshake/check @@ -0,0 +1,182 @@ +#!/usr/bin/env ysh + +# Idriç Handshake checkpoint runner. +# The offline surfaces are executable; the live network command must fail +# closed at the one documented ICU/Idric-Net dependency boundary. + +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 + +expect_failure_output \ + 'transport/explicit-gap' \ + 'handshake: live EDU GET pending ICU credential-safe x-api-key redirect policy (icu#19)' \ + env HANDSHAKE_EDU_API_KEY=synthetic "$PROGRAM" jobs + +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..7a0b446 --- /dev/null +++ b/checkpoints/handshake/idric/Handshake.idric @@ -0,0 +1,390 @@ +module Handshake + +import Data.String +import Language.JSON +import System +import System.File + + +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 one fixed, valid environment-variable name. +environment_value : String → IO (Maybe String) +environment_value name = getEnv name + + +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 + + +-- Genuine remaining dependency boundary: +-- merged ICU/Idric-Net does not yet expose the complete caller-header + body +-- capture path. Pending ICU #13 stacked on #12 has those mechanics, but its +-- cross-origin redirect policy strips only Authorization/Cookie credentials; +-- x-api-key remains an ordinary header. Fail closed until callers can mark an +-- arbitrary header credential-sensitive (ICU #19). +edu_icu_get : String → String → IO (Either String String) +edu_icu_get api_key url = + pure + (Left + "live EDU GET pending ICU credential-safe x-api-key redirect policy (icu#19)") + + +endpoint_url : endpoint → String +endpoint_url jobs_endpoint = edu_base ++ "/jobs?page_size=100" +endpoint_url roles_endpoint = edu_base ++ "/job_role_groups?page_size=100" + + +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 = fetch_and_print print_jobs_response (endpoint_url jobs_endpoint) +run fetch_roles = fetch_and_print print_roles_response (endpoint_url roles_endpoint) + + +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