Initial commit

This commit is contained in:
2026-07-09 14:41:31 -04:00
commit df65dc1dee
7 changed files with 582 additions and 0 deletions
+23
View File
@@ -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=
+5
View File
@@ -0,0 +1,5 @@
.env
.venv/
__pycache__/
*.pyc
.DS_Store
+64
View File
@@ -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.
+310
View File
@@ -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` 7000079999) 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 <base64(DOMAIN\username:password)>
```
- 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 <JWT access token>
```
- 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: <your 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 <base64 override>
```
---
## 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 `<span>` 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.
+96
View File
@@ -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
+82
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
requests>=2.31
python-dotenv>=1.0