diff --git a/README.md b/README.md index 04adc72..71bcb0f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ pnpm add @longsightgroup/oneroster Import REST APIs from the version you need. The v1.1 and v1.2 entry points have separate types and behavior, with no cross-version fallback. +New to the REST API? Start with the [OneRoster REST guide](docs/rest.md), then use the [generated operation catalog](docs/rest-operations.md) to find every client call, HTTP path, query option, and OAuth scope. + CSV support follows the corrected OneRoster CSV Binding 1.2.1. Packages still declare `oneroster.version,1.2` in `manifest.csv`. The 1.2.1 number identifies the CSV document correction level, not a separate manifest or REST version. diff --git a/docs/rest-operations.md b/docs/rest-operations.md new file mode 100644 index 0000000..6133b61 --- /dev/null +++ b/docs/rest-operations.md @@ -0,0 +1,125 @@ +# OneRoster 1.2 REST operation catalog + + + +This catalog maps the official OneRoster 1.2 OpenAPI operations to the methods exposed by `@longsightgroup/oneroster/v1p2`. Start with the [REST guide](./rest.md) if you have not configured a client yet. + +Paths in the tables are relative to the client family's service base URL. OAuth scope names omit the shared `https://purl.imsglobal.org/spec/or/v1p2/scope/` prefix. The client passes every scope listed for an operation to the configured access-token provider. + +| Client family | Factory | Service base path | +| -------------------------- | -------------------------------------------- | ------------------------------- | +| Rostering | `createOneRosterV1p2RosteringClient` | `/ims/oneroster/rostering/v1p2` | +| Gradebook | `createOneRosterV1p2GradebookClient` | `/ims/oneroster/gradebook/v1p2` | +| Assessment Results Profile | `createOneRosterV1p2AssessmentResultsClient` | `/ims/oneroster/gradebook/v1p2` | +| Resources | `createOneRosterV1p2ResourcesClient` | `/ims/oneroster/resources/v1p2` | + +Collection reads accept `limit`, `offset`, `sort`, `orderBy`, `filter`, and `fields` unless a row says otherwise. The client also exposes an `iterate…` method for every collection operation. Rostering collection operations additionally expose bounded `collect…` methods. + +## Rostering + +Create this client with `createOneRosterV1p2RosteringClient`. + +| TypeScript call | HTTP | Path | Query | Success | OAuth scopes | +| ---------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | -------------------------------------------------------- | ------- | ----------------------------------------- | +| `client.getAllAcademicSessions(options?)` | GET | `/academicSessions` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getAcademicSession(sourcedId, options?)` | GET | `/academicSessions/{sourcedId}` | `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getAllClasses(options?)` | GET | `/classes` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getStudentsForClass(classSourcedId, options?)` | GET | `/classes/{classSourcedId}/students` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getTeachersForClass(classSourcedId, options?)` | GET | `/classes/{classSourcedId}/teachers` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getClass(sourcedId, options?)` | GET | `/classes/{sourcedId}` | `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getAllCourses(options?)` | GET | `/courses` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getClassesForCourse(courseSourcedId, options?)` | GET | `/courses/{courseSourcedId}/classes` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getCourse(sourcedId, options?)` | GET | `/courses/{sourcedId}` | `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getAllDemographics(options?)` | GET | `/demographics` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-demographics.readonly` | +| `client.getDemographics(sourcedId, options?)` | GET | `/demographics/{sourcedId}` | `fields` | 200 | `roster-demographics.readonly` | +| `client.getAllEnrollments(options?)` | GET | `/enrollments` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getEnrollment(sourcedId, options?)` | GET | `/enrollments/{sourcedId}` | `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getAllGradingPeriods(options?)` | GET | `/gradingPeriods` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getGradingPeriod(sourcedId, options?)` | GET | `/gradingPeriods/{sourcedId}` | `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getAllOrgs(options?)` | GET | `/orgs` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getOrg(sourcedId, options?)` | GET | `/orgs/{sourcedId}` | `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getAllSchools(options?)` | GET | `/schools` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getClassesForSchool(schoolSourcedId, options?)` | GET | `/schools/{schoolSourcedId}/classes` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getEnrollmentsForClassInSchool(schoolSourcedId, classSourcedId, options?)` | GET | `/schools/{schoolSourcedId}/classes/{classSourcedId}/enrollments` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getStudentsForClassInSchool(schoolSourcedId, classSourcedId, options?)` | GET | `/schools/{schoolSourcedId}/classes/{classSourcedId}/students` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getTeachersForClassInSchool(schoolSourcedId, classSourcedId, options?)` | GET | `/schools/{schoolSourcedId}/classes/{classSourcedId}/teachers` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getCoursesForSchool(schoolSourcedId, options?)` | GET | `/schools/{schoolSourcedId}/courses` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getEnrollmentsForSchool(schoolSourcedId, options?)` | GET | `/schools/{schoolSourcedId}/enrollments` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getStudentsForSchool(schoolSourcedId, options?)` | GET | `/schools/{schoolSourcedId}/students` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getTeachersForSchool(schoolSourcedId, options?)` | GET | `/schools/{schoolSourcedId}/teachers` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getTermsForSchool(schoolSourcedId, options?)` | GET | `/schools/{schoolSourcedId}/terms` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getSchool(sourcedId, options?)` | GET | `/schools/{sourcedId}` | `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getAllStudents(options?)` | GET | `/students` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getStudent(sourcedId, options?)` | GET | `/students/{sourcedId}` | `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getClassesForStudent(studentSourcedId, options?)` | GET | `/students/{studentSourcedId}/classes` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getAllTeachers(options?)` | GET | `/teachers` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getTeacher(sourcedId, options?)` | GET | `/teachers/{sourcedId}` | `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getClassesForTeacher(teacherSourcedId, options?)` | GET | `/teachers/{teacherSourcedId}/classes` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getAllTerms(options?)` | GET | `/terms` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getTerm(sourcedId, options?)` | GET | `/terms/{sourcedId}` | `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getClassesForTerm(termSourcedId, options?)` | GET | `/terms/{termSourcedId}/classes` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getGradingPeriodsForTerm(termSourcedId, options?)` | GET | `/terms/{termSourcedId}/gradingPeriods` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | +| `client.getAllUsers(options?)` | GET | `/users` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getUser(sourcedId, options?)` | GET | `/users/{sourcedId}` | `fields` | 200 | `roster-core.readonly`, `roster.readonly` | +| `client.getClassesForUser(userSourcedId, options?)` | GET | `/users/{userSourcedId}/classes` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `roster.readonly` | + +## Gradebook + +Create this client with `createOneRosterV1p2GradebookClient`. + +| TypeScript call | HTTP | Path | Query | Success | OAuth scopes | +| ----------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------- | -------------------------------------------------------- | ------- | ----------------------------------------------- | +| `client.getAllCategories(options?)` | GET | `/categories` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `gradebook-core.readonly`, `gradebook.readonly` | +| `client.getCategory(sourcedId, options?)` | GET | `/categories/{sourcedId}` | `fields` | 200 | `gradebook-core.readonly`, `gradebook.readonly` | +| `client.deleteCategory(sourcedId, { signal })` | DELETE | `/categories/{sourcedId}` | — | 204 | `gradebook.delete` | +| `client.putCategory(sourcedId, entity, { signal })` | PUT | `/categories/{sourcedId}` | — | 201 | `gradebook.createput` | +| `client.postResultsForAcademicSessionForClass(classSourcedId, academicSessionSourcedId, items, { signal })` | POST | `/classes/{classSourcedId}/academicSessions/{academicSessionSourcedId}/results` | — | 201 | `gradebook.createpost` | +| `client.getCategoriesForClass(classSourcedId, options?)` | GET | `/classes/{classSourcedId}/categories` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `gradebook.readonly` | +| `client.getLineItemsForClass(classSourcedId, options?)` | GET | `/classes/{classSourcedId}/lineItems` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `gradebook.readonly` | +| `client.postLineItemsForClass(classSourcedId, items, { signal })` | POST | `/classes/{classSourcedId}/lineItems` | — | 201 | `gradebook.createpost` | +| `client.getResultsForLineItemForClass(classSourcedId, lineItemSourcedId, options?)` | GET | `/classes/{classSourcedId}/lineItems/{lineItemSourcedId}/results` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `gradebook.readonly` | +| `client.getResultsForClass(classSourcedId, options?)` | GET | `/classes/{classSourcedId}/results` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `gradebook.readonly` | +| `client.getScoreScalesForClass(classSourcedId, options?)` | GET | `/classes/{classSourcedId}/scoreScales` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `gradebook.readonly` | +| `client.getResultsForStudentForClass(classSourcedId, studentSourcedId, options?)` | GET | `/classes/{classSourcedId}/students/{studentSourcedId}/results` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `gradebook.readonly` | +| `client.getAllLineItems(options?)` | GET | `/lineItems` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `gradebook-core.readonly`, `gradebook.readonly` | +| `client.postResultsForLineItem(lineItemSourcedId, items, { signal })` | POST | `/lineItems/{lineItemSourcedId}/results` | — | 201 | `gradebook.createpost` | +| `client.getLineItem(sourcedId, options?)` | GET | `/lineItems/{sourcedId}` | `fields` | 200 | `gradebook-core.readonly`, `gradebook.readonly` | +| `client.deleteLineItem(sourcedId, { signal })` | DELETE | `/lineItems/{sourcedId}` | — | 204 | `gradebook.delete` | +| `client.putLineItem(sourcedId, entity, { signal })` | PUT | `/lineItems/{sourcedId}` | — | 201 | `gradebook.createput` | +| `client.getAllResults(options?)` | GET | `/results` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `gradebook-core.readonly`, `gradebook.readonly` | +| `client.getResult(sourcedId, options?)` | GET | `/results/{sourcedId}` | `fields` | 200 | `gradebook-core.readonly`, `gradebook.readonly` | +| `client.deleteResult(sourcedId, { signal })` | DELETE | `/results/{sourcedId}` | — | 204 | `gradebook.delete` | +| `client.putResult(sourcedId, entity, { signal })` | PUT | `/results/{sourcedId}` | — | 201 | `gradebook.createput` | +| `client.postLineItemsForSchool(schoolSourcedId, items, { signal })` | POST | `/schools/{schoolSourcedId}/lineItems` | — | 201 | `gradebook.createpost` | +| `client.getScoreScalesForSchool(schoolSourcedId, options?)` | GET | `/schools/{schoolSourcedId}/scoreScales` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `gradebook.readonly` | +| `client.getAllScoreScales(options?)` | GET | `/scoreScales` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `gradebook-core.readonly`, `gradebook.readonly` | +| `client.getScoreScale(sourcedId, options?)` | GET | `/scoreScales/{sourcedId}` | `fields` | 200 | `gradebook-core.readonly`, `gradebook.readonly` | +| `client.deleteScoreScale(sourcedId, { signal })` | DELETE | `/scoreScales/{sourcedId}` | — | 204 | `gradebook.delete` | +| `client.putScoreScale(sourcedId, entity, { signal })` | PUT | `/scoreScales/{sourcedId}` | — | 201 | `gradebook.createput` | + +## Assessment Results Profile + +Create this client with `createOneRosterV1p2AssessmentResultsClient`. + +| TypeScript call | HTTP | Path | Query | Success | OAuth scopes | +| ------------------------------------------------------------- | ------ | ---------------------------------- | -------------------------------------------------------- | ------- | ---------------------- | +| `client.getAllAssessmentLineItems(options?)` | GET | `/assessmentLineItems` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `assessment.readonly` | +| `client.getAssessmentLineItem(sourcedId, options?)` | GET | `/assessmentLineItems/{sourcedId}` | `fields` | 200 | `assessment.readonly` | +| `client.deleteAssessmentLineItem(sourcedId, { signal })` | DELETE | `/assessmentLineItems/{sourcedId}` | — | 204 | `assessment.delete` | +| `client.putAssessmentLineItem(sourcedId, entity, { signal })` | PUT | `/assessmentLineItems/{sourcedId}` | — | 201 | `assessment.createput` | +| `client.getAllAssessmentResults(options?)` | GET | `/assessmentResults` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `assessment.readonly` | +| `client.getAssessmentResult(sourcedId, options?)` | GET | `/assessmentResults/{sourcedId}` | `fields` | 200 | `assessment.readonly` | +| `client.deleteAssessmentResult(sourcedId, { signal })` | DELETE | `/assessmentResults/{sourcedId}` | — | 204 | `assessment.delete` | +| `client.putAssessmentResult(sourcedId, entity, { signal })` | PUT | `/assessmentResults/{sourcedId}` | — | 201 | `assessment.createput` | + +## Resources + +Create this client with `createOneRosterV1p2ResourcesClient`. + +| TypeScript call | HTTP | Path | Query | Success | OAuth scopes | +| --------------------------------------------------------- | ---- | -------------------------------------- | -------------------------------------------------------- | ------- | --------------------------------------------- | +| `client.getResourcesForClass(classSourcedId, options?)` | GET | `/classes/{classSourcedId}/resources` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `resource.readonly` | +| `client.getResourcesForCourse(courseSourcedId, options?)` | GET | `/courses/{courseSourcedId}/resources` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `resource.readonly` | +| `client.getAllResources(options?)` | GET | `/resources` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `resource-core.readonly`, `resource.readonly` | +| `client.getResource(sourcedId, options?)` | GET | `/resources/{sourcedId}` | `fields` | 200 | `resource-core.readonly`, `resource.readonly` | +| `client.getResourcesForUser(userSourcedId, options?)` | GET | `/users/{userSourcedId}/resources` | `limit`, `offset`, `sort`, `orderBy`, `filter`, `fields` | 200 | `resource.readonly` | diff --git a/docs/rest.md b/docs/rest.md new file mode 100644 index 0000000..28afe5e --- /dev/null +++ b/docs/rest.md @@ -0,0 +1,201 @@ +# OneRoster REST with TypeScript + +Use the versioned `@longsightgroup/oneroster/v1p2` entry point for OneRoster 1.2 REST calls. The package provides four clients: + +| What you need | Client | +| -------------------------------------------------------- | -------------------------------------------- | +| Users, classes, courses, schools, terms, and enrollments | `createOneRosterV1p2RosteringClient` | +| Categories, line items, results, and score scales | `createOneRosterV1p2GradebookClient` | +| Hierarchical assessment line items and results | `createOneRosterV1p2AssessmentResultsClient` | +| Resource allocations for classes, courses, and users | `createOneRosterV1p2ResourcesClient` | + +The [generated operation catalog](./rest-operations.md) lists every client method with its HTTP method, path, query controls, success status, and OAuth scopes. + +## Connect to a OneRoster provider + +OneRoster 1.2 uses OAuth 2 client credentials. Keep the client secret in server-side code; do not ship it in browser JavaScript. + +```ts +import { + createOneRosterV1p2OAuth2ClientCredentialsProvider, + createOneRosterV1p2RosteringClient, +} from "@longsightgroup/oneroster/v1p2"; + +const tokenProvider = createOneRosterV1p2OAuth2ClientCredentialsProvider({ + tokenEndpoint: "https://sis.example/oauth/token", + clientId: yourClientId, + clientSecret: yourClientSecret, + clientAuthentication: "client_secret_basic", + scopes: [ + "https://purl.imsglobal.org/spec/or/v1p2/scope/roster-core.readonly", + "https://purl.imsglobal.org/spec/or/v1p2/scope/roster.readonly", + ], + fetch, +}); + +if (tokenProvider._tag === "err") { + console.error(tokenProvider.error.code); +} else { + const client = createOneRosterV1p2RosteringClient({ + serviceBaseUrls: { + rostering: "https://sis.example/ims/oneroster/rostering/v1p2", + }, + accessTokenProvider: tokenProvider.value, + }); + + if (client._tag === "err") { + console.error(client.error.code, client.error.message); + } else { + const users = await client.value.getAllUsers({ + query: { limit: 100 }, + signal: AbortSignal.timeout(30_000), + }); + + if (users._tag === "err") console.error(users.error._tag); + else console.log(users.value.items); + } +} +``` + +The package itself reads no environment variables. The host application passes credentials into the portable client. + +## Perform a complete roster sync + +Rostering clients provide bounded `collectAll*` methods when the complete result can fit in memory. Always set both limits so a bad pagination response cannot make the sync unbounded. + +```ts +const bounds = { + maxPages: 100, + maxItems: 100_000, + signal: AbortSignal.timeout(120_000), +}; + +const [users, classes, enrollments] = await Promise.all([ + client.collectAllUsers(bounds), + client.collectAllClasses(bounds), + client.collectAllEnrollments(bounds), +]); + +for (const result of [users, classes, enrollments]) { + if (result._tag === "err") { + console.error(result.error._tag); + break; + } +} +``` + +For larger datasets, use `iterateAllUsers`, `iterateAllClasses`, or `iterateAllEnrollments`. Each async iterator yields one parsed page, so the application can process data without retaining the full dataset. + +## Find a student's classes + +Relationship operations are named after the corresponding OneRoster operation. Pass path identifiers first and request options last. + +```ts +const classes = await client.getClassesForStudent(studentSourcedId, { + query: { limit: 100, fields: ["sourcedId", "title", "course", "school"] }, + signal: AbortSignal.timeout(30_000), +}); + +if (classes._tag === "ok") { + for (const oneRosterClass of classes.value.items) { + console.log(oneRosterClass.sourcedId, oneRosterClass.title); + } +} +``` + +Use `iterateClassesForStudent` when the relationship may span more than one page. + +## Pass a grade back + +Gradebook writes require an `AbortSignal`. `PUT` checks that the path `sourcedId` matches the entity before sending the request. + +```ts +import { createOneRosterV1p2GradebookClient } from "@longsightgroup/oneroster/v1p2"; + +const configured = createOneRosterV1p2GradebookClient({ + serviceBaseUrls: { + gradebook: "https://sis.example/ims/oneroster/gradebook/v1p2", + }, + accessTokenProvider, +}); + +if (configured._tag === "ok") { + const signal = AbortSignal.timeout(30_000); + const written = await configured.value.putResult(result.sourcedId, result, { signal }); + + if (written._tag === "err") console.error(written.error._tag); + else console.log(written.value.status); +} +``` + +Use `postResultsForLineItem` or `postResultsForAcademicSessionForClass` to create a collection. Those calls return the provider's parsed sourced-ID pairs. + +## Read assigned resources + +The Resources client uses its own service base URL and scope. + +```ts +import { createOneRosterV1p2ResourcesClient } from "@longsightgroup/oneroster/v1p2"; + +const configured = createOneRosterV1p2ResourcesClient({ + serviceBaseUrls: { + resources: "https://sis.example/ims/oneroster/resources/v1p2", + }, + accessTokenProvider, +}); + +if (configured._tag === "ok") { + const resources = await configured.value.getResourcesForClass(classSourcedId, { + query: { limit: 100 }, + signal: AbortSignal.timeout(30_000), + }); +} +``` + +## Query collections + +Collection reads support pagination, sorting, filtering, and field projection. Build filters with the exported filter constructors instead of assembling filter syntax by hand. + +```ts +import { createOneRosterV1p2EqualsFilter } from "@longsightgroup/oneroster/v1p2"; + +const active = createOneRosterV1p2EqualsFilter("status", "active"); +if (active._tag === "ok") { + const page = await client.getAllUsers({ + query: { + limit: 100, + sort: "familyName", + orderBy: "asc", + filter: active.value, + fields: ["sourcedId", "givenName", "familyName"], + }, + }); +} +``` + +Field projections are checked by TypeScript and narrow the returned entity type. + +## Handle failures + +Configuration and REST operations return `Result` values for expected failures. Check `_tag` before using the value. REST error tags distinguish HTTP failures, invalid JSON, invalid payloads, pagination problems, cancellation, authentication, and other boundary failures. Diagnostics do not retain response bodies or credentials. + +Read retries are opt-in and apply only to `GET`. Gradebook mutations are never retried automatically. + +## Inspect raw HTTP with OpenAPI and Swagger + +The generated catalog is the package reference. Use an OpenAPI viewer such as Swagger UI or Scalar when you need raw request and response schemas, or want to compare the package with a provider's HTTP API. + +Official OneRoster 1.2 OpenAPI 3 documents: + +- [Rostering](https://purl.imsglobal.org/spec/or/v1p2/schema/openapi/onerosterv1p2rostersservice_openapi3_v1p0.json) +- [Gradebook](https://purl.imsglobal.org/spec/or/v1p2/schema/openapi/onerosterv1p2gradebookservice_openapi3_v1p0.json) +- [Assessment Results Profile](https://purl.imsglobal.org/spec/or/v1p2/schema/openapi/assessmentresultv1p0service_openapi3_v1p0.json) +- [Resources](https://purl.imsglobal.org/spec/or/v1p2/schema/openapi/onerosterv1p2resourcesservice_openapi3_v1p0.json) + +A real provider should publish localized discovery documents with its server URLs, supported operations, OAuth token endpoint, and scopes. Use `buildOneRosterV1p2DiscoveryUrl`, `readOneRosterV1p2Discovery`, and `checkOneRosterV1p2DiscoveryCapabilities` to inspect that contract in code. Prefer the provider's localized document when testing against that provider. + +Do not paste production access tokens or client secrets into a third-party OpenAPI viewer. Host the viewer yourself if you need authenticated “Try it out” requests. + +## OneRoster 1.1 + +Import OneRoster 1.1 compatibility APIs from `@longsightgroup/oneroster/v1p1`. Version 1.1 uses a different authentication and service contract, so do not reuse the 1.2 examples unchanged. diff --git a/package.json b/package.json index 8b45b9b..46b98c8 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "files": [ "dist", + "docs", "README.md", "CHANGELOG.md" ], @@ -42,14 +43,16 @@ }, "scripts": { "build": "pnpm run clean && tsc -p tsconfig.build.json", - "check": "pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test", + "check": "pnpm run docs:rest:check && pnpm run format:check && pnpm run lint && pnpm run typecheck && pnpm run test", "clean": "rm -rf dist coverage", "format": "oxfmt --write .", "format:check": "oxfmt --check .", "lint": "oxlint . --type-aware --report-unused-disable-directives --deny-warnings", "prepublishOnly": "pnpm run check && pnpm run test:portability && pnpm run build", "generate:v1p1-operation": "node tools/generate-v1p1-operation.mjs && oxfmt --write src/v1p1/rest/operation.generated.ts", - "generate:v1p2-operation": "node tools/generate-v1p2-operation.mjs && oxfmt --write src/v1p2/rest/operation.generated.ts src/v1p2/rest/payload.generated.ts", + "generate:v1p2-operation": "node tools/generate-v1p2-operation.mjs && oxfmt --write src/v1p2/rest/operation.generated.ts src/v1p2/rest/payload.generated.ts && pnpm run docs:rest", + "docs:rest": "node tools/generate-rest-operation-docs.mjs && oxfmt --write docs/rest.md", + "docs:rest:check": "node tools/generate-rest-operation-docs.mjs --check", "rest:spec-check": "node tools/generate-v1p1-operation.mjs --refresh --check && pnpm run generate:v1p2-operation && pnpm run build && node tools/check-v1p2-openapi.mjs", "compatibility:v1p1": "pnpm run build && node tools/report-v1p1-v1p2-compatibility.mjs", "csv:rostering-cert-check": "pnpm run build && node tools/check-csv-rostering-conformance.mjs", diff --git a/tools/generate-rest-operation-docs.mjs b/tools/generate-rest-operation-docs.mjs new file mode 100644 index 0000000..3a1d93a --- /dev/null +++ b/tools/generate-rest-operation-docs.mjs @@ -0,0 +1,186 @@ +/* oxlint-disable import/no-nodejs-modules, eslint/no-console */ +import { readFile, writeFile } from "node:fs/promises"; + +const sourceUrl = new URL("../src/v1p2/rest/operation.generated.ts", import.meta.url); +const outputUrl = new URL("../docs/rest-operations.md", import.meta.url); +const check = process.argv.includes("--check"); + +const source = await readFile(sourceUrl, "utf8"); +const queryGroups = readGeneratedGroupObject( + source, + "const queryGroups = ", + " as const;\nconst scopeGroups", +); +const scopeGroups = readGeneratedGroupObject( + source, + "const scopeGroups = ", + " as const;\n\ntype GeneratedOperationData", +); +const operationData = readGeneratedJson( + source, + "// oxfmt-ignore\nconst operationData = ", + " as const satisfies ReadonlyArray;", +); + +const operations = operationData.map( + ([ + service, + operationId, + providerKind, + method, + path, + responseKind, + queryGroup, + scopeGroup, + successStatuses, + ]) => ({ + service, + operationId, + providerKind, + method, + path, + responseKind, + allowedQuery: queryGroups[queryGroup], + requiredScopes: scopeGroups[scopeGroup], + successStatuses, + }), +); + +const groups = [ + { + kind: "rostering", + heading: "Rostering", + factory: "createOneRosterV1p2RosteringClient", + basePath: "/ims/oneroster/rostering/v1p2", + }, + { + kind: "gradebook", + heading: "Gradebook", + factory: "createOneRosterV1p2GradebookClient", + basePath: "/ims/oneroster/gradebook/v1p2", + }, + { + kind: "assessmentResults", + heading: "Assessment Results Profile", + factory: "createOneRosterV1p2AssessmentResultsClient", + basePath: "/ims/oneroster/gradebook/v1p2", + }, + { + kind: "resources", + heading: "Resources", + factory: "createOneRosterV1p2ResourcesClient", + basePath: "/ims/oneroster/resources/v1p2", + }, +]; + +const sections = groups.map((group) => operationSection(group, operations)).join("\n\n"); +const output = `# OneRoster 1.2 REST operation catalog + + + +This catalog maps the official OneRoster 1.2 OpenAPI operations to the methods exposed by \`@longsightgroup/oneroster/v1p2\`. Start with the [REST guide](./rest.md) if you have not configured a client yet. + +Paths in the tables are relative to the client family's service base URL. OAuth scope names omit the shared \`https://purl.imsglobal.org/spec/or/v1p2/scope/\` prefix. The client passes every scope listed for an operation to the configured access-token provider. + +${markdownTable( + ["Client family", "Factory", "Service base path"], + groups.map((group) => [group.heading, `\`${group.factory}\``, `\`${group.basePath}\``]), +)} + +Collection reads accept \`limit\`, \`offset\`, \`sort\`, \`orderBy\`, \`filter\`, and \`fields\` unless a row says otherwise. The client also exposes an \`iterate…\` method for every collection operation. Rostering collection operations additionally expose bounded \`collect…\` methods. + +${sections} +`; + +if (check) { + const current = await readFile(outputUrl, "utf8").catch((error) => { + if (error?.code === "ENOENT") return undefined; + throw error; + }); + if (current !== output) { + console.error("docs/rest-operations.md is stale. Run pnpm run docs:rest."); + process.exitCode = 1; + } else { + console.log(`REST operation documentation is current (${operations.length} operations).`); + } +} else { + await writeFile(outputUrl, output); + console.log(`Generated ${operations.length} REST operations at ${outputUrl.pathname}`); +} + +function readGeneratedJson(text, startMarker, endMarker) { + return JSON.parse(readGeneratedText(text, startMarker, endMarker)); +} + +function readGeneratedGroupObject(text, startMarker, endMarker) { + const value = readGeneratedText(text, startMarker, endMarker) + .replace(/^(\s*)(g\d+):/gm, '$1"$2":') + .replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(value); +} + +function readGeneratedText(text, startMarker, endMarker) { + const start = text.indexOf(startMarker); + if (start < 0) throw new Error(`Missing generated marker: ${startMarker}`); + const valueStart = start + startMarker.length; + const end = text.indexOf(endMarker, valueStart); + if (end < 0) throw new Error(`Missing generated marker: ${endMarker}`); + return text.slice(valueStart, end); +} + +function operationSection(group, values) { + const rows = values + .filter((operation) => operation.providerKind === group.kind) + .map((operation) => [ + `\`${clientCall(operation)}\``, + operation.method, + `\`${operation.path}\``, + displayQuery(operation.allowedQuery), + operation.successStatuses, + displayScopes(operation.requiredScopes), + ]); + return `## ${group.heading} + +Create this client with \`${group.factory}\`. + +${markdownTable(["TypeScript call", "HTTP", "Path", "Query", "Success", "OAuth scopes"], rows)}`; +} + +function clientCall(operation) { + const pathParameters = Array.from(operation.path.matchAll(/\{([^}]+)\}/g), (match) => match[1]); + if (operation.method === "GET") + return call(operation.operationId, [...pathParameters, "options?"]); + if (operation.method === "DELETE") + return call(operation.operationId, [...pathParameters, "{ signal }"]); + const payload = operation.method === "POST" ? "items" : "entity"; + return call(operation.operationId, [...pathParameters, payload, "{ signal }"]); +} + +function call(operationId, parameters) { + return `client.${operationId}(${parameters.join(", ")})`; +} + +function displayQuery(query) { + return query.length === 0 ? "—" : query.map((value) => `\`${value}\``).join(", "); +} + +function displayScopes(scopes) { + const prefix = "https://purl.imsglobal.org/spec/or/v1p2/scope/"; + return scopes + .map((scope) => `\`${scope.startsWith(prefix) ? scope.slice(prefix.length) : scope}\``) + .join(", "); +} + +function markdownTable(headers, rows) { + const stringRows = rows.map((row) => row.map(String)); + const widths = headers.map((header, index) => + Math.max(3, header.length, ...stringRows.map((row) => row[index]?.length ?? 0)), + ); + const line = (cells) => + `| ${cells.map((cell, index) => cell.padEnd(widths[index])).join(" | ")} |`; + return [ + line(headers), + line(widths.map((width) => "-".repeat(width))), + ...stringRows.map(line), + ].join("\n"); +}