From fc94cb3d07503528e273700133163997a3e06ee3 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Thu, 27 Aug 2026 14:57:16 -0600 Subject: [PATCH 1/3] docs(rest): map exported tables to their automatic REST endpoints Adds a "Tables and Their Automatic Endpoints" section to the REST overview that joins the two facts a reader currently has to assemble from separate pages: a table needs `@table @export` in the schema AND `rest: true` in the application's config.yaml before it answers any HTTP request. `rest` is not in defaultConfig.yaml, so without it the REST handler is never registered and exported tables 404. The section is a compact endpoint table whose rows link down to the existing per-method sections rather than restating them, so PUT/PATCH semantics stay defined in exactly one place. Also adds a one-sentence pointer from the schema reference's `@export` directive to the new section. Closes #538 Co-Authored-By: Claude Opus 5 --- reference/database/schema.md | 2 ++ reference/rest/overview.md | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/reference/database/schema.md b/reference/database/schema.md index e9d94f69b..e2f1d3712 100644 --- a/reference/database/schema.md +++ b/reference/database/schema.md @@ -224,6 +224,8 @@ type MyTable @table @export(name: "my-table") { The optional `name` parameter specifies the URL path segment (e.g., `/my-table/`). Without `name`, the type name is used. +`@export` alone does not serve HTTP traffic — the `rest` plugin must also be enabled in the application's `config.yaml`. See [REST Overview / Tables and Their Automatic Endpoints](../rest/overview.md#tables-and-their-automatic-endpoints) for the endpoints an exported table produces and the `rest: true` requirement. + :::warning `@export` is a routing directive, not an access control Omitting `@export` removes the REST/MQTT route for a table (callers get 404), but it does **not** protect the data. The table still exists in the database and remains accessible through the Operations API and SQL, subject to RBAC, to administrators and roles with the required operation and table permissions. For table-level confidentiality, omit the table from a role's grants or set its table-level `read` permission to `false` rather than relying on the absence of an export route. To protect individual fields, configure `attribute_permissions` as a whitelist: list every field the role may read and omit the restricted field (or list it with `read: false`). Also account for the [filter side-channel on exported Resources](../users-and-roles/overview.md#filter-side-channel-for-read-restricted-attributes). ::: diff --git a/reference/rest/overview.md b/reference/rest/overview.md index 74b755f7d..442af0afd 100644 --- a/reference/rest/overview.md +++ b/reference/rest/overview.md @@ -35,6 +35,53 @@ rest: webSocket: false # disables automatic WebSocket support (enabled by default) ``` +## Tables and Their Automatic Endpoints + +A table is served over REST only when **both** of the following are true: + +1. The table is exported in a schema definition with [`@export`](../database/schema.md#export). +2. The `rest` plugin is enabled in the application's `config.yaml` (see [Configuration](#configuration)). + +```graphql +# schema.graphql +type Product @table @export { + id: Long @primaryKey + name: String + price: Float +} +``` + +```yaml +# config.yaml +graphqlSchema: + files: schema.graphql +rest: true +``` + +Neither half is sufficient on its own. Without `@export` the table has no REST route and callers get `404`. Without `rest: true` the REST handler is never registered for the application, so exported tables do not respond to HTTP requests at all — `rest` is not enabled by default. + +With both in place, the exported name becomes the base path (`Product` above, or the `name` argument of `@export`) and Harper serves the following endpoints on the application HTTP server port (default `9926`). No route definitions, controllers, or handler code are required. + +| Endpoint | Description | Details | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| `GET /Product` | Describes the resource — table name, database, and declared attributes — plus an href to the record collection. No trailing slash. | [GET](#get) | +| `GET /Product/` | The record collection. Append query parameters to search, filter, sort, and page. | [GET](#get), [Querying](./querying.md) | +| `GET /Product/{id}` | A single record by primary key; `404` when no such record exists. | [GET](#get) | +| `GET /Product/{id}.property` | A single property of one record. Only properties declared in the schema. | [GET](#get) | +| `POST /Product/` | Creates a record with a Harper-assigned primary key, returned in the `Location` response header. Requires the trailing slash. | [POST](#post) | +| `PUT /Product/{id}` | Creates or replaces the record at `{id}` (upsert). The stored record matches the body exactly — properties omitted from the body are **removed**. | [PUT](#put) | +| `PATCH /Product/{id}` | Merges the body into the existing record, preserving unspecified properties. The merge is **shallow** — a nested object in the body replaces the stored one wholesale. | [PATCH](#patch) | +| `DELETE /Product/{id}` | Deletes the record at `{id}`. | [DELETE](#delete) | +| `DELETE /Product/?query` | Deletes every record matching the query. With no query parameters, this matches — and deletes — every record in the table. | [DELETE](#delete) | + +Notes on this surface: + +- The trailing slash is significant throughout: `/Product` addresses the resource itself, `/Product/` addresses its collection of records. See [URL Structure](#url-structure). +- `HEAD` behaves as `GET` with the body omitted, and `OPTIONS` reports the resource's supported methods in an `Allow` header. A method a resource does not implement returns `405` with an `Allow` header listing the ones it does. +- Enabling `rest` also enables [WebSocket](./websockets.md) and [Server-Sent Events](./server-sent-events.md) subscriptions on these same resource paths. +- Every exported resource is included in the generated [OpenAPI](#openapi) document. +- Custom resource classes exported from an application get the same URL structure and method mapping; they implement the methods themselves rather than inheriting the table behavior above. See [Resource API](../resources/resource-api.md). + ## URL Structure The REST interface follows a consistent URL structure: From 5681519df9c304702bee7347858bf7d22dc59da3 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Thu, 27 Aug 2026 15:13:28 -0600 Subject: [PATCH 2/3] docs(rest): correct method surface, config-default carve-out, and POST status Three corrections to the endpoints section, each re-verified against harper origin/main c4dd96237: - Remove the incorrect HEAD/OPTIONS/405 note. `allowedMethods()` (resources/Resource.ts:1003) is called with the Resource *class* in server/REST.ts's OPTIONS case, so it inspects statics, not instance methods: it over-reports MOVE and COPY (statics exist and dispatch to instance methods a table does not define, so both 405) and omits HEAD (no static). "OPTIONS reports the supported methods" overstated it. Replaced with what is actually true: HEAD is GET minus the body (REST.ts:352), and QUERY works on the collection path, dispatching through `Resource.query` to the table's `search` (Resource.ts:347). - Add a carve-out for components with no configuration file. static/defaultConfig.yaml has no `rest` key, but components/DEFAULT_CONFIG.ts sets `rest: true`, and componentLoader.ts:602-614 selects one or the other verbatim with no merge. The two cases are disjoint, so the blanket "not enabled by default" told a reader with no config.yaml their exported tables would 404 when they work. - Correct the POST row: `POST /Table` without the trailing slash returns 404 from a purpose-built ClientError during argument normalization (Resource.ts:747), not 405; only `POST /Table/{id}` reaches missingMethod. Also note the response is 201 and that `Location` carries the bare primary key rather than a URL (Resource.ts:246, REST.ts:319-320). Also folds in PUT's three always-applied exceptions to full replacement (Table.ts:2461-2487) as a note rather than a table row, so the "omitted properties are removed" warning keeps the lede. Co-Authored-By: Claude Opus 5 --- reference/rest/overview.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/reference/rest/overview.md b/reference/rest/overview.md index 442af0afd..099378cb1 100644 --- a/reference/rest/overview.md +++ b/reference/rest/overview.md @@ -58,7 +58,13 @@ graphqlSchema: rest: true ``` -Neither half is sufficient on its own. Without `@export` the table has no REST route and callers get `404`. Without `rest: true` the REST handler is never registered for the application, so exported tables do not respond to HTTP requests at all — `rest` is not enabled by default. +Neither half is sufficient on its own. Without `@export` the table has no REST route and callers get `404`. Without `rest: true` the REST handler is never registered for the application, so even an exported table does not respond to HTTP requests. + +:::note Components with no configuration file +The one exception is a component directory that has **no configuration file at all**. Harper falls back to a built-in default for those, and that default enables `rest` and loads `*.graphql` from the component root — so a bare directory containing only a schema file does serve its exported tables. + +The fallback is all or nothing. As soon as a configuration file exists it is used verbatim, with no merge against the built-in default, so a `config.yaml` that omits `rest` turns REST off even though the same directory would have had it with no file present. If you add a configuration file, add `rest: true` with it. +::: With both in place, the exported name becomes the base path (`Product` above, or the `name` argument of `@export`) and Harper serves the following endpoints on the application HTTP server port (default `9926`). No route definitions, controllers, or handler code are required. @@ -68,7 +74,7 @@ With both in place, the exported name becomes the base path (`Product` above, or | `GET /Product/` | The record collection. Append query parameters to search, filter, sort, and page. | [GET](#get), [Querying](./querying.md) | | `GET /Product/{id}` | A single record by primary key; `404` when no such record exists. | [GET](#get) | | `GET /Product/{id}.property` | A single property of one record. Only properties declared in the schema. | [GET](#get) | -| `POST /Product/` | Creates a record with a Harper-assigned primary key, returned in the `Location` response header. Requires the trailing slash. | [POST](#post) | +| `POST /Product/` | Creates a record with a Harper-assigned primary key and responds `201`. Requires the trailing slash — `POST /Product` returns `404`. | [POST](#post) | | `PUT /Product/{id}` | Creates or replaces the record at `{id}` (upsert). The stored record matches the body exactly — properties omitted from the body are **removed**. | [PUT](#put) | | `PATCH /Product/{id}` | Merges the body into the existing record, preserving unspecified properties. The merge is **shallow** — a nested object in the body replaces the stored one wholesale. | [PATCH](#patch) | | `DELETE /Product/{id}` | Deletes the record at `{id}`. | [DELETE](#delete) | @@ -77,7 +83,9 @@ With both in place, the exported name becomes the base path (`Product` above, or Notes on this surface: - The trailing slash is significant throughout: `/Product` addresses the resource itself, `/Product/` addresses its collection of records. See [URL Structure](#url-structure). -- `HEAD` behaves as `GET` with the body omitted, and `OPTIONS` reports the resource's supported methods in an `Allow` header. A method a resource does not implement returns `405` with an `Allow` header listing the ones it does. +- `HEAD` is served exactly as `GET` with the response body omitted. `QUERY` is accepted on the collection path (`QUERY /Product/`) and runs a search taken from the request body rather than the URL. +- On a successful `POST`, the new record's primary key is returned in the `Location` response header. The header carries the bare key value, not a URL — it is not a link to follow. +- `PUT` replaces the stored record, with three exceptions that Harper always applies: a [`@createdTime`](../database/schema.md#createdtime) attribute keeps the original record's value, an [`@updatedTime`](../database/schema.md#updatedtime) attribute is re-stamped with the time of the write, and the primary key is forced to match the `{id}` in the URL even if the body carries a different one. - Enabling `rest` also enables [WebSocket](./websockets.md) and [Server-Sent Events](./server-sent-events.md) subscriptions on these same resource paths. - Every exported resource is included in the generated [OpenAPI](#openapi) document. - Custom resource classes exported from an application get the same URL structure and method mapping; they implement the methods themselves rather than inheriting the table behavior above. See [Resource API](../resources/resource-api.md). From 3baf5197ab34cacd0d184ff27d0afdc04bb2f633 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Fri, 28 Aug 2026 11:29:27 -0600 Subject: [PATCH 3/3] docs(rest): scope the automatic-endpoint rules and qualify their exceptions Addresses review feedback on the "Tables and Their Automatic Endpoints" section: - Scope the "@export + rest" requirement to the default table Resource that Harper registers automatically, and point at Extending a Table for the case where a tables.MyTable subclass owns the route instead (omit @export). The previous absolute wording contradicted reference/resources/overview.md and could lead users into the conflicting-endpoints trap it warns about. - A collection POST only generates a primary key when the body omits the primary-key property. A supplied key is preserved and echoed in Location; a POST to an existing key returns 409 rather than overwriting. - REST enables WebSocket subscriptions by default, not unconditionally: rest.webSocket: false suppresses WS registration while REST keeps serving. SSE is described separately, since it is negotiated per request via Accept and is not gated by that option. - Qualify OpenAPI inclusion as every non-hidden exported resource, naming both @hidden on the type and static hidden = true on a programmatic Resource. - reference/database/schema.md no longer claims config.yaml is required; it now matches the no-configuration-file carve-out documented on the REST page. Verified against harper origin/main: Table.create (resources/Table.ts), Resource.create/post (resources/Resource.ts), the webSocket early return and the SSE branch (server/REST.ts), and the hidden filters (resources/openApi.ts). Co-Authored-By: Claude Opus 5 --- reference/database/schema.md | 2 +- reference/rest/overview.md | 42 ++++++++++++++++++++---------------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/reference/database/schema.md b/reference/database/schema.md index e2f1d3712..ffadf06e3 100644 --- a/reference/database/schema.md +++ b/reference/database/schema.md @@ -224,7 +224,7 @@ type MyTable @table @export(name: "my-table") { The optional `name` parameter specifies the URL path segment (e.g., `/my-table/`). Without `name`, the type name is used. -`@export` alone does not serve HTTP traffic — the `rest` plugin must also be enabled in the application's `config.yaml`. See [REST Overview / Tables and Their Automatic Endpoints](../rest/overview.md#tables-and-their-automatic-endpoints) for the endpoints an exported table produces and the `rest: true` requirement. +`@export` alone does not serve HTTP traffic — REST must also be enabled for the application, either explicitly with `rest: true` in its `config.yaml` or by Harper's built-in default for a component directory that has no configuration file at all. See [REST Overview / Tables and Their Automatic Endpoints](../rest/overview.md#tables-and-their-automatic-endpoints) for the endpoints an exported table produces and the exact conditions. :::warning `@export` is a routing directive, not an access control Omitting `@export` removes the REST/MQTT route for a table (callers get 404), but it does **not** protect the data. The table still exists in the database and remains accessible through the Operations API and SQL, subject to RBAC, to administrators and roles with the required operation and table permissions. For table-level confidentiality, omit the table from a role's grants or set its table-level `read` permission to `false` rather than relying on the absence of an export route. To protect individual fields, configure `attribute_permissions` as a whitelist: list every field the role may read and omit the restricted field (or list it with `read: false`). Also account for the [filter side-channel on exported Resources](../users-and-roles/overview.md#filter-side-channel-for-read-restricted-attributes). diff --git a/reference/rest/overview.md b/reference/rest/overview.md index 099378cb1..48fd1880e 100644 --- a/reference/rest/overview.md +++ b/reference/rest/overview.md @@ -37,10 +37,10 @@ rest: ## Tables and Their Automatic Endpoints -A table is served over REST only when **both** of the following are true: +This section describes the **default table Resource** — the endpoints Harper registers automatically for a table, with no handler code of your own. Harper serves that default Resource only when **both** of the following are true: 1. The table is exported in a schema definition with [`@export`](../database/schema.md#export). -2. The `rest` plugin is enabled in the application's `config.yaml` (see [Configuration](#configuration)). +2. REST is enabled for the application — normally `rest: true` in `config.yaml` (see [Configuration](#configuration)); a component directory with **no configuration file at all** gets it from Harper's built-in default instead, as described below. ```graphql # schema.graphql @@ -58,36 +58,40 @@ graphqlSchema: rest: true ``` -Neither half is sufficient on its own. Without `@export` the table has no REST route and callers get `404`. Without `rest: true` the REST handler is never registered for the application, so even an exported table does not respond to HTTP requests. +Neither half is sufficient on its own. Without `@export` Harper registers no default Resource for the table, so it has no REST route and callers get `404`. Without REST enabled the REST handler is never registered for the application, so even an exported table does not respond to HTTP requests. + +`@export` is how the **table itself** claims the URL. When a JavaScript subclass of `tables.MyTable` should own that URL instead, omit `@export` from the schema and export the class — the class claims the route and serves whatever it implements, and REST still has to be enabled. Leaving `@export` on the schema while also exporting a same-named subclass produces conflicting endpoints. See [Extending a Table](../resources/overview.md#extending-a-table). :::note Components with no configuration file -The one exception is a component directory that has **no configuration file at all**. Harper falls back to a built-in default for those, and that default enables `rest` and loads `*.graphql` from the component root — so a bare directory containing only a schema file does serve its exported tables. +Enabling REST is normally explicit, with one exception: a component directory that has **no configuration file at all**. Harper falls back to a built-in default for those, and that default enables `rest` and loads `*.graphql` from the component root — so a bare directory containing only a schema file does serve its exported tables without a `config.yaml`. The fallback is all or nothing. As soon as a configuration file exists it is used verbatim, with no merge against the built-in default, so a `config.yaml` that omits `rest` turns REST off even though the same directory would have had it with no file present. If you add a configuration file, add `rest: true` with it. ::: With both in place, the exported name becomes the base path (`Product` above, or the `name` argument of `@export`) and Harper serves the following endpoints on the application HTTP server port (default `9926`). No route definitions, controllers, or handler code are required. -| Endpoint | Description | Details | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | -| `GET /Product` | Describes the resource — table name, database, and declared attributes — plus an href to the record collection. No trailing slash. | [GET](#get) | -| `GET /Product/` | The record collection. Append query parameters to search, filter, sort, and page. | [GET](#get), [Querying](./querying.md) | -| `GET /Product/{id}` | A single record by primary key; `404` when no such record exists. | [GET](#get) | -| `GET /Product/{id}.property` | A single property of one record. Only properties declared in the schema. | [GET](#get) | -| `POST /Product/` | Creates a record with a Harper-assigned primary key and responds `201`. Requires the trailing slash — `POST /Product` returns `404`. | [POST](#post) | -| `PUT /Product/{id}` | Creates or replaces the record at `{id}` (upsert). The stored record matches the body exactly — properties omitted from the body are **removed**. | [PUT](#put) | -| `PATCH /Product/{id}` | Merges the body into the existing record, preserving unspecified properties. The merge is **shallow** — a nested object in the body replaces the stored one wholesale. | [PATCH](#patch) | -| `DELETE /Product/{id}` | Deletes the record at `{id}`. | [DELETE](#delete) | -| `DELETE /Product/?query` | Deletes every record matching the query. With no query parameters, this matches — and deletes — every record in the table. | [DELETE](#delete) | +| Endpoint | Description | Details | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| `GET /Product` | Describes the resource — table name, database, and declared attributes — plus an href to the record collection. No trailing slash. | [GET](#get) | +| `GET /Product/` | The record collection. Append query parameters to search, filter, sort, and page. | [GET](#get), [Querying](./querying.md) | +| `GET /Product/{id}` | A single record by primary key; `404` when no such record exists. | [GET](#get) | +| `GET /Product/{id}.property` | A single property of one record. Only properties declared in the schema. | [GET](#get) | +| `POST /Product/` | Creates a record and responds `201`, with a Harper-assigned primary key when the body does not supply one. Requires the trailing slash — `POST /Product` returns `404`. | [POST](#post) | +| `PUT /Product/{id}` | Creates or replaces the record at `{id}` (upsert). The stored record matches the body exactly — properties omitted from the body are **removed**. | [PUT](#put) | +| `PATCH /Product/{id}` | Merges the body into the existing record, preserving unspecified properties. The merge is **shallow** — a nested object in the body replaces the stored one wholesale. | [PATCH](#patch) | +| `DELETE /Product/{id}` | Deletes the record at `{id}`. | [DELETE](#delete) | +| `DELETE /Product/?query` | Deletes every record matching the query. With no query parameters, this matches — and deletes — every record in the table. | [DELETE](#delete) | Notes on this surface: - The trailing slash is significant throughout: `/Product` addresses the resource itself, `/Product/` addresses its collection of records. See [URL Structure](#url-structure). - `HEAD` is served exactly as `GET` with the response body omitted. `QUERY` is accepted on the collection path (`QUERY /Product/`) and runs a search taken from the request body rather than the URL. -- On a successful `POST`, the new record's primary key is returned in the `Location` response header. The header carries the bare key value, not a URL — it is not a link to follow. +- A collection `POST` only generates a primary key when the body omits the table's primary-key property. If the body supplies one, Harper stores the record under that value rather than replacing it, and a `POST` to a key that already exists fails with `409` instead of overwriting. +- On a successful `POST`, the new record's primary key — supplied or generated — is returned in the `Location` response header. The header carries the bare key value, not a URL, so it is not a link to follow. - `PUT` replaces the stored record, with three exceptions that Harper always applies: a [`@createdTime`](../database/schema.md#createdtime) attribute keeps the original record's value, an [`@updatedTime`](../database/schema.md#updatedtime) attribute is re-stamped with the time of the write, and the primary key is forced to match the `{id}` in the URL even if the body carries a different one. -- Enabling `rest` also enables [WebSocket](./websockets.md) and [Server-Sent Events](./server-sent-events.md) subscriptions on these same resource paths. -- Every exported resource is included in the generated [OpenAPI](#openapi) document. +- Enabling `rest` also registers [WebSocket](./websockets.md) subscriptions on these same resource paths **by default**. Setting `webSocket: false` under `rest` (see [Configuration](#configuration)) suppresses that registration while leaving REST itself serving. +- [Server-Sent Events](./server-sent-events.md) subscriptions are served on these same paths as well, negotiated per request with `Accept: text/event-stream`. They come with `rest` and are not affected by the `webSocket` option. +- Every **non-hidden** exported resource is included in the generated [OpenAPI](#openapi) document. A type marked [`@hidden`](../database/schema.md#hidden-type-directive), or a programmatic Resource with [`static hidden = true`](../resources/resource-api.md#static-hidden-boolean), is omitted from it. - Custom resource classes exported from an application get the same URL structure and method mapping; they implement the methods themselves rather than inheriting the table behavior above. See [Resource API](../resources/resource-api.md). ## URL Structure @@ -149,7 +153,7 @@ Creates or replaces the record with primary key `123`. ### POST -Create a new record without specifying a primary key, or trigger a custom action. Handled by the resource's `post(data)` method. The auto-assigned primary key is returned in the `Location` response header. +Create a new record, or trigger a custom action. Handled by the resource's `post(data)` method. The new record's primary key is returned in the `Location` response header — the value the body supplied, if it carried the primary-key property, and otherwise a Harper-assigned key. ```http POST /MyTable/