From df65dc1deee0473f7e1e775eca326635fbd5746a Mon Sep 17 00:00:00 2001 From: Daniel Messer Date: Thu, 9 Jul 2026 14:41:31 -0400 Subject: [PATCH] Initial commit --- .env.example | 23 +++ .gitignore | 5 + README.md | 64 +++++++ docs/polaris-papi-reference.md | 310 +++++++++++++++++++++++++++++++++ polaris_client.py | 96 ++++++++++ post_notices.py | 82 +++++++++ requirements.txt | 2 + 7 files changed, 582 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/polaris-papi-reference.md create mode 100644 polaris_client.py create mode 100644 post_notices.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cec7232 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Copy this file to .env and fill in your library's values. +# .env is gitignored -- never commit real credentials. + +# Hostname only, no scheme/path, e.g. osceola.librarycatalog.info +POLARIS_SERVER= + +# Customer site domain segment in the API URI (default is usually "polaris") +POLARIS_SITE_DOMAIN=polaris + +# Logged-on organization ID and workstation ID (integers, from Polaris) +POLARIS_ORGANIZATION_ID= +POLARIS_WORKSTATION_ID= + +# ISO 639-2 language code used in the API URI +POLARIS_LANGUAGE=eng + +# Product ID for the URI. Notices=7 is documented as correct for this endpoint; +# if that 400s, try 19 (PolarisApplicationServices) instead. +POLARIS_PRODUCT_ID=7 + +# Staff login. Username MUST include the domain, e.g. MYLIB\Aladdin +POLARIS_USERNAME= +POLARIS_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02cc2c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +.venv/ +__pycache__/ +*.pyc +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..db10102 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Remote Poster + +Posts notices to a Polaris ILS database via the Polaris Leap (PAPI) API. + +## Setup + +1. (Recommended) create and activate a virtual environment, then install dependencies: + ``` + python3 -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` + On macOS with Homebrew Python you may get an "externally managed environment" error if you + skip the venv and `pip install` directly — the venv avoids that. +2. Copy `.env.example` to `.env` and fill in your server, org/workstation IDs, and staff login. + `.env` is gitignored, so real credentials never get committed. + + - `POLARIS_USERNAME` **must** include the domain, e.g. `MYLIB\Aladdin`. + - `POLARIS_ORGANIZATION_ID` should match the `OrganizationID` your staff login actually + belongs to (visible in the `PolarisUser` object returned on successful auth) — it's your + *logged-on* org context, not the list of orgs you're posting notices to. + - `POLARIS_PRODUCT_ID` defaults to `7` (Notices). If calls fail, `19` + (PolarisApplicationServices) is the confirmed working alternative — see Notes below. + +## Usage + +``` +python post_notices.py --notice-type 2 --org-ids 3,4,5,6,7,8 +``` + +- `--notice-type`: the Notification Type ID to post (from your Polaris system tables). +- `--org-ids`: comma-separated Organization IDs to post notices *for* (the branches whose + notices go out) — separate from `POLARIS_ORGANIZATION_ID` in `.env`, which is your login context. +- `--delivery-option`: optional, defaults to `1` (Mail) — currently the only value Polaris accepts. + +On success it prints a confirmation. On failure (bad credentials, invalid IDs, etc.) it prints +the error from Polaris and exits non-zero. + +Every run sends real notices to real patrons — there's no dry-run mode. Double-check +`--notice-type` and `--org-ids` before running. + +## Notes on the API (things the published docs get wrong or omit) + +While wiring this up against Osceola's live server, two things in the reference doc +(`docs/polaris-papi-reference.md`) turned out to be incomplete or misleading: + +1. **The staff authentication URL is shorter than every other endpoint's.** Most PAPI routes + follow `/api/{version}/{language}/{productId}/{siteDomain}/{organizationId}/{workstationId}/...`, + but `authentication/staffuser` only uses `/api/{version}/{language}/{productId}/authentication/staffuser` + — no `siteDomain`, `organizationId`, or `workstationId`. This makes sense in hindsight (those + three describe a *logged-on* session, and you're not logged on yet when authenticating), but + it isn't spelled out anywhere in the rendered help page — it's only visible as a `title="..."` + tooltip attribute in the raw HTML of + `polaris.applicationservices/help/authentication/post_staffuser`, which a normal page read + (rendered text, or an AI summary of the page) won't surface. `polaris_client.py` builds this + URL separately from every other call (`self.auth_url` vs `self.base_url`). +2. **`productId`**: `19` (PolarisApplicationServices) is confirmed working against the live + server; `7` (Notices) was the doc's best guess but untested — worth trying if `19` ever stops + working, but don't be surprised if it 400s/404s. + +If you hit a `404` with an empty response body on any call, that almost always means the URL +path itself doesn't match a route (wrong segment count, wrong product ID, etc.) rather than a +credentials problem — Polaris's own application-level errors come back as JSON with a +`Message`/`ErrorCode`, not an empty body. diff --git a/docs/polaris-papi-reference.md b/docs/polaris-papi-reference.md new file mode 100644 index 0000000..c300a72 --- /dev/null +++ b/docs/polaris-papi-reference.md @@ -0,0 +1,310 @@ +# Polaris.ApplicationServices (PAPI) API Reference + +Source: Osceola Library System's Polaris.ApplicationServices help docs +(`https://osceola.librarycatalog.info/polaris.applicationservices/help/...`) +Compiled for internal reference to support scripting against the Notices endpoint. + +--- + +## 1. The API — Basics + +- Base API version: `v1` (only supported version) +- Status check URL: `https://[your server name]/Polaris.ApplicationServices/api` + Returns a JSON blob with `ServiceName`, `Version`, `Major`, `Minor`, `Build`, `Revision`. + +### URI structure + +All URIs (other than API status and staff authentication) include these route segments, in order: + +``` +/api/{version}/{language}/{productId}/{siteDomain}/{organizationId}/{workstationId}/... +``` + +| Segment | Type | Required | Notes | +|---|---|---|---| +| `version` | string | Yes | `"v1"` | +| `language` | string | Yes | ISO 639-2 code — `eng`, `spa`, `fre`, `chi`, `ara`, `fas`, `hat`, `haw`, `kor`, `rus`, `vie`, etc. | +| `productId` | integer | Yes | See product ID table below | +| `siteDomain` | string | Yes | Customer site domain (default is `"polaris"`) | +| `organizationId` | integer | Yes | Logged-on organization ID | +| `workstationId` | integer | Yes | Logged-on workstation ID | + +Example full URI: +``` +/api/v1/eng/19/polaris/1/1/patrons/{id}/basicdata +``` +Docs often show this in shorthand as `/api/.../patrons/{id}/basicdata`. + +### Product IDs + +| Value | Product | +|---|---| +| 0 | All | +| 1 | PowerPAC | +| 2 | ChildrensPAC | +| 3 | ActivePAC | +| 4 | ExpressCheck | +| 5 | InboundTelephony | +| 6 | OutboundTelephony | +| 7 | Notices | +| 8 | ILL | +| 9 | PolarisFusion | +| 10 | AcquisitionsExchange | +| 11 | MobilePAC | +| 12 | SIPService | +| 13 | NCIPService | +| 14 | ERMSPortal | +| 15 | Receipts | +| 16 | ContentXChange | +| 17 | PolarisImport | +| 18 | PolarisAPIConsumerService | +| 19 | PolarisApplicationServices | +| 20 | StaffWebClient | +| 21 | StaffClient | +| 22 | PolarisDatabase | +| 23 | SystemOutput | +| 24 | Vega | + +> Since we're calling the Notices endpoint, `productId = 7` (Notices) is likely the semantically "correct" one to use, though `19` (PolarisApplicationServices) may also work depending on how the server is configured — worth confirming against a working example if you have one. + +### HTTP response codes + +| Code | Status | Notes | +|---|---|---| +| 200 | OK | Success | +| 201 | Created | Returns created object | +| 204 | No Content | Successful deletion | +| 400 | Bad Request | Validation errors, malformed params, etc. Body includes `Message`/`MessageDetail`, sometimes `ErrorCode`. | +| 401 | Unauthorized | `{"ErrorCode":10001,"Message":"User authentication failed."}` | +| 403 | Forbidden | Permission not granted; response includes override info (see Security section) | +| 404 | Not Found | Either "no matching route" or "record not found" (e.g. `ErrorCode 60027`) | +| 405 | Method Not Allowed | Wrong HTTP verb for the resource | +| 409 | Conflict | Object lock (`ErrorCode` 70000–79999) or secured record (`ErrorCode` 10004) | +| 411 | Length Required | Missing `Content-Length` | +| 422 | Unprocessable Entity | Body isn't valid JSON | +| 500+ | Server Error | Unhandled exception | + +--- + +## 2. Security and Authentication + +Three supported auth mechanisms: + +1. **HTTP Basic Authentication + Access Token/Secret** (the standard flow) +2. **ASP.NET Forms Authentication** (cookie-based, browser/JS clients on same domain) +3. **Bearer Token via OAuth2** (OpenID Connect + PKCE) + +Plus: **Proxy-Authorization** for privilege overrides. + +### 2.1 Basic Auth → Access Token/Secret (primary flow for scripting) + +Step 1 — Call the staff auth endpoint with HTTP Basic Auth: + +``` +POST /api/.../authentication/staffuser +Authorization: Basic +``` + +- Username **must include the domain**, e.g. `MYLIB\Aladdin`. +- Combine as `"DOMAIN\username:password"`, then base64-encode the whole string. +- No request body. + +Step 2 — Response contains an `AccessToken` and `AccessSecret`: + +```json +{ + "SiteDomain": "polaris", + "UserDomain": "iii.com", + "AccessToken": "NXmeihFv2kq6meg3EdYoenv2VagJrPHs", + "AccessSecret": "odXCBZuhXBkbwSo4", + "AuthExpDate": "2013-03-26T10:41:11.103", + "PolarisUser": { + "PolarisUserID": 923, + "OrganizationID": 3, + "Name": "Young", + "BranchID": null, + "Enabled": true, + "CreatorID": 895, + "ModifierID": null, + "CreationDate": "2011-02-16T20:28:16.177", + "ModificationDate": null + }, + "ERMSNetworkAddress": "young-lt2.polarislibrary.com", + "DataSource": "RD-POLARIS" +} +``` + +Step 3 — Use the token/secret on all subsequent calls, in a custom `PAS` auth scheme (not base64-encoded): + +``` +Authorization: PAS {siteDomain}:{AccessToken}:{AccessSecret} +``` + +Example: +``` +Authorization: PAS polaris:NXmeihFv2kq6meg3EdYoenv2VagJrPHs:odXCBZuhXBkbwSo4 +``` + +### 2.2 Bearer Token (OAuth2) variant of staff auth + +``` +POST /api/.../authentication/staffuser/oauth +Authorization: Bearer +``` + +- The JWT's `upn` claim must be `username@domain` format and match a Polaris staff user. +- Response shape is identical to the Basic Auth flow (still returns `AccessToken`/`AccessSecret` to use for follow-on calls). + +### 2.3 API Key Authentication (alternative, no token/secret round-trip) + +``` +POST /api/.../authentication/staffuser +x-api-key: +``` + +- No `Authorization` header — the key goes in the `x-api-key` header. +- Response does **not** include `AccessToken`/`AccessSecret` (they're blank strings) — the API key itself is presumably reused directly on subsequent calls (docs don't show that call shape explicitly, so confirm against a working example). + +```json +{ + "SiteDomain": "", + "UserDomain": "", + "AccessToken": "", + "AccessSecret": "", + "AuthExpDate": null, + "PolarisUser": { + "PolarisUserID": 1, + "OrganizationID": 5, + "Name": "PolarisExec", + "BranchID": null, + "Enabled": true, + "CreatorID": 1, + "ModifierID": 1, + "CreationDate": null, + "ModificationDate": "2020-12-11T10:28:20.69-05:00" + }, + "ERMSNetworkAddress": "clvitn-pc197yjl.polarislibrary.com", + "DataSource": "CLVITN-PC197YJL", + "SiteCode": "MIS/" +} +``` + +### 2.4 Privilege overrides (403 handling) + +If a call returns `403 Forbidden` because the authenticated user lacks a permission, you can supply override credentials via: + +- `Proxy-Authorization` header, **or** +- `Polaris-Override-Authorization` custom header (supports multiple overrides per call) + +Format (before base64 encoding): +``` +username:password:permissionid:permissionownerid +``` + +Example: +``` +Proxy-Authorization: Basic cG9sYXJpc2V4ZWNAZG9tYWluOnBhc3N3b3JkOjgzOjEwMw== +``` + +Multiple overrides via repeated headers or a comma-delimited list of `Basic ...` values in one header. + +**Note:** the primary `Authorization` header (token/secret) is still required alongside any override header. + +--- + +## 3. HTTP Headers Cheat Sheet + +**GET requests** — set `Accept`: +``` +Accept: application/json +``` +(also valid: `text/json`, `image/jpg` for binary/image responses) + +**PATCH / POST / PUT requests** — set `Content-Type`: +``` +Content-Type: application/json +``` + +**Auth headers**: +``` +Authorization: PAS {siteDomain}:{AccessToken}:{AccessSecret} +Proxy-Authorization: Basic +``` + +--- + +## 4. Notices — POST (the endpoint we care about) + +### Post notices to the database + +``` +POST /api/.../notices/post?noticeType={notificationtypeid}&deliveryOption={deliveryoptionid} +``` + +#### Query parameters + +| Name | Type | Required | Description | +|---|---|---|---| +| `noticeType` | integer | Yes | ID of the Notification Type | +| `deliveryOption` | integer | Yes | ID of the Delivery Option. **Only valid value is Mail (`1`).** | + +#### Request body + +A JSON string containing a comma-separated list of Organization IDs: + +```json +"3,5,7" +``` + +#### Response + +Returns a plain boolean. + +- Success: `true` +- Failure: `false` + +#### HTTP response codes + +| Code | Meaning | +|---|---| +| 200 | OK. Success | +| 400 | Failure — bad request, invalid `noticeType`, or invalid `deliveryOption` | + +#### Notes / open questions for implementation + +- The docs don't enumerate valid `noticeType` (Notification Type) IDs on this page — that's presumably in Polaris's system tables or the "System Administration" section elsewhere in the docs (e.g. Parameters and Profiles, or a lookup table). Worth checking the Polaris database (`Polaris.SystemNotifications` or similar) or asking the vendor if you need the full list. +- `deliveryOption` is documented as only accepting `1` (Mail) right now, despite the API allowing an integer — so don't expect e.g. email/SMS options to work here even if other DeliveryOptions endpoints list more. +- The response being a bare `true`/`false` (not an object) means your client code should parse the raw JSON boolean rather than expecting a structured object — easy to trip up if you assume every endpoint returns a DTO. +- `productId` for this call is most likely `7` (Notices) per the product ID table — worth confirming with a test call. + +--- + +## 5. Corrections confirmed against a live server (Osceola) + +While building `post_notices.py` against Osceola's live Polaris.ApplicationServices instance, +two things in this doc turned out to be wrong or incomplete: + +- **Staff authentication URL is shorter than section 1 implies.** The "all URIs other than API + status and staff authentication" caveat in section 1 undersells how different the auth route + is. It is **not** `/api/{version}/{language}/{productId}/{siteDomain}/{organizationId}/{workstationId}/authentication/staffuser` + — it's just `/api/{version}/{language}/{productId}/authentication/staffuser`, with no + `siteDomain`, `organizationId`, or `workstationId` segments at all. Calling the full-length + URL returns a `404` with an **empty body** (a routing miss, not an app-level error — Polaris's + real error responses come back as JSON with `Message`/`ErrorCode`). This exact URL shape isn't + visible anywhere in the rendered help page text; it only shows up as a `title="{version}/{language}/{productId}"` + attribute on the `` wrapping the "..." in `POST /api/.../authentication/staffuser`, i.e. + you have to view-source the page (`polaris.applicationservices/help/authentication/post_staffuser`) + to find it — a plain fetch or rendered read of the page won't surface it. +- **`productId = 19`** (PolarisApplicationServices) is confirmed working end-to-end (auth + + `notices/post`) against Osceola's server. `productId = 7` (Notices) — this doc's original best + guess — was not confirmed; it may still be correct, but wasn't tested. + +--- + +## 6. Gaps in this doc (pages that returned no additional content) + +Two pages in the doc set — **Common Response Structures** and **Collections** (both under "Introduction") — returned only their page heading with no body content when fetched. This might mean: +- The content is loaded client-side via JavaScript that a simple page fetch won't execute, or +- Those particular pages are thin/placeholder pages in this Polaris version. + +If you need the actual content of those two pages (e.g. for handling paginated list responses in the `notices` or other endpoints), it may be worth viewing them directly in a browser to check whether there's dynamically-loaded content, since a plain HTTP fetch didn't surface anything under those headings. diff --git a/polaris_client.py b/polaris_client.py new file mode 100644 index 0000000..5fa1891 --- /dev/null +++ b/polaris_client.py @@ -0,0 +1,96 @@ +"""Minimal client for the Polaris Leap (PAPI) Notices endpoint.""" + +import json + +import requests + + +class PolarisAuthError(Exception): + pass + + +class PolarisRequestError(Exception): + pass + + +class PolarisClient: + def __init__( + self, + server, + site_domain, + organization_id, + workstation_id, + language="eng", + product_id=7, + ): + self.auth_url = f"https://{server}/Polaris.ApplicationServices/api/v1/{language}/{product_id}/authentication/staffuser" + self.base_url = f"https://{server}/Polaris.ApplicationServices/api/v1/{language}/{product_id}/{site_domain}/{organization_id}/{workstation_id}" + self.site_domain = site_domain + self.access_token = None + self.access_secret = None + + def authenticate(self, username, password): + """Exchange a staff username/password for an AccessToken/AccessSecret. + + Username must include the domain, e.g. MYLIB\\Aladdin. + + Note: per the live API help page (view-source, not the rendered page -- + it's a tooltip on the "..." in the route), the staff-authentication route + only includes {version}/{language}/{productId} -- no siteDomain, + organizationId, or workstationId, since those describe a *logged-on* + session and you're not logged on yet at this point. + """ + response = requests.post( + self.auth_url, + auth=(username, password), + headers={"Accept": "application/json"}, + ) + if response.status_code == 401: + raise PolarisAuthError("Authentication failed: check username/password.") + if not response.ok: + raise PolarisAuthError( + f"Authentication failed ({response.status_code}): {response.text}" + ) + + data = response.json() + self.access_token = data["AccessToken"] + self.access_secret = data["AccessSecret"] + return data + + def _auth_header(self): + if not self.access_token or not self.access_secret: + raise PolarisAuthError("Not authenticated yet -- call authenticate() first.") + return f"PAS {self.site_domain}:{self.access_token}:{self.access_secret}" + + def post_notices(self, notice_type, organization_ids, delivery_option=1): + """POST notices for the given notice type to the given org IDs. + + organization_ids: an iterable/list of integer org IDs. + delivery_option: only 1 (Mail) is currently supported by the API. + + Returns True/False as reported by Polaris. + """ + org_ids_csv = ",".join(str(oid) for oid in organization_ids) + + response = requests.post( + f"{self.base_url}/notices/post", + params={"noticeType": notice_type, "deliveryOption": delivery_option}, + headers={ + "Authorization": self._auth_header(), + "Content-Type": "application/json", + "Accept": "application/json", + }, + data=json.dumps(org_ids_csv), + ) + if not response.ok: + raise PolarisRequestError( + f"Notice post failed ({response.status_code}): {response.text}" + ) + + result = response.json() + if result is not True: + raise PolarisRequestError( + f"Polaris reported failure (returned {result!r}) for noticeType={notice_type}, " + f"organizationIds={org_ids_csv}" + ) + return result diff --git a/post_notices.py b/post_notices.py new file mode 100644 index 0000000..c08917d --- /dev/null +++ b/post_notices.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""CLI for posting Polaris notices via the Leap (PAPI) API. + +Usage: + python post_notices.py --notice-type 8 --org-ids 3,5,7 +""" + +import argparse +import os +import sys + +from dotenv import load_dotenv + +from polaris_client import PolarisAuthError, PolarisClient, PolarisRequestError + + +def parse_args(): + parser = argparse.ArgumentParser(description="Post notices to Polaris via the Leap API.") + parser.add_argument( + "--notice-type", + type=int, + required=True, + help="Notification Type ID to post (see your Polaris system tables).", + ) + parser.add_argument( + "--org-ids", + required=True, + help="Comma-separated list of Organization IDs, e.g. 3,5,7", + ) + parser.add_argument( + "--delivery-option", + type=int, + default=1, + help="Delivery Option ID. Only 1 (Mail) is currently supported by Polaris.", + ) + return parser.parse_args() + + +def main(): + load_dotenv() + args = parse_args() + + required_env = [ + "POLARIS_SERVER", + "POLARIS_SITE_DOMAIN", + "POLARIS_ORGANIZATION_ID", + "POLARIS_WORKSTATION_ID", + "POLARIS_USERNAME", + "POLARIS_PASSWORD", + ] + missing = [name for name in required_env if not os.environ.get(name)] + if missing: + print(f"Missing required .env values: {', '.join(missing)}", file=sys.stderr) + print("Copy .env.example to .env and fill it in.", file=sys.stderr) + sys.exit(1) + + org_ids = [int(oid.strip()) for oid in args.org_ids.split(",") if oid.strip()] + + client = PolarisClient( + server=os.environ["POLARIS_SERVER"], + site_domain=os.environ["POLARIS_SITE_DOMAIN"], + organization_id=os.environ["POLARIS_ORGANIZATION_ID"], + workstation_id=os.environ["POLARIS_WORKSTATION_ID"], + language=os.environ.get("POLARIS_LANGUAGE", "eng"), + product_id=os.environ.get("POLARIS_PRODUCT_ID", "7"), + ) + + try: + client.authenticate(os.environ["POLARIS_USERNAME"], os.environ["POLARIS_PASSWORD"]) + client.post_notices(args.notice_type, org_ids, args.delivery_option) + except (PolarisAuthError, PolarisRequestError) as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + print( + f"Notices posted successfully: noticeType={args.notice_type}, " + f"orgIds={org_ids}, deliveryOption={args.delivery_option}" + ) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5160da3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31 +python-dotenv>=1.0