first commit

This commit is contained in:
2026-06-04 12:09:45 -03:00
commit ac20adb79d
33 changed files with 250575 additions and 0 deletions
@@ -0,0 +1,9 @@
{
"version": 1,
"transcripts": {
"/Users/leonardotoniolo/.cursor/projects/Users-leonardotoniolo-Developer-www-RivusLab-Clients-ARCO-middleware/agent-transcripts/91a87ea5-b164-4e87-821c-88d955d8fbe5/91a87ea5-b164-4e87-821c-88d955d8fbe5.jsonl": {
"mtimeMs": 1780410511000,
"processedAt": "2026-06-02T14:30:00.000Z"
}
}
}
+4
View File
@@ -0,0 +1,4 @@
CLARIZEN_BASE_URL=https://api.clarizen.com
CLARIZEN_API_KEY=your-api-key-here
CLARIZEN_USERNAME=your-user@company.com
CLARIZEN_PASSWORD=your-password
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log
+28
View File
@@ -0,0 +1,28 @@
# AGENTS
## Learned User Preferences
- Wants a TypeScript Clarizen client with SDK-style API: `new Clarizen(url, apiToken)` and namespaced methods (e.g. `clarizen.data.entityQuery(...)`).
- Wants session authentication via `authentication.login(user, pass)` that returns an API session for subsequent calls, not only static API tokens.
- May describe requirements in Portuguese as well as English.
- Wants full Clarizen API typings from `https://api.clarizen.com/V2.0/services/types/`, including nested types (`Condition`, `Expression`, `Operator`, etc.) — not generic `JsonObject` shortcuts for documented fields.
- When adding types, follow nested type links from the API docs and model every referenced type, not only top-level operation params.
- When extending the SDK, cross-check the Planview Adaptive Work REST API Guide (V2) with live `api.clarizen.com` docs; apply guide content only when it adds value beyond the live schema pages.
- Wants automatic retry (3 attempts) on all Clarizen HTTP calls except when the API returns `SessionTimeout`.
## Learned Workspace Facts
- ARCO monorepo (RivusLab): `clarizen-lib/` is the Clarizen SDK; sibling apps consume `@arco/clarizen-lib` via npm workspaces or `file:../clarizen-lib`.
- TypeScript package `@arco/clarizen-lib`: Clarizen / Planview Adaptive Work REST API V2.0 client.
- Clarizen V2.0 REST on `api.clarizen.com` is the Planview Adaptive Work API (Clarizen host, same V2.0 surface).
- Target API surface: `https://api.clarizen.com/V2.0/services/`; operations grouped in namespaces (`data`, `authentication`, `metadata`, etc.).
- Type schemas at `https://api.clarizen.com/V2.0/services/types/` drive request/response TypeScript types (e.g. EntityQuery `where` is `Condition`, sort is `orders` not `orderBy`).
- Secondary reference: Planview Adaptive Work REST API Guide V2 at https://success.planview.com/Planview_AdaptiveWork/API/REST_API_Guide_Version_2
- HTTP auth: `Authorization: ApiKey {token}` for API keys; `Authorization: Session {sessionId}` after login.
- All SDK HTTP traffic goes through `HttpClient.request`; retries are centralized there (3 attempts, no retry on `SessionTimeout`).
- Multi-tenant session login: `loginWithDiscovery` calls `authentication.getServerDefinition`, sets `baseUrl` from `serverLocation`, then `authentication.login`.
- `Clarizen.createForLogin(baseUrl?)` builds a client without prior auth for the discovery/login flow.
- Entity CRUD is exposed as `clarizen.objects` (REST under `/V2.0/services/data/objects/{typeName}/...`).
- API TypeScript types are generated from crawled schema (`npm run scrape:types``src/generated/clarizen-schema.json`, `npm run generate:types``src/clarizen-api-types.ts`).
- Planview guide notes live in `docs/references/planview-rest-api-v2.md`.
- Org metadata catalog: `listEntities` + `describeMetadata` with `flags: ["fields", "relations"]`; `clarizen.metadata.fetchCatalog()` at runtime; `npm run scrape:metadata``src/generated/metadata-catalog.json` + `metadata-types.ts`. Filter out names containing `C_` or `R_` (custom fields/relations/entities).
+185
View File
@@ -0,0 +1,185 @@
# @arco/clarizen-lib
TypeScript client for the [Planview Adaptive Work REST API V2.0](https://api.clarizen.com/V2.0/services/).
Human-readable guide: [REST API Guide Version 2](https://success.planview.com/Planview_AdaptiveWork/API/REST_API_Guide_Version_2) — see also [docs/references/planview-rest-api-v2.md](docs/references/planview-rest-api-v2.md).
## Install (library)
```bash
cd clarizen-lib
npm install
npm run build
```
## Use from another package in this repo
From the ARCO root (npm workspaces):
```bash
cd /path/to/ARCO
npm install
```
In your app `package.json`:
```json
{
"dependencies": {
"@arco/clarizen-lib": "workspace:*"
}
}
```
Without workspaces (sibling folder):
```json
{
"dependencies": {
"@arco/clarizen-lib": "file:../clarizen-lib"
}
}
```
Then build the library once (`npm run build` inside `clarizen-lib`) and import:
```typescript
import { Clarizen } from "@arco/clarizen-lib";
```
## Usage
### API key (recommended)
```typescript
import { Clarizen } from "@arco/clarizen-lib";
const clarizen = new Clarizen("https://api.clarizen.com", process.env.CLARIZEN_API_KEY);
const users = await clarizen.data.entityQuery({
typeName: "User",
fields: ["Name", "Email"],
paging: { limit: 10, from: 0 },
});
```
Or with options:
```typescript
const clarizen = new Clarizen({
baseUrl: "https://api.clarizen.com",
apiToken: process.env.CLARIZEN_API_KEY,
});
```
### Session (username + password)
```typescript
const clarizen = Clarizen.createForLogin("https://api.clarizen.com");
const { sessionId } = await clarizen.loginWithDiscovery({
userName: "user@company.com",
password: "secret",
});
```
### Entity CRUD
```typescript
const { id } = await clarizen.objects.create("Issue", { title: "Issue 1" });
const issue = await clarizen.objects.get("Issue", id, {
fields: ["CreatedOn", "CreatedBy.Name"],
});
```
Or construct directly with an existing session:
```typescript
const clarizen = new Clarizen({
baseUrl: "https://api.clarizen.com",
sessionId: "abc123_28849473",
});
```
## Services
All documented operations are exposed under namespaces:
| Namespace | Example |
|-----------|---------|
| `authentication` | `clarizen.authentication.login({ userName, password })` |
| `data` | `clarizen.data.entityQuery({ typeName, fields })` |
| `objects` | `clarizen.objects.get("Issue", id, { fields: [...] })` |
| `metadata` | `clarizen.metadata.fetchCatalog()` |
| `files` | `clarizen.files.getUploadUrl()` |
| `bulk` | `clarizen.bulk.execute({ ... })` |
| `applications` | `clarizen.applications.getApplicationStatus()` |
| `utils` | `clarizen.utils.sendEMail({ ... })` |
Generic call:
```typescript
await clarizen.call("data", "EntityQuery", { typeName: "Project", fields: ["Name"] });
```
## Environment
Copy `.env.example` to `.env`:
```
CLARIZEN_BASE_URL=https://api.clarizen.com
CLARIZEN_API_KEY=...
CLARIZEN_USERNAME=...
CLARIZEN_PASSWORD=...
```
## Examples
```bash
npm run example:api-key
npm run example:session
```
## Regenerate endpoints from API docs
```bash
npm run scrape
```
## Regenerate API types (60 types, recursive crawl)
Crawls all operations and `/V2.0/services/types/*`, then generates `src/clarizen-api-types.ts`:
```bash
npm run scrape:types # writes src/generated/clarizen-schema.json
npm run generate:types # writes src/clarizen-api-types.ts
```
Includes `Condition`, `Expression`, `Query` (11 variants), `EntityQuery`, enums (`Operator`, `ErrorCode`, …), and nested types referenced from each page.
## Org metadata catalog (runtime + codegen)
Fetches your tenants entity types, fields, and relations via `listEntities` + `describeMetadata` (`flags: ["fields", "relations"]`). Custom names containing `C_` or `R_` are excluded.
**Runtime:**
```typescript
const catalog = await clarizen.metadata.fetchCatalog();
const project = catalog.entities.find((e) => e.typeName === "Project");
```
**Regenerate** (requires `.env` credentials):
```bash
npm run scrape:metadata # writes src/generated/metadata-catalog.json + metadata-types.ts
npm run build
```
**Typed entity/field names** (after scrape):
```typescript
import type { EntityTypeName } from "@arco/clarizen-lib";
import { FIELDS_BY_ENTITY, entityFields } from "@arco/clarizen-lib/metadata";
```
Commit `metadata-types.ts` (and optionally `metadata-catalog.json`) when refreshing against your reference tenant.
+38
View File
@@ -0,0 +1,38 @@
# Planview Adaptive Work — REST API V2 (reference)
Official guide: [REST API Guide Version 2](https://success.planview.com/Planview_AdaptiveWork/API/REST_API_Guide_Version_2)
Live operation/type reference: [https://api.clarizen.com/V2.0/services/](https://api.clarizen.com/V2.0/services/) (requires `Accept: text/html` or API auth)
## Auth flow (password)
1. `POST authentication/getServerDefinition``serverLocation` (org datacenter, e.g. `https://api2.clarizen.com/V2.0/services`)
2. `POST authentication/login` on that base URL → `sessionId`
3. Subsequent calls: `Authorization: Session {sessionId}`
API keys use `Authorization: ApiKey {token}` on the orgs API host (often discoverable via `getServerDefinition` with the key).
## Entity CRUD
`/V2.0/services/data/objects/{typeName}` — PUT create
`/V2.0/services/data/objects/{typeName}/{id}` — GET retrieve, POST update, DELETE remove
Field selection on GET: `?fields=CreatedOn,CreatedBy.Name`
## Queries
- `data/entityQuery` — structured `where` (`Condition`), `orders`, `paging` (`from` / `limit`)
- `data/query` — CZQL (`?q=SELECT ...`)
- Conditions may include `_type: 'Compare'` (optional; see guide examples)
## Operations notes
- Prefer `Accept-Encoding: gzip` on requests
- Errors: HTTP 500 body with `errorCode`, `message`, `referenceId`
- Session idle timeout ~10 minutes; max 2 concurrent sessions per user
- Rate limit: ~25 requests/second per organization (bulk inner calls count separately)
## Related Planview articles (same site)
- [API Keys Support](https://success.planview.com/Planview_AdaptiveWork/API/API_Keys_Support) (sibling under API)
- CZQL — linked from the v2 guide “New Features” section
+31
View File
@@ -0,0 +1,31 @@
import "dotenv/config";
import { Clarizen } from "../src/index.js";
const baseUrl = process.env.CLARIZEN_BASE_URL ?? "https://api.clarizen.com";
const apiKey = process.env.CLARIZEN_API_KEY ?? process.env.API_KEY_AW;
if (!apiKey) {
console.error("Set CLARIZEN_API_KEY or API_KEY_AW in .env");
process.exit(1);
}
const clarizen = new Clarizen({ baseUrl, apiToken: apiKey });
const session = await clarizen.authentication.getSessionInfo();
console.log("Session:", session.sessionId, "| User:", session.userId);
const users = await clarizen.data.entityQuery({
typeName: "User",
fields: ["Name"],
paging: { limit: 3, from: 0 },
where: {
leftExpression: { fieldName: "Name" },
operator: "BeginsWith",
rightExpression: { value: "A" },
},
});
console.log(
"Users:",
users.entities.map((u) => u.Name),
);
+19
View File
@@ -0,0 +1,19 @@
import "dotenv/config";
import { Clarizen } from "../src/index.js";
const baseUrl = process.env.CLARIZEN_BASE_URL ?? "https://api.clarizen.com";
const userName = process.env.CLARIZEN_USERNAME;
const password = process.env.CLARIZEN_PASSWORD;
if (!userName || !password) {
console.error("Set CLARIZEN_USERNAME and CLARIZEN_PASSWORD in .env");
process.exit(1);
}
const clarizen = Clarizen.createForLogin(baseUrl);
const login = await clarizen.loginWithDiscovery({ userName, password });
console.log("Logged in. Session:", login.sessionId);
const entities = await clarizen.metadata.listEntities();
console.log("Entity types count:", Array.isArray(entities) ? entities.length : "(see response)");
+583
View File
@@ -0,0 +1,583 @@
{
"name": "@arco/clarizen",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@arco/clarizen",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^22.15.0",
"dotenv": "^16.5.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@arco/clarizen-lib",
"version": "0.1.0",
"description": "TypeScript client for Clarizen / Planview Adaptive Work REST API V2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"README.md"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./metadata": {
"types": "./dist/generated/metadata-types.d.ts",
"import": "./dist/generated/metadata-types.js"
}
},
"scripts": {
"build": "tsc",
"scrape": "tsx scripts/scrape-api.ts",
"scrape:types": "python3 scripts/scrape-types.py",
"generate:types": "python3 scripts/generate-api-types.py",
"scrape:metadata": "tsx scripts/scrape-metadata.ts",
"generate:metadata": "tsx scripts/scrape-metadata.ts",
"example:api-key": "tsx examples/api-key.ts",
"example:session": "tsx examples/session-login.ts"
},
"devDependencies": {
"@types/node": "^22.15.0",
"dotenv": "^16.5.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
},
"engines": {
"node": ">=18"
}
}
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Generate src/clarizen-api-types.ts from clarizen-schema.json."""
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCHEMA = ROOT / "src" / "generated" / "clarizen-schema.json"
OUT = ROOT / "src" / "clarizen-api-types.ts"
ENUMS = {
"Operator": [
"Equal", "NotEqual", "LessThan", "GreaterThan", "LessThanOrEqual",
"GreaterThanOrEqual", "BeginsWith", "EndsWith", "Contains", "DoesNotContain",
"In", "Like", "InRange", "NotInRange", "NotIn",
],
"Order": ["Ascending", "Descending"],
"DatePeriod": [
"ThisWeek", "ThisMonth", "ThisQuarter", "ThisYear",
"NextWeek", "NextMonth", "NextQuarter", "NextYear",
"LastWeek", "LastMonth", "LastQuarter", "LastYear",
],
"FunctionType": ["Count", "Max", "Min", "Sum", "Avg"],
"LicenseType": [
"Full", "Limited", "Email", "None", "TeamMember", "Social", "ExternalCollaborator",
],
"NewsFeedMode": ["Following", "All"],
"TimesheetState": ["UnSubmitted", "PendingApproval", "Approved", "All"],
"ErrorCode": [
"EntityNotFound", "InvalidArgument", "MissingArgument", "InvalidOperation",
"DuplicateKey", "InvalidField", "InvalidType", "FileNotFound", "VirusCheckFailed",
"Unauthorized", "UnsupportedClient", "General", "Internal", "ValidationRuleError",
"LoginFailure", "SessionTimeout", "Redirect", "InvalidQuery",
"ExecutionThresholdExceeded", "TooManyRequests",
],
"FieldType": [
"Boolean", "String", "Integer", "Long", "Double", "DateTime", "Date",
"Entity", "Duration", "Money",
],
"PresentationType": [
"Text", "Numeric", "Date", "Time", "Checkbox", "TextArea", "Currency",
"Duration", "ReferenceToObject", "PickList", "Url", "Percent", "Other",
],
"RecipientType": ["To", "CC"],
"AccessType": ["Public", "Private"],
"TriggerType": [
"Create", "CreateOrEdit", "Delete", "CreateOrEditWithPreviousValueNotEqual",
],
"HttpMethod": ["DELETE", "GET", "POST", "PUT"],
}
STUB_TYPES = {
"ApplyOnMembersResult": "string",
"IFormat": "Record<string, JsonValue>",
"RoleType": "string",
}
PRIMITIVE_MAP = {
"string": "string",
"boolean": "boolean",
"integer": "number",
"long": "number",
"double": "number",
"datetime": "string",
"date": "string",
"type": "Record<string, JsonValue>",
"json object": "Record<string, JsonValue>",
"object": "JsonValue",
"entityid": "EntityId",
}
def pascal(name: str) -> str:
if not name:
return name
if name[0].isupper():
return name.replace(" ", "")
return name[0].upper() + name[1:]
ARRAY_PROP_NAMES = frozenset({
"fields", "groupby", "orders", "relations", "aggregations",
"feeditemoptions", "workitems", "validstates",
})
def ts_prop_type(prop: dict) -> str:
pname = prop.get("name", "").rstrip("*").lower()
href = prop.get("typeHref") or ""
if href:
tail = href.rstrip("/").split("/")[-1]
tail = pascal(tail)
if tail in ("ListOf_String", "Array_String"):
return "string[]"
if pname in ARRAY_PROP_NAMES or tail == "FieldAggregation":
if tail == "FieldAggregation":
return "FieldAggregation[]"
if tail == "FieldDescription":
return "FieldDescription[]"
if tail == "RelationDescription":
return "RelationDescription[]"
if tail == "OrderBy":
return "OrderBy[]"
if tail == "Relation":
return "Relation[]"
return f"{tail}[]"
return tail
raw = prop.get("type", "JsonValue").lower()
raw = re.sub(r"\(.*\)", "", raw).strip()
if raw.endswith("[]"):
inner = PRIMITIVE_MAP.get(raw[:-2], pascal(raw[:-2]))
return f"{inner}[]"
return PRIMITIVE_MAP.get(raw, "JsonValue")
def emit_enum(name: str, values: list[str]) -> str:
lines = [f"export type {name} ="]
for v in values:
lines.append(f' | "{v}"')
lines[-1] = lines[-1] + ";"
return "\n".join(lines)
def prop_optional(prop_name: str) -> str:
return "" if prop_name.endswith("*") else "?"
def clean_prop_name(prop_name: str) -> str:
return prop_name.rstrip("*")
def emit_interface(name: str, variant: dict) -> str:
props = variant.get("properties", [])
if not props and "(enum)" in variant.get("variant", ""):
return ""
lines = [f"export interface {name} {{"]
for p in props:
pname = clean_prop_name(p["name"])
lines.append(f" {pname}{prop_optional(p['name'])}: {ts_prop_type(p)};")
lines.append("}")
return "\n".join(lines)
def main():
data = json.loads(SCHEMA.read_text())
parsed = data["parsed"]
out: list[str] = [
"// Auto-generated from Clarizen REST API V2.0 type docs. Run: npm run generate:types",
'import type { JsonValue } from "./types.js";',
"",
"export type EntityId = string;",
"",
]
for enum_name, values in ENUMS.items():
out.append(emit_enum(enum_name, values))
out.append("")
# Condition union (manual structure from variants)
out.append("export interface AndCondition { and: Condition[]; }")
out.append("export interface OrCondition { or: Condition[]; }")
out.append("export interface NotCondition { not: Condition; }")
out.append("export interface CompareCondition {")
out.append(" _type?: \"Compare\";")
out.append(" leftExpression: Expression;")
out.append(" operator: ComparisonOperator;")
out.append(" rightExpression: Expression;")
out.append("}")
out.append("export interface CzqlCondition {")
out.append(" text: string;")
out.append(" parameters?: Parameters;")
out.append("}")
out.append(
"export type Condition = AndCondition | OrCondition | NotCondition | CompareCondition | CzqlCondition;"
)
out.append("")
# Expression union
out.append("export interface FieldExpression { fieldName: string; }")
out.append("export interface ConstantExpression { value: JsonValue; }")
out.append("export interface ConstantListExpression { values: JsonValue[]; }")
out.append("export interface QueryExpression { query: Query; }")
out.append("export interface PredefinedDateRangeExpression {")
out.append(" value?: DatePeriod;")
out.append(" dateRangeValue?: DatePeriod;")
out.append("}")
out.append(
"export type Expression = FieldExpression | ConstantExpression | ConstantListExpression | QueryExpression | PredefinedDateRangeExpression;"
)
out.append("")
# Single-variant interfaces from schema
skip = {
"Condition", "Expression", "Operator", "Order", "Query",
"ListOf_String", "Array_String", "EntityId",
}
for type_name, stub in STUB_TYPES.items():
out.append(f"export type {type_name} = {stub};")
out.append("")
for type_name in sorted(parsed.keys()):
if type_name in skip or type_name in STUB_TYPES:
continue
info = parsed[type_name]
if len(info["variants"]) != 1:
continue
v = info["variants"][0]
if "(enum)" in v.get("variant", ""):
continue
iface = emit_interface(type_name, v)
if iface:
out.append(iface)
out.append("")
# Query variants
query_info = parsed.get("Query", {})
query_names: list[str] = []
for v in query_info.get("variants", []):
variant_key = v["variant"]
iface_name = pascal(variant_key)
if iface_name == "CZQLQuery":
iface_name = "CzqlQuery"
props = v.get("properties", [])
lines = [f"export interface {iface_name} {{"]
for p in props:
pname = clean_prop_name(p["name"])
lines.append(f" {pname}{prop_optional(p['name'])}: {ts_prop_type(p)};")
lines.append("}")
out.append("\n".join(lines))
out.append("")
query_names.append(iface_name)
out.append(f"export type Query = {' | '.join(query_names)};")
out.append("")
# Shared query body (entityQuery variant) for EntityQuery operation
out.append("export type EntityQueryBody = EntityQuery;")
out.append("")
out.append("export type ComparisonOperator = Operator;")
out.append("export type SortOrder = Order;")
out.append("export type ConditionParameters = Parameters;")
text = "\n".join(out) + "\n"
OUT.write_text(text)
print(f"Wrote {OUT} ({len(text)} bytes)")
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
/**
* Regenerates src/endpoints.ts from the live Clarizen API docs.
* Requires CLARIZEN_API_KEY (or API_KEY_AW) in .env for Accept: text/html.
*/
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
const baseUrl = process.env.CLARIZEN_BASE_URL ?? "https://api.clarizen.com";
const apiKey = process.env.CLARIZEN_API_KEY ?? process.env.API_KEY_AW;
if (!apiKey) {
console.error("Set CLARIZEN_API_KEY or API_KEY_AW");
process.exit(1);
}
const indexRes = await fetch(`${baseUrl}/V2.0/services/`, {
headers: {
Authorization: `ApiKey ${apiKey}`,
Accept: "text/html",
},
});
const html = await indexRes.text();
const linkRe = /href="(\/V2\.0\/services\/[^"]+)"/g;
const paths = [...new Set([...html.matchAll(linkRe)].map((m) => m[1]))].filter(
(p) => !p.endsWith("/objects"),
);
const endpoints: Array<{
namespace: string;
operation: string;
path: string;
httpMethods: string[];
description: string;
}> = [];
for (const docPath of paths) {
const res = await fetch(`${baseUrl}${docPath}`, {
headers: {
Authorization: `ApiKey ${apiKey}`,
Accept: "text/html",
},
});
const page = await res.text();
const parts = docPath.split("/");
const namespace = parts[3] ?? "";
const operation = parts[4] ?? "";
const pathMatch = page.match(/<samp>([^<]+)<\/samp>/);
const methodMatch = page.match(
/<dt>HTTP Method<\/dt>\s*<dd><code>([^<]+)<\/code>/,
);
const descMatch = page.match(/<dt>Description<\/dt>\s*<dd>([^<]+)/);
endpoints.push({
namespace,
operation,
path: pathMatch?.[1] ?? docPath,
httpMethods: (methodMatch?.[1] ?? "POST")
.split(",")
.map((m) => m.trim().toUpperCase()),
description: (descMatch?.[1] ?? "")
.replace(/\u00a0/g, " ")
.trim(),
});
}
const namespaces = [...new Set(endpoints.map((e) => e.namespace))];
const out = `import type { EndpointDefinition } from "./types.js";
/** Auto-generated from ${baseUrl}/V2.0/services/ — run: npm run scrape */
export const ENDPOINTS: EndpointDefinition[] = ${JSON.stringify(endpoints, null, 2)};
export const NAMESPACES = ${JSON.stringify(namespaces, null, 2)} as const;
export type ClarizenNamespace = (typeof NAMESPACES)[number];
`;
const target = resolve(import.meta.dirname, "../src/endpoints.ts");
writeFileSync(target, out);
console.log(`Wrote ${endpoints.length} endpoints to ${target}`);
+50
View File
@@ -0,0 +1,50 @@
/**
* Fetches filtered org metadata and writes src/generated/metadata-catalog.json
* and src/generated/metadata-types.ts.
*
* Requires CLARIZEN_API_KEY or CLARIZEN_USERNAME + CLARIZEN_PASSWORD in .env
*/
import "dotenv/config";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { Clarizen } from "../src/clarizen.js";
import { generateMetadataTypesFile } from "../src/metadata/generate-types.js";
const baseUrl = process.env.CLARIZEN_BASE_URL ?? "https://api.clarizen.com";
const apiKey = process.env.CLARIZEN_API_KEY ?? process.env.API_KEY_AW;
const userName = process.env.CLARIZEN_USERNAME;
const password = process.env.CLARIZEN_PASSWORD;
const outDir = resolve(import.meta.dirname, "../src/generated");
const catalogPath = resolve(outDir, "metadata-catalog.json");
const typesPath = resolve(outDir, "metadata-types.ts");
async function createClient(): Promise<Clarizen> {
if (apiKey) {
return new Clarizen({ baseUrl, apiToken: apiKey });
}
if (userName && password) {
const clarizen = Clarizen.createForLogin(baseUrl);
await clarizen.loginWithDiscovery({ userName, password });
return clarizen;
}
console.error(
"Set CLARIZEN_API_KEY or CLARIZEN_USERNAME + CLARIZEN_PASSWORD in .env",
);
process.exit(1);
}
const clarizen = await createClient();
console.log("Fetching metadata catalog...");
const catalog = await clarizen.metadata.fetchCatalog({
onProgress: (msg) => console.log(msg),
});
mkdirSync(outDir, { recursive: true });
writeFileSync(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
writeFileSync(typesPath, generateMetadataTypesFile(catalog));
console.log(
`Wrote ${catalog.entities.length} entities to:\n ${catalogPath}\n ${typesPath}`,
);
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Discover all Clarizen API types by crawling services + types docs."""
import json
import os
import re
import subprocess
import sys
from html import unescape
from pathlib import Path
from urllib.parse import urljoin
ROOT = Path(__file__).resolve().parents[1]
BASE = os.environ.get("CLARIZEN_BASE_URL", "https://api.clarizen.com")
TOKEN = os.environ.get("CLARIZEN_API_KEY") or os.environ.get("API_KEY_AW")
if not TOKEN:
sys.exit("Set CLARIZEN_API_KEY or API_KEY_AW")
TYPE_LINK = re.compile(r'href="(/V2\.0/services/types/([^"]+))"', re.I)
SERVICE_LINK = re.compile(r'href="(/V2\.0/services/(?!types)[^"]+)"', re.I)
VARIANT_NAME = re.compile(r"<dt>Type</dt>\s*<dd>\s*<i>([^<]+)</i>", re.I)
PROP_ROW = re.compile(
r"<td><i><b>(.*?)</b></i></td>\s*<td>(?:<a href=\"([^\"]+)\">)?([^<]+)",
re.I | re.S,
)
ENUM_ROW = re.compile(r"<tr><td>([^<]+)</td><td>[^<]*</td><td>", re.I)
def fetch(path: str) -> str:
url = urljoin(BASE, path)
r = subprocess.run(
[
"curl",
"-sL",
"--max-time",
"20",
url,
"-H",
f"Authorization: ApiKey {TOKEN}",
"-H",
"Accept: text/html",
],
capture_output=True,
text=True,
)
return r.stdout
def normalize_type_name(href_tail: str) -> str:
name = href_tail.split("/")[-1]
if name.lower() in ("object", "string", "integer", "boolean", "json object"):
return name
return name[0].upper() + name[1:] if name else name
def parse_type_page(html: str, type_name: str) -> dict:
variants = []
parts = re.split(r"<dt>Type</dt>", html, flags=re.I)
for part in parts[1:]:
name_m = re.search(r"<dd>\s*<i>([^<]+)</i>", part, re.I)
if not name_m:
continue
variant = unescape(name_m.group(1)).strip()
desc_m = re.search(
r"<dt>Description</dt>\s*<dd>([^<]*)", part, re.I | re.S
)
description = unescape(desc_m.group(1)).strip() if desc_m else ""
properties = []
prop_section = re.search(r"<dt>Properties</dt>(.*?)<dt>", part, re.I | re.S)
if not prop_section:
prop_section = re.search(r"<dt>Properties</dt>(.*)", part, re.I | re.S)
if prop_section:
block = prop_section.group(1)
rows = PROP_ROW.findall(block)
for pname, href, ptype in rows:
clean_name = re.sub(r"<[^>]+>", "", pname)
properties.append(
{
"name": unescape(clean_name).strip(),
"typeHref": href or None,
"type": unescape(re.sub(r"<[^>]+>", "", ptype)).strip(),
}
)
enum_values = []
if re.search(r"<dt>Possible Values</dt>", part, re.I):
ev_block = re.search(
r"<dt>Possible Values</dt>(.*?)<dt>", part, re.I | re.S
)
if ev_block:
enum_values = [
unescape(m.group(1)).strip()
for m in re.finditer(
r"<tr><td>([^<]+)</td>", ev_block.group(1), re.I
)
]
variants.append(
{
"variant": variant,
"description": description,
"properties": properties,
"enumValues": enum_values,
}
)
return {"name": type_name, "variants": variants}
def main():
seeds = ["/V2.0/services/"]
# all operations from index
index = fetch("/V2.0/services/")
seeds.extend(m.group(1) for m in SERVICE_LINK.finditer(index))
discovered_types: set[str] = set()
queue: list[str] = []
parsed: dict[str, dict] = {}
def enqueue_type(href: str):
tail = href.split("/types/")[-1]
key = normalize_type_name(tail)
if key.lower() in ("object", "string", "integer", "boolean", "json object"):
return
if key not in discovered_types:
discovered_types.add(key)
queue.append(key)
for seed in seeds:
html = fetch(seed) if seed != "/V2.0/services/" else index
if seed != "/V2.0/services/":
html = fetch(seed)
for m in TYPE_LINK.finditer(html):
enqueue_type(m.group(1))
while queue:
tname = queue.pop(0)
if tname in parsed:
continue
path = f"/V2.0/services/types/{tname}"
html = fetch(path)
if "<dt>Type</dt>" not in html and "<dt>Possible Values</dt>" not in html:
# try lowercase
path = f"/V2.0/services/types/{tname[0].lower()}{tname[1:]}"
html = fetch(path)
info = parse_type_page(html, tname)
parsed[tname] = info
for v in info["variants"]:
for p in v["properties"]:
if p.get("typeHref"):
enqueue_type(p["typeHref"])
out = {
"typeCount": len(parsed),
"types": sorted(parsed.keys()),
"parsed": parsed,
}
out_path = ROOT / "src" / "generated" / "clarizen-schema.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
print(f"Wrote {out_path} ({len(parsed)} types)")
if __name__ == "__main__":
main()
+519
View File
@@ -0,0 +1,519 @@
// Auto-generated from Clarizen REST API V2.0 type docs. Run: npm run generate:types
import type { JsonValue } from "./types.js";
export type EntityId = string;
export type Operator =
| "Equal"
| "NotEqual"
| "LessThan"
| "GreaterThan"
| "LessThanOrEqual"
| "GreaterThanOrEqual"
| "BeginsWith"
| "EndsWith"
| "Contains"
| "DoesNotContain"
| "In"
| "Like"
| "InRange"
| "NotInRange"
| "NotIn";
export type Order =
| "Ascending"
| "Descending";
export type DatePeriod =
| "ThisWeek"
| "ThisMonth"
| "ThisQuarter"
| "ThisYear"
| "NextWeek"
| "NextMonth"
| "NextQuarter"
| "NextYear"
| "LastWeek"
| "LastMonth"
| "LastQuarter"
| "LastYear";
export type FunctionType =
| "Count"
| "Max"
| "Min"
| "Sum"
| "Avg";
export type LicenseType =
| "Full"
| "Limited"
| "Email"
| "None"
| "TeamMember"
| "Social"
| "ExternalCollaborator";
export type NewsFeedMode =
| "Following"
| "All";
export type TimesheetState =
| "UnSubmitted"
| "PendingApproval"
| "Approved"
| "All";
export type ErrorCode =
| "EntityNotFound"
| "InvalidArgument"
| "MissingArgument"
| "InvalidOperation"
| "DuplicateKey"
| "InvalidField"
| "InvalidType"
| "FileNotFound"
| "VirusCheckFailed"
| "Unauthorized"
| "UnsupportedClient"
| "General"
| "Internal"
| "ValidationRuleError"
| "LoginFailure"
| "SessionTimeout"
| "Redirect"
| "InvalidQuery"
| "ExecutionThresholdExceeded"
| "TooManyRequests";
export type FieldType =
| "Boolean"
| "String"
| "Integer"
| "Long"
| "Double"
| "DateTime"
| "Date"
| "Entity"
| "Duration"
| "Money";
export type PresentationType =
| "Text"
| "Numeric"
| "Date"
| "Time"
| "Checkbox"
| "TextArea"
| "Currency"
| "Duration"
| "ReferenceToObject"
| "PickList"
| "Url"
| "Percent"
| "Other";
export type RecipientType =
| "To"
| "CC";
export type AccessType =
| "Public"
| "Private";
export type TriggerType =
| "Create"
| "CreateOrEdit"
| "Delete"
| "CreateOrEditWithPreviousValueNotEqual";
export type HttpMethod =
| "DELETE"
| "GET"
| "POST"
| "PUT";
export interface AndCondition { and: Condition[]; }
export interface OrCondition { or: Condition[]; }
export interface NotCondition { not: Condition; }
export interface CompareCondition {
_type?: "Compare";
leftExpression: Expression;
operator: ComparisonOperator;
rightExpression: Expression;
}
export interface CzqlCondition {
text: string;
parameters?: Parameters;
}
export type Condition = AndCondition | OrCondition | NotCondition | CompareCondition | CzqlCondition;
export interface FieldExpression { fieldName: string; }
export interface ConstantExpression { value: JsonValue; }
export interface ConstantListExpression { values: JsonValue[]; }
export interface QueryExpression { query: Query; }
export interface PredefinedDateRangeExpression {
value?: DatePeriod;
dateRangeValue?: DatePeriod;
}
export type Expression = FieldExpression | ConstantExpression | ConstantListExpression | QueryExpression | PredefinedDateRangeExpression;
export type ApplyOnMembersResult = string;
export type IFormat = Record<string, JsonValue>;
export type RoleType = string;
export interface Action {
url: string;
method: HttpMethod;
headers?: string;
body?: string;
}
export interface ApplyOnMembersResultItemStatus {
userGroupId?: EntityId;
userGroupName?: string;
status?: ApplyOnMembersResult;
failureStatusMessage?: FailureStatusMessage;
}
export interface CalendarException {
id?: EntityId;
name?: string;
isWorkingDay?: boolean;
isAllDay?: boolean;
startHour?: number;
endHour?: number;
exceptionType?: EntityId;
startDate?: DateTime;
endDate?: DateTime;
repeat?: Repeat;
}
export interface CreateResult {
id?: EntityId;
}
export interface DateTime {
}
export interface DayInformation {
isWorkingDay?: boolean;
totalWorkingHours?: number;
startHour?: number;
endHour?: number;
}
export interface Entity {
id?: EntityId;
objectFields?: Record<string, JsonValue>;
}
export interface EntityDescription {
typeName?: string;
fields?: FieldDescription[];
validStates?: string[];
label?: string;
labelPlural?: string;
parentEntity?: string;
displayField?: string;
disabled?: boolean;
relations?: RelationDescription[];
}
export interface EntityRelationsDescription {
typeName?: string;
relations?: RelationDescription[];
}
export interface Error {
errorCode?: ErrorCode;
message?: string;
referenceId?: string;
}
export interface Every {
recurrenceType?: string;
period?: number;
}
export interface ExceptionDate {
date?: DateTime;
calendarExceptionId?: EntityId;
}
export interface FailureStatusMessage {
failureMessage?: string;
}
export interface FieldAggregation {
function?: FunctionType;
fieldName?: string;
alias?: string;
}
export interface FieldDescription {
name?: string;
type?: FieldType;
presentationType?: PresentationType;
label?: string;
defaultValue?: JsonValue;
system?: boolean;
calculated?: boolean;
nullable?: boolean;
createOnly?: boolean;
updateable?: boolean;
internal?: boolean;
custom?: boolean;
visible?: boolean;
decimalPlaces?: number;
filterable?: boolean;
sortable?: boolean;
format?: IFormat;
maxLength?: number;
flags?: string[];
restrictedFieldSetName?: string;
manuallySetName?: string;
}
export interface FieldValue {
fieldName?: string;
value?: JsonValue;
}
export interface FileInformation {
storage?: Storage;
url?: string;
fileName?: string;
subType?: string;
extendedInfo?: string;
}
export interface IdentifierMap {
oid?: string;
id?: string;
}
export interface LicenseConsumption {
licenseTypeLabel?: string;
licenseType?: LicenseType;
assignedLicenses?: number;
activeUsers?: number;
allocatedLicenses?: number;
}
export interface LoginOptions {
partnerId?: string;
applicationId?: string;
}
export interface MissingTimesheets {
date?: DateTime;
workingHours?: number;
reportedHours?: number;
missingHours?: number;
}
export interface OrderBy {
fieldName?: string;
order?: Order;
}
export interface OrganizationLicenseConsumption {
id?: EntityId;
licenseConsumption?: LicenseConsumption;
}
export interface Paging {
from?: number;
limit?: number;
hasMore?: boolean;
}
export interface Parameters {
objectFields?: Record<string, JsonValue>;
}
export interface PostFeedItem {
latestReplies?: ReplyFeedItem;
pinned?: boolean;
message?: Entity;
likedByMe?: boolean;
relatedEntities?: Entity;
notify?: Entity;
topics?: Entity;
bodyMarkup?: string;
summary?: string;
}
export interface Recipient {
recipientType?: RecipientType;
user?: EntityId;
eMail?: string;
}
export interface Relation {
name?: string;
fields?: string[];
where?: Condition;
orders?: OrderBy[];
fromLink?: boolean;
}
export interface RelationDescription {
name?: string;
label?: string;
roleLabel?: string;
readOnly?: boolean;
linkTypeName?: string;
relatedTypeName?: string;
sourceFieldName?: string;
}
export interface Repeat {
every?: Every;
occurrences?: number;
endBy?: DateTime;
}
export interface ReplyFeedItem {
message?: Entity;
likedByMe?: boolean;
relatedEntities?: Entity;
notify?: Entity;
topics?: Entity;
bodyMarkup?: string;
summary?: string;
}
export interface Request {
url?: string;
method?: string;
body?: JsonValue;
}
export interface Response {
statusCode?: number;
body?: JsonValue;
}
export interface RetrieveResult {
entity?: Entity;
}
export interface RoleForResource {
role: RoleType;
resource: EntityId;
}
export interface EntityFeedQuery {
entityId: EntityId;
fields?: string[];
feedItemOptions?: string[];
paging?: Paging;
}
export interface GroupsQuery {
fields?: string[];
paging?: Paging;
}
export interface NewsFeedQuery {
mode?: NewsFeedMode;
fields?: string[];
feedItemOptions?: string[];
paging?: Paging;
}
export interface RepliesQuery {
postId: EntityId;
fields?: string[];
feedItemOptions?: string[];
paging?: Paging;
}
export interface AggregateQuery {
typeName: string;
groupBy?: string[];
orders?: OrderBy[];
where?: Condition;
aggregations: FieldAggregation[];
paging?: Paging;
}
export interface CzqlQuery {
q?: string;
parameters?: Parameters;
paging?: Paging;
}
export interface EntityQuery {
typeName: string;
fields?: string[];
orders?: OrderBy[];
where?: Condition;
relations?: Relation[];
deleted?: boolean;
originalExternalID?: boolean;
paging?: Paging;
}
export interface RelationQuery {
entityId: EntityId;
relationName: string;
fields?: string[];
orders?: OrderBy[];
where?: Condition;
relations?: Relation[];
fromLink?: boolean;
paging?: Paging;
}
export interface ExpenseQuery {
projectId?: EntityId;
customerId?: EntityId;
typeName: string;
fields?: string[];
orders?: OrderBy[];
where?: Condition;
relations?: Relation[];
deleted?: boolean;
originalExternalID?: boolean;
paging?: Paging;
}
export interface TimesheetQuery {
projectId?: EntityId;
customerId?: EntityId;
iAmTheApprover?: boolean;
workItems?: EntityId[];
timesheetState?: TimesheetState;
typeName: string;
fields?: string[];
orders?: OrderBy[];
where?: Condition;
relations?: Relation[];
deleted?: boolean;
originalExternalID?: boolean;
paging?: Paging;
}
export interface FindUserQuery {
firstName?: string;
lastName?: string;
eMail?: string;
fuzzySearchUserName?: boolean;
includeSuspendedUsers?: boolean;
fields?: string[];
orders?: OrderBy[];
paging?: Paging;
}
export type Query = EntityFeedQuery | GroupsQuery | NewsFeedQuery | RepliesQuery | AggregateQuery | CzqlQuery | EntityQuery | RelationQuery | ExpenseQuery | TimesheetQuery | FindUserQuery;
export type EntityQueryBody = EntityQuery;
export type ComparisonOperator = Operator;
export type SortOrder = Order;
export type ConditionParameters = Parameters;
+147
View File
@@ -0,0 +1,147 @@
import { NAMESPACES, type ClarizenNamespace } from "./endpoints.js";
import { HttpClient } from "./http.js";
import { EntityObjectsService } from "./objects.js";
import { createMetadataService, type MetadataService } from "./metadata/service.js";
import {
createNamespaceService,
type NamespaceService,
type OperationCall,
} from "./service.js";
import type { QueryParamValue } from "./http.js";
import type {
AuthMode,
EntityQueryParams,
EntityQueryResult,
JsonObject,
LoginParams,
LoginResult,
ServerDefinition,
SessionInfo,
} from "./types.js";
export interface ClarizenOptions {
baseUrl?: string;
apiToken?: string;
sessionId?: string;
}
export interface AuthenticationService {
getServerDefinition: (body?: JsonObject) => Promise<ServerDefinition>;
getSessionInfo: () => Promise<SessionInfo>;
login: (params: LoginParams) => Promise<LoginResult>;
logout: OperationCall;
}
export interface DataService {
entityQuery: (params: EntityQueryParams) => Promise<EntityQueryResult>;
[operation: string]: OperationCall | ((params: EntityQueryParams) => Promise<EntityQueryResult>);
}
export type { MetadataService } from "./metadata/service.js";
export class Clarizen {
private readonly http: HttpClient;
readonly authentication: AuthenticationService;
readonly applications: NamespaceService;
readonly bulk: NamespaceService;
readonly data: DataService;
readonly files: NamespaceService;
readonly metadata: MetadataService;
readonly utils: NamespaceService;
readonly objects: EntityObjectsService;
constructor(baseUrl: string, apiToken?: string);
constructor(options: ClarizenOptions);
constructor(
baseUrlOrOptions: string | ClarizenOptions,
apiToken?: string,
) {
const options: ClarizenOptions =
typeof baseUrlOrOptions === "string"
? { baseUrl: baseUrlOrOptions, apiToken }
: baseUrlOrOptions;
const baseUrl = (options.baseUrl ?? "https://api.clarizen.com").replace(
/\/$/,
"",
);
let auth: AuthMode;
if (options.sessionId) {
auth = { type: "session", sessionId: options.sessionId };
} else if (options.apiToken) {
auth = { type: "apiKey", token: options.apiToken };
} else {
auth = { type: "none" };
}
this.http = new HttpClient(baseUrl, auth);
const authBase = createNamespaceService(this.http, "authentication");
this.authentication = {
getServerDefinition: (body) =>
authBase.getServerDefinition(body) as Promise<ServerDefinition>,
getSessionInfo: () =>
authBase.getSessionInfo() as Promise<SessionInfo>,
logout: authBase.logout,
login: async (params) => {
const result = (await authBase.login(
params as unknown as JsonObject,
)) as LoginResult;
this.useSession(result.sessionId);
return result;
},
};
this.applications = createNamespaceService(this.http, "applications");
this.bulk = createNamespaceService(this.http, "bulk");
this.files = createNamespaceService(this.http, "files");
this.metadata = createMetadataService(this.http);
this.utils = createNamespaceService(this.http, "utils");
this.objects = new EntityObjectsService(this.http);
this.data = createNamespaceService(this.http, "data") as unknown as DataService;
}
get baseUrl(): string {
return this.http.getBaseUrl();
}
async loginWithDiscovery(params: LoginParams): Promise<LoginResult> {
const definition = await this.authentication.getServerDefinition(
params as unknown as JsonObject,
);
this.http.setBaseUrl(definition.serverLocation);
return this.authentication.login(params);
}
useSession(sessionId: string): void {
this.http.setAuth({ type: "session", sessionId });
}
useApiKey(apiToken: string): void {
this.http.setAuth({ type: "apiKey", token: apiToken });
}
get sessionId(): string | undefined {
const auth = this.http.getAuth();
return auth.type === "session" ? auth.sessionId : undefined;
}
call<T = unknown>(
namespace: ClarizenNamespace,
operation: string,
body?: Record<string, unknown>,
query?: Record<string, QueryParamValue>,
): Promise<T> {
const service = this[namespace] as NamespaceService;
return service(operation, body as JsonObject, query) as Promise<T>;
}
static createForLogin(baseUrl = "https://api.clarizen.com"): Clarizen {
return new Clarizen({ baseUrl });
}
static readonly namespaces = NAMESPACES;
}
+68
View File
@@ -0,0 +1,68 @@
import type { EndpointDefinition } from "./types.js";
export const ENDPOINTS: EndpointDefinition[] = [
{ namespace: "applications", operation: "GetApplicationStatus", path: "/V2.0/services/applications/getApplicationStatus", httpMethods: ["GET"], description: "Get application status" },
{ namespace: "applications", operation: "InstallApplication", path: "/V2.0/services/applications/installApplication", httpMethods: ["POST"], description: "Install application" },
{ namespace: "authentication", operation: "GetServerDefinition", path: "/V2.0/services/authentication/getServerDefinition", httpMethods: ["POST"], description: "Returns the URL where your organization API is located" },
{ namespace: "authentication", operation: "GetSessionInfo", path: "/V2.0/services/authentication/getSessionInfo", httpMethods: ["GET", "POST"], description: "Returns information about the current session" },
{ namespace: "authentication", operation: "Login", path: "/V2.0/services/authentication/login", httpMethods: ["POST"], description: "Login to the API" },
{ namespace: "authentication", operation: "Logout", path: "/V2.0/services/authentication/logout", httpMethods: ["GET"], description: "Terminate the current API session" },
{ namespace: "bulk", operation: "Execute", path: "/V2.0/services/bulk/execute", httpMethods: ["POST"], description: "Execute several API calls in a single round trip" },
{ namespace: "data", operation: "AggregateQuery", path: "/V2.0/services/data/aggregateQuery", httpMethods: ["GET", "POST"], description: "Query and aggregate results" },
{ namespace: "data", operation: "ApplyOnMembers", path: "/V2.0/services/data/applyOnMembers", httpMethods: ["POST"], description: "Apply settings from User Group to members" },
{ namespace: "data", operation: "ChangeState", path: "/V2.0/services/data/changeState", httpMethods: ["POST"], description: "Change the state of an object" },
{ namespace: "data", operation: "CloseFiscalMonth", path: "/V2.0/services/data/closeFiscalMonth", httpMethods: ["POST"], description: "Close the fiscal month" },
{ namespace: "data", operation: "CountQuery", path: "/V2.0/services/data/countQuery", httpMethods: ["GET", "POST"], description: "Query and return result count" },
{ namespace: "data", operation: "CreateAndRetrieve", path: "/V2.0/services/data/createAndRetrieve", httpMethods: ["POST"], description: "Create and immediately retrieve an entity" },
{ namespace: "data", operation: "CreateDiscussion", path: "/V2.0/services/data/createDiscussion", httpMethods: ["POST"], description: "Create a discussion message" },
{ namespace: "data", operation: "CreateFromTemplate", path: "/V2.0/services/data/createFromTemplate", httpMethods: ["POST"], description: "Create an entity from a template" },
{ namespace: "data", operation: "CreateMultiply", path: "/V2.0/services/data/createMultiply", httpMethods: ["PUT"], description: "Create multiple entities" },
{ namespace: "data", operation: "DeletePermissions", path: "/V2.0/services/data/deletePermissions", httpMethods: ["POST"], description: "Delete permission on entity" },
{ namespace: "data", operation: "EntityFeedQuery", path: "/V2.0/services/data/entityFeedQuery", httpMethods: ["GET", "POST"], description: "Return the social feed of an object" },
{ namespace: "data", operation: "EntityQuery", path: "/V2.0/services/data/entityQuery", httpMethods: ["GET", "POST"], description: "Retrieve entities by criteria" },
{ namespace: "data", operation: "ExecuteCustomAction", path: "/V2.0/services/data/executeCustomAction", httpMethods: ["GET", "POST"], description: "Execute custom action" },
{ namespace: "data", operation: "ExpenseQuery", path: "/V2.0/services/data/expenseQuery", httpMethods: ["POST"], description: "Retrieve expenses for a project or customer" },
{ namespace: "data", operation: "FindUserQuery", path: "/V2.0/services/data/findUserQuery", httpMethods: ["POST"], description: "Find a user by criteria" },
{ namespace: "data", operation: "GetCalendarExceptions", path: "/V2.0/services/data/getCalendarExceptions", httpMethods: ["GET"], description: "Retrieve calendar exceptions" },
{ namespace: "data", operation: "GetCalendarInfo", path: "/V2.0/services/data/getCalendarInfo", httpMethods: ["GET"], description: "Calendar definitions info" },
{ namespace: "data", operation: "GetTemplateDescriptions", path: "/V2.0/services/data/getTemplateDescriptions", httpMethods: ["GET"], description: "List templates for an entity type" },
{ namespace: "data", operation: "GroupsQuery", path: "/V2.0/services/data/groupsQuery", httpMethods: ["GET", "POST"], description: "List groups the current user belongs to" },
{ namespace: "data", operation: "LicenseConsumption", path: "/V2.0/services/data/licenseConsumption", httpMethods: ["GET", "POST"], description: "License consumption per organization" },
{ namespace: "data", operation: "Lifecycle", path: "/V2.0/services/data/lifecycle", httpMethods: ["POST"], description: "Lifecycle operations (Activate, Cancel, etc.)" },
{ namespace: "data", operation: "MissingTimesheets", path: "/V2.0/services/data/missingTimesheets", httpMethods: ["GET"], description: "Missing timesheets information" },
{ namespace: "data", operation: "NewsFeedQuery", path: "/V2.0/services/data/newsFeedQuery", httpMethods: ["GET", "POST"], description: "Current user news feed" },
{ namespace: "data", operation: "ObjectIds", path: "/V2.0/services/data/objectIds", httpMethods: ["POST"], description: "Resolve object IDs" },
{ namespace: "data", operation: "Query", path: "/V2.0/services/data/query", httpMethods: ["GET", "POST"], description: "Execute a CZQL query" },
{ namespace: "data", operation: "RelationQuery", path: "/V2.0/services/data/relationQuery", httpMethods: ["GET", "POST"], description: "Related entities from a relation" },
{ namespace: "data", operation: "ReopenFiscalMonth", path: "/V2.0/services/data/reopenFiscalMonth", httpMethods: ["POST"], description: "Reopen fiscal month" },
{ namespace: "data", operation: "RepliesQuery", path: "/V2.0/services/data/repliesQuery", httpMethods: ["GET", "POST"], description: "Reply feed of a discussion" },
{ namespace: "data", operation: "RetrieveMultiple", path: "/V2.0/services/data/retrieveMultiple", httpMethods: ["POST"], description: "Retrieve multiple entities of the same type" },
{ namespace: "data", operation: "Search", path: "/V2.0/services/data/search", httpMethods: ["GET"], description: "Text search in entity types" },
{ namespace: "data", operation: "SetPermissions", path: "/V2.0/services/data/setPermissions", httpMethods: ["POST"], description: "Create or update permission on entity" },
{ namespace: "data", operation: "TimesheetQuery", path: "/V2.0/services/data/timesheetQuery", httpMethods: ["GET", "POST"], description: "Retrieve timesheets" },
{ namespace: "data", operation: "Upsert", path: "/V2.0/services/data/upsert", httpMethods: ["POST"], description: "Create or update an entity" },
{ namespace: "files", operation: "Download", path: "/V2.0/services/files/download", httpMethods: ["GET"], description: "Download file attached to a document" },
{ namespace: "files", operation: "GetUploadUrl", path: "/V2.0/services/files/getUploadUrl", httpMethods: ["GET"], description: "Get URL for uploading files" },
{ namespace: "files", operation: "UpdateImage", path: "/V2.0/services/files/updateImage", httpMethods: ["POST"], description: "Set or reset object image" },
{ namespace: "files", operation: "Upload", path: "/V2.0/services/files/upload", httpMethods: ["POST"], description: "Upload file to a document" },
{ namespace: "metadata", operation: "DescribeEntities", path: "/V2.0/services/metadata/describeEntities", httpMethods: ["GET"], description: "Entity type fields, relations and states" },
{ namespace: "metadata", operation: "DescribeEntityRelations", path: "/V2.0/services/metadata/describeEntityRelations", httpMethods: ["POST"], description: "Describe relation between entities" },
{ namespace: "metadata", operation: "DescribeMetadata", path: "/V2.0/services/metadata/describeMetadata", httpMethods: ["GET"], description: "Entity types in your organization" },
{ namespace: "metadata", operation: "GetSystemSettingsValues", path: "/V2.0/services/metadata/getSystemSettingsValues", httpMethods: ["GET"], description: "Retrieve system settings values" },
{ namespace: "metadata", operation: "ListEntities", path: "/V2.0/services/metadata/listEntities", httpMethods: ["GET"], description: "List entity types for your organization" },
{ namespace: "metadata", operation: "SetSystemSettingsValues", path: "/V2.0/services/metadata/setSystemSettingsValues", httpMethods: ["POST"], description: "Save system settings values" },
{ namespace: "utils", operation: "AppLogin", path: "/V2.0/services/utils/appLogin", httpMethods: ["GET"], description: "Convert API session to web application session" },
{ namespace: "utils", operation: "SendEMail", path: "/V2.0/services/utils/sendEMail", httpMethods: ["POST"], description: "Send email attached to an object" },
];
export const NAMESPACES = [
"applications",
"authentication",
"bulk",
"data",
"files",
"metadata",
"utils",
] as const;
export type ClarizenNamespace = (typeof NAMESPACES)[number];
+19
View File
@@ -0,0 +1,19 @@
import type { ClarizenErrorBody } from "./types.js";
export class ClarizenApiError extends Error {
readonly errorCode: string;
readonly referenceId?: string;
readonly status: number;
constructor(status: number, body: ClarizenErrorBody) {
super(body.message);
this.name = "ClarizenApiError";
this.status = status;
this.errorCode = body.errorCode;
this.referenceId = body.referenceId;
}
isSessionTimeout(): boolean {
return this.errorCode === "SessionTimeout";
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+134
View File
@@ -0,0 +1,134 @@
import { ClarizenApiError } from "./errors.js";
import type { AuthMode, ClarizenErrorBody, HttpMethod, JsonObject } from "./types.js";
export type QueryParamValue =
| string
| number
| boolean
| readonly (string | number | boolean)[]
| undefined;
export interface RequestOptions {
method?: HttpMethod;
query?: Record<string, QueryParamValue>;
body?: JsonObject;
}
function appendQueryParam(
params: URLSearchParams,
key: string,
value: QueryParamValue,
): void {
if (value === undefined) return;
if (Array.isArray(value)) {
for (const item of value) {
params.append(key, String(item));
}
return;
}
params.set(key, String(value));
}
const MAX_ATTEMPTS = 3;
export class HttpClient {
constructor(
private baseUrl: string,
private auth: AuthMode,
) {}
getBaseUrl(): string {
return this.baseUrl;
}
setBaseUrl(baseUrl: string): void {
this.baseUrl = baseUrl.replace(/\/$/, "");
}
setAuth(auth: AuthMode): void {
this.auth = auth;
}
getAuth(): AuthMode {
return this.auth;
}
private authorizationHeader(): string | undefined {
if (this.auth.type === "apiKey") {
return `ApiKey ${this.auth.token}`;
}
if (this.auth.type === "session") {
return `Session ${this.auth.sessionId}`;
}
return undefined;
}
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await this.executeOnce<T>(path, options);
} catch (error) {
if (
(error instanceof ClarizenApiError && error.isSessionTimeout()) ||
attempt >= MAX_ATTEMPTS
) {
throw error;
}
}
}
}
private async executeOnce<T>(
path: string,
options: RequestOptions,
): Promise<T> {
const method = options.method ?? "POST";
const url = new URL(path, this.baseUrl);
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
appendQueryParam(url.searchParams, key, value);
}
}
const headers: Record<string, string> = {
Accept: "application/json",
"Accept-Encoding": "gzip",
};
const authHeader = this.authorizationHeader();
if (authHeader) {
headers.Authorization = authHeader;
}
const init: RequestInit = { method, headers };
if (method !== "GET" && options.body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(options.body);
}
const response = await fetch(url, init);
const text = await response.text();
if (!text) {
if (!response.ok) {
throw new ClarizenApiError(response.status, {
errorCode: "HttpError",
message: response.statusText,
});
}
return {} as T;
}
const data = JSON.parse(text) as T | ClarizenErrorBody;
if (!response.ok || (data && typeof data === "object" && "errorCode" in data)) {
const err = data as ClarizenErrorBody;
if (err.errorCode) {
throw new ClarizenApiError(response.status || 400, err);
}
}
return data as T;
}
}
+39
View File
@@ -0,0 +1,39 @@
export { Clarizen, type ClarizenOptions } from "./clarizen.js";
export type {
AuthenticationService,
DataService,
MetadataService,
} from "./clarizen.js";
export { ClarizenApiError } from "./errors.js";
export { ENDPOINTS, NAMESPACES, type ClarizenNamespace } from "./endpoints.js";
export { EntityObjectsService, entityObjectPath } from "./objects.js";
export type { EntityGetOptions } from "./objects.js";
export type {
AuthMode,
EntityQueryParams,
EntityQueryResult,
LoginParams,
LoginResult,
ServerDefinition,
SessionInfo,
EndpointDefinition,
JsonObject,
JsonValue,
} from "./types.js";
export * from "./clarizen-api-types.js";
export {
fetchMetadataCatalog,
filterStandardTypeNames,
isStandardMetadataName,
sanitizeEntityDescription,
} from "./metadata/index.js";
export type {
DescribeMetadataParams,
DescribeMetadataResult,
EntityMetadataEntry,
FetchMetadataCatalogOptions,
ListEntitiesResult,
MetadataCatalog,
MetadataFlag,
} from "./metadata/index.js";
export * from "./generated/metadata-types.js";
+160
View File
@@ -0,0 +1,160 @@
import { ClarizenApiError } from "../errors.js";
import { ENDPOINTS } from "../endpoints.js";
import type { HttpClient } from "../http.js";
import {
filterStandardTypeNames,
sanitizeEntityDescription,
} from "./filter.js";
import type {
DescribeMetadataResult,
EntityMetadataEntry,
FetchMetadataCatalogOptions,
ListEntitiesResult,
MetadataCatalog,
MetadataFlag,
} from "./types.js";
const DEFAULT_FLAGS: MetadataFlag[] = ["fields", "relations"];
const DEFAULT_BATCH_SIZE = 20;
const DESCRIBE_METADATA_PATH =
ENDPOINTS.find(
(e) => e.namespace === "metadata" && e.operation === "DescribeMetadata",
)?.path ?? "/V2.0/services/metadata/describeMetadata";
const LIST_ENTITIES_PATH =
ENDPOINTS.find(
(e) => e.namespace === "metadata" && e.operation === "ListEntities",
)?.path ?? "/V2.0/services/metadata/listEntities";
function describeMetadataQuery(
typeNames: string[] | undefined,
flags: MetadataFlag[],
): Record<string, string | string[] | undefined> {
const query: Record<string, string | string[] | undefined> = {
flags,
};
if (typeNames !== undefined && typeNames.length > 0) {
query.typeNames = typeNames;
}
return query;
}
async function listEntities(http: HttpClient): Promise<string[]> {
const result = await http.request<ListEntitiesResult>(LIST_ENTITIES_PATH, {
method: "GET",
});
return result.typeNames ?? [];
}
async function describeMetadata(
http: HttpClient,
typeNames: string[] | undefined,
flags: MetadataFlag[],
): Promise<EntityMetadataEntry[]> {
const result = await http.request<DescribeMetadataResult>(
DESCRIBE_METADATA_PATH,
{
method: "GET",
query: describeMetadataQuery(typeNames, flags),
},
);
const entries: EntityMetadataEntry[] = [];
for (const desc of result.entityDescriptions ?? []) {
const entry = sanitizeEntityDescription(desc);
if (entry) {
entries.push(entry);
}
}
return entries;
}
function chunk<T>(items: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
function mergeEntries(
batches: EntityMetadataEntry[][],
): EntityMetadataEntry[] {
const byType = new Map<string, EntityMetadataEntry>();
for (const batch of batches) {
for (const entry of batch) {
byType.set(entry.typeName, entry);
}
}
return [...byType.values()].sort((a, b) =>
a.typeName.localeCompare(b.typeName),
);
}
function needsBatchedFetch(error: unknown): boolean {
if (error instanceof ClarizenApiError) {
return (
error.errorCode === "MissingArgument" ||
error.errorCode === "InvalidArgument"
);
}
if (error instanceof Error) {
const msg = error.message.toLowerCase();
return msg.includes("missing") || msg.includes("typenames");
}
return false;
}
async function fetchBatched(
http: HttpClient,
flags: MetadataFlag[],
batchSize: number,
log: (message: string) => void,
): Promise<EntityMetadataEntry[]> {
log("listEntities + batched describeMetadata...");
const rawNames = await listEntities(http);
const typeNames = filterStandardTypeNames(rawNames);
log(` ${typeNames.length} standard entities (${rawNames.length} total)`);
const batches = chunk(typeNames, batchSize);
const batchResults: EntityMetadataEntry[][] = [];
for (let i = 0; i < batches.length; i++) {
const batch = batches[i]!;
log(` batch ${i + 1}/${batches.length} (${batch.length} types)...`);
batchResults.push(await describeMetadata(http, batch, flags));
}
return mergeEntries(batchResults);
}
export async function fetchMetadataCatalog(
http: HttpClient,
options: FetchMetadataCatalogOptions = {},
): Promise<MetadataCatalog> {
const flags = options.flags ?? DEFAULT_FLAGS;
const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
const log = options.onProgress ?? (() => {});
let entries: EntityMetadataEntry[];
try {
log("describeMetadata (all entities)...");
entries = await describeMetadata(http, undefined, flags);
if (entries.length === 0) {
entries = await fetchBatched(http, flags, batchSize, log);
}
} catch (error) {
if (needsBatchedFetch(error)) {
entries = await fetchBatched(http, flags, batchSize, log);
} else {
throw error;
}
}
return {
fetchedAt: new Date().toISOString(),
entities: entries,
};
}
+38
View File
@@ -0,0 +1,38 @@
import type { EntityDescription } from "../clarizen-api-types.js";
import type { EntityMetadataEntry } from "./types.js";
/** Standard (non-custom) metadata names exclude C_ and R_ segments. */
export function isStandardMetadataName(name: string): boolean {
return !name.includes("C_") && !name.includes("R_");
}
export function filterStandardTypeNames(typeNames: string[]): string[] {
return typeNames.filter(isStandardMetadataName);
}
export function sanitizeEntityDescription(
desc: EntityDescription,
): EntityMetadataEntry | null {
const typeName = desc.typeName;
if (!typeName || !isStandardMetadataName(typeName)) {
return null;
}
const fields = (desc.fields ?? []).filter(
(f) => f.name && isStandardMetadataName(f.name),
);
const relations = (desc.relations ?? []).filter(
(r) =>
r.name &&
isStandardMetadataName(r.name) &&
(!r.relatedTypeName || isStandardMetadataName(r.relatedTypeName)),
);
return {
typeName,
label: desc.label,
fields,
relations,
};
}
+70
View File
@@ -0,0 +1,70 @@
import type { MetadataCatalog } from "./types.js";
function escapeString(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function objectKey(name: string): string {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : JSON.stringify(name);
}
export function generateMetadataTypesFile(catalog: MetadataCatalog): string {
const entityTypes = catalog.entities.map((e) => e.typeName);
const fieldsLines = catalog.entities.map((e) => {
const names = e.fields
.map((f) => f.name)
.filter((n): n is string => Boolean(n));
const literals = names.map((n) => `"${escapeString(n)}"`).join(", ");
return ` ${objectKey(e.typeName)}: [${literals}] as const`;
});
const relationsLines = catalog.entities.map((e) => {
const rels = e.relations
.filter((r) => r.name && r.relatedTypeName)
.map(
(r) =>
`{ name: "${escapeString(r.name!)}", relatedTypeName: "${escapeString(r.relatedTypeName!)}" }`,
);
return ` ${objectKey(e.typeName)}: [${rels.join(", ")}] as const`;
});
return `// Auto-generated by npm run scrape:metadata. Do not edit manually.
/* eslint-disable */
export const ENTITY_TYPES = [
${entityTypes.map((t) => ` "${escapeString(t)}",`).join("\n")}
] as const;
export type EntityTypeName = (typeof ENTITY_TYPES)[number];
export const FIELDS_BY_ENTITY = {
${fieldsLines.join(",\n")},
} as const;
export type EntityFieldName<T extends EntityTypeName> =
(typeof FIELDS_BY_ENTITY)[T][number];
export const RELATIONS_BY_ENTITY = {
${relationsLines.join(",\n")},
} as const;
export type EntityRelationEntry<T extends EntityTypeName> =
(typeof RELATIONS_BY_ENTITY)[T][number];
export type EntityRelationName<T extends EntityTypeName> =
EntityRelationEntry<T>["name"];
export function entityFields<T extends EntityTypeName>(
typeName: T,
): readonly EntityFieldName<T>[] {
return FIELDS_BY_ENTITY[typeName];
}
export function entityRelations<T extends EntityTypeName>(
typeName: T,
): readonly EntityRelationEntry<T>[] {
return RELATIONS_BY_ENTITY[typeName];
}
`;
}
+15
View File
@@ -0,0 +1,15 @@
export { fetchMetadataCatalog } from "./catalog.js";
export {
filterStandardTypeNames,
isStandardMetadataName,
sanitizeEntityDescription,
} from "./filter.js";
export type {
DescribeMetadataParams,
DescribeMetadataResult,
EntityMetadataEntry,
FetchMetadataCatalogOptions,
ListEntitiesResult,
MetadataCatalog,
MetadataFlag,
} from "./types.js";
+67
View File
@@ -0,0 +1,67 @@
import type { HttpClient, QueryParamValue } from "../http.js";
import {
createNamespaceService,
type OperationCall,
} from "../service.js";
import type { JsonObject } from "../types.js";
import { fetchMetadataCatalog } from "./catalog.js";
import type {
DescribeMetadataParams,
DescribeMetadataResult,
FetchMetadataCatalogOptions,
ListEntitiesResult,
MetadataCatalog,
} from "./types.js";
function describeMetadataQuery(
params?: DescribeMetadataParams,
): Record<string, QueryParamValue> | undefined {
if (!params) return undefined;
const query: Record<string, QueryParamValue> = {};
if (params.typeNames?.length) {
query.typeNames = params.typeNames;
}
if (params.flags?.length) {
query.flags = params.flags;
}
return Object.keys(query).length > 0 ? query : undefined;
}
export interface MetadataService {
listEntities: () => Promise<ListEntitiesResult>;
describeMetadata: (
params?: DescribeMetadataParams,
) => Promise<DescribeMetadataResult>;
fetchCatalog: (
options?: FetchMetadataCatalogOptions,
) => Promise<MetadataCatalog>;
(
operation: string,
body?: JsonObject,
query?: Record<string, QueryParamValue>,
): Promise<unknown>;
[operation: string]:
| OperationCall
| (() => Promise<ListEntitiesResult>)
| ((params?: DescribeMetadataParams) => Promise<DescribeMetadataResult>)
| ((options?: FetchMetadataCatalogOptions) => Promise<MetadataCatalog>);
}
export function createMetadataService(http: HttpClient): MetadataService {
const base = createNamespaceService(http, "metadata");
const service = base as MetadataService;
service.listEntities = () =>
base.listEntities(undefined, undefined) as Promise<ListEntitiesResult>;
service.describeMetadata = (params?: DescribeMetadataParams) =>
base.describeMetadata(
undefined,
describeMetadataQuery(params),
) as Promise<DescribeMetadataResult>;
service.fetchCatalog = (options?: FetchMetadataCatalogOptions) =>
fetchMetadataCatalog(http, options);
return service;
}
+38
View File
@@ -0,0 +1,38 @@
import type {
EntityDescription,
FieldDescription,
RelationDescription,
} from "../clarizen-api-types.js";
export type MetadataFlag = "fields" | "relations";
export interface ListEntitiesResult {
typeNames: string[];
}
export interface DescribeMetadataParams {
typeNames?: string[];
flags?: MetadataFlag[];
}
export interface DescribeMetadataResult {
entityDescriptions: EntityDescription[];
}
export interface EntityMetadataEntry {
typeName: string;
label?: string;
fields: FieldDescription[];
relations: RelationDescription[];
}
export interface MetadataCatalog {
fetchedAt: string;
entities: EntityMetadataEntry[];
}
export interface FetchMetadataCatalogOptions {
flags?: MetadataFlag[];
batchSize?: number;
onProgress?: (message: string) => void;
}
+51
View File
@@ -0,0 +1,51 @@
import type { Entity, EntityId } from "./clarizen-api-types.js";
import type { HttpClient } from "./http.js";
import type { JsonObject } from "./types.js";
export function entityObjectPath(typeName: string, id?: string): string {
const type = encodeURIComponent(typeName);
if (id) {
const entityId = id.includes("/") ? id.split("/").pop()! : id;
return `/V2.0/services/data/objects/${type}/${encodeURIComponent(entityId)}`;
}
return `/V2.0/services/data/objects/${type}`;
}
export interface EntityGetOptions {
fields?: string[];
}
export class EntityObjectsService {
constructor(private readonly http: HttpClient) {}
get(typeName: string, id: EntityId, options?: EntityGetOptions): Promise<Entity> {
const query: Record<string, string> = {};
if (options?.fields?.length) {
query.fields = options.fields.join(",");
}
return this.http.request<Entity>(entityObjectPath(typeName, id), {
method: "GET",
query,
});
}
create(typeName: string, fields: JsonObject): Promise<{ id: EntityId }> {
return this.http.request<{ id: EntityId }>(entityObjectPath(typeName), {
method: "PUT",
body: fields,
});
}
update(typeName: string, id: EntityId, fields: JsonObject): Promise<JsonObject> {
return this.http.request(entityObjectPath(typeName, id), {
method: "POST",
body: fields,
});
}
delete(typeName: string, id: EntityId): Promise<JsonObject> {
return this.http.request(entityObjectPath(typeName, id), {
method: "DELETE",
});
}
}
+57
View File
@@ -0,0 +1,57 @@
import { ENDPOINTS } from "./endpoints.js";
import type { HttpClient, QueryParamValue } from "./http.js";
import type { EndpointDefinition, HttpMethod, JsonObject } from "./types.js";
function toCamelCase(name: string): string {
return name.charAt(0).toLowerCase() + name.slice(1);
}
function pickMethod(endpoint: EndpointDefinition): HttpMethod {
if (endpoint.httpMethods.includes("POST")) return "POST";
if (endpoint.httpMethods.includes("GET")) return "GET";
if (endpoint.httpMethods.includes("PUT")) return "PUT";
return endpoint.httpMethods[0];
}
export type OperationCall = <T = JsonObject>(
body?: JsonObject,
query?: Record<string, QueryParamValue>,
) => Promise<T>;
export type ServiceCall = <T = JsonObject>(
operation: string,
body?: JsonObject,
query?: Record<string, QueryParamValue>,
) => Promise<T>;
export type NamespaceService = ServiceCall & Record<string, OperationCall>;
export function createNamespaceService(
http: HttpClient,
namespace: string,
): NamespaceService {
const endpoints = ENDPOINTS.filter((e) => e.namespace === namespace);
const handler: ServiceCall = async (operation, body, query) => {
const endpoint = endpoints.find(
(e) => e.operation.toLowerCase() === operation.toLowerCase(),
);
if (!endpoint) {
throw new Error(`Unknown ${namespace} operation: ${operation}`);
}
return http.request(endpoint.path, {
method: pickMethod(endpoint),
body,
query,
});
};
const service = handler as NamespaceService;
for (const endpoint of endpoints) {
const methodName = toCamelCase(endpoint.operation);
service[methodName] = (body?, query?) =>
handler(endpoint.operation, body, query);
}
return service;
}
+75
View File
@@ -0,0 +1,75 @@
import type {
Condition,
Entity,
EntityQuery,
ErrorCode,
LoginOptions,
Paging,
} from "./clarizen-api-types.js";
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonObject = { [key: string]: JsonValue };
export interface ClarizenErrorBody {
errorCode: ErrorCode | string;
message: string;
referenceId?: string;
}
export interface LoginParams {
userName: string;
password: string;
loginOptions?: LoginOptions;
}
export interface ServerDefinition {
serverLocation: string;
appLocation?: string;
organizationId?: number;
}
export interface LoginResult {
sessionId: string;
userId: string;
organizationId: string;
licenseType: string;
serverTime?: string;
}
export interface SessionInfo {
sessionId: string;
userId: string;
organizationId: string;
serverTime?: string;
licenseType?: string;
customInfo?: Array<{ fieldName: string; value: string }>;
}
export type EntityQueryParams = EntityQuery;
export interface EntityQueryResult {
entities: Entity[];
paging?: Paging;
}
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
export interface EndpointDefinition {
namespace: string;
operation: string;
path: string;
httpMethods: HttpMethod[];
description: string;
}
export type AuthMode =
| { type: "apiKey"; token: string }
| { type: "session"; sessionId: string }
| { type: "none" };
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}