|
| 1 | +--- |
| 2 | +title: "OData datasources — ERP records, live, with no driver of your own" |
| 3 | +navTitle: OData Datasources |
| 4 | +description: Declare a datasource over an OData v4 service such as SAP S/4HANA or Dynamics 365, bind objects to its entity sets, and read and write them live, with no code per entity set. |
| 5 | +--- |
| 6 | + |
| 7 | +Many business systems already publish their records over **OData v4**: SAP S/4HANA, |
| 8 | +Microsoft Dynamics 365 and Dataverse, and any service that serves a CSDL 4.0 |
| 9 | +`$metadata` document. For those you need no driver of your own. Mount |
| 10 | +`@objectstack/driver-odata` once per service, declare a datasource, and bind an object |
| 11 | +to each entity set you want. Every view, form, permission set, formula and hook then |
| 12 | +works on those records, and no row is stored on the platform side. |
| 13 | + |
| 14 | +The OData driver is a **plugin driver**, so everything [Virtual objects](/docs/data-modeling/virtual-objects) |
| 15 | +says about plugin drivers holds here too: the engine re-applies every query, a read |
| 16 | +matches at most 1000 rows, and the sharing model needs a deliberate choice. The one |
| 17 | +difference is that this driver is already written. |
| 18 | +[Which way in](/docs/data-modeling/virtual-objects#which-way-in-odata-a-plugin-driver-or-a-real-object) |
| 19 | +says when to choose it over a driver of your own or a synced copy. |
| 20 | + |
| 21 | +Everything on this page is what the driver's own tests assert, against an in-process |
| 22 | +OData service and behind the real engine |
| 23 | +(`packages/drivers/driver-odata/src/odata-driver.floor.test.ts` and |
| 24 | +`odata-driver.conformance.test.ts`). Read them when this page and the runtime seem to |
| 25 | +disagree. |
| 26 | + |
| 27 | +## Check the service first |
| 28 | + |
| 29 | +- **It speaks OData v4.** Open `<serviceRoot>$metadata`. The root element must say |
| 30 | + `Version="4.0"`. Only the v4 dialect ships. A service that speaks only OData v2 needs a |
| 31 | + v2 dialect, which this package does not include. Many older SAP Gateway services are |
| 32 | + v2 only, so check each service you plan to bind. |
| 33 | +- **Each entity set you bind has a key.** The key becomes the record's `id`. An entity |
| 34 | + type with no usable key is refused at the first request. |
| 35 | +- **You know how it authenticates.** The driver sends no credential, a bearer token, or |
| 36 | + HTTP basic. It does not run an OAuth2 flow ([Credentials](#3-credentials)). |
| 37 | + |
| 38 | +## 1. Mount the plugin |
| 39 | + |
| 40 | +The plugin builds the driver from its options and publishes it as the service |
| 41 | +`driver.<name>`. Mount it in the stack's `plugins`, one plugin per service: |
| 42 | + |
| 43 | +```typescript |
| 44 | +import { defineStack } from '@objectstack/spec'; |
| 45 | +import { createODataDriverPlugin } from '@objectstack/driver-odata'; |
| 46 | +import { erpSecretStore } from './secrets'; // your host's secret store; see Credentials |
| 47 | + |
| 48 | +export default defineStack({ |
| 49 | + // …the datasource and objects of step 2… |
| 50 | + plugins: [ |
| 51 | + createODataDriverPlugin({ |
| 52 | + name: 'erp', // = the datasource name |
| 53 | + serviceRoot: 'https://erp.example.com/odata/v4/sales/', |
| 54 | + auth: { scheme: 'bearer' }, |
| 55 | + secrets: erpSecretStore, |
| 56 | + }), |
| 57 | + ], |
| 58 | +}); |
| 59 | +``` |
| 60 | + |
| 61 | +| Option | Default | Meaning | |
| 62 | +| :--- | :--- | :--- | |
| 63 | +| `name` | `odata` | The datasource it serves. It is also the driver name and the `driver.<name>` service suffix. | |
| 64 | +| `serviceRoot` | (required) | The service root URL, http or https, with no credentials and no query string. | |
| 65 | +| `version` | `4.0` | The protocol version. `4.0` is the only value. | |
| 66 | +| `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. | |
| 67 | +| `secrets` | (none) | Where `credentialsRef` is resolved: an object with `resolve(credentialsRef)`. Required for `bearer` and `basic`. | |
| 68 | +| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). | |
| 69 | +| `requestTimeoutMs` | `30000` | The timeout of each request. | |
| 70 | + |
| 71 | +The options are checked when the plugin is built. An unknown key, a `serviceRoot` with |
| 72 | +a user name or a query in it, or a version other than `4.0` refuses the boot with every |
| 73 | +problem named. |
| 74 | + |
| 75 | +## 2. Declare the datasource and bind objects |
| 76 | + |
| 77 | +{/* os:check */} |
| 78 | +```typescript |
| 79 | +import { defineStack } from '@objectstack/spec'; |
| 80 | + |
| 81 | +export default defineStack({ |
| 82 | + manifest: { id: 'com.example.sales', name: 'Sales', version: '1.0.0', type: 'app' }, |
| 83 | + datasources: [ |
| 84 | + { |
| 85 | + name: 'erp', |
| 86 | + label: 'ERP sales service (OData v4)', |
| 87 | + driver: 'erp', // the plugin's name, i.e. the `driver.erp` service |
| 88 | + schemaMode: 'external', |
| 89 | + config: {}, // the plugin's options configure the driver, not this |
| 90 | + external: { |
| 91 | + allowWrites: true, |
| 92 | + credentialsRef: 'sys_secret:7c1e…', // the bearer token, as a secret-store handle |
| 93 | + validation: { onMismatch: 'warn', checkOnBoot: false }, |
| 94 | + }, |
| 95 | + }, |
| 96 | + ], |
| 97 | + objects: [ |
| 98 | + { |
| 99 | + name: 'sales_order', |
| 100 | + label: 'Sales order', |
| 101 | + datasource: 'erp', |
| 102 | + sharingModel: 'public_read_write', // see "Choose the sharing model" below |
| 103 | + external: { |
| 104 | + remoteName: 'SalesOrders', // the entity set |
| 105 | + writable: true, |
| 106 | + // remote property → local field; a property not named keeps its own name |
| 107 | + columnMap: { |
| 108 | + SalesOrderID: 'order_number', |
| 109 | + CustomerName: 'customer_name', |
| 110 | + NetAmount: 'net_amount', |
| 111 | + OverallStatus: 'status', |
| 112 | + DeliveryNote: 'delivery_note', |
| 113 | + }, |
| 114 | + }, |
| 115 | + fields: { |
| 116 | + order_number: { type: 'text', label: 'Order' }, |
| 117 | + customer_name: { type: 'text', label: 'Customer' }, |
| 118 | + net_amount: { type: 'number', label: 'Net amount' }, |
| 119 | + status: { type: 'text', label: 'Status' }, |
| 120 | + delivery_note: { type: 'text', label: 'Delivery note' }, |
| 121 | + }, |
| 122 | + }, |
| 123 | + ], |
| 124 | +}); |
| 125 | +``` |
| 126 | + |
| 127 | +What each piece does on this driver: |
| 128 | + |
| 129 | +- **Three names stay equal:** the plugin's `name`, the datasource `name`, and the |
| 130 | + datasource `driver`. The connect step finds `driver.erp` already registered and uses |
| 131 | + it as it is. `config` is left empty, because the plugin's options already configured |
| 132 | + the driver. |
| 133 | +- **`remoteName` names the entity set.** Without it the driver looks for an entity set |
| 134 | + named like the object. |
| 135 | +- **This driver applies `external.columnMap` itself**, in both directions: rows arrive |
| 136 | + under your field names, and filters, sorts and writes go out under the service's |
| 137 | + property names. That is a property of this driver, not of plugin drivers in general. |
| 138 | +- **`id` is the entity key.** A single-property key is read as text (`'4711'`). A |
| 139 | + composite key is the body of its key predicate (`'OrderID=1,ProductID=7'`), which is |
| 140 | + also what `GET /data/<object>/<id>` takes. |
| 141 | +- **Declare each field by hand.** Nothing generates fields from `$metadata` today. |
| 142 | + Navigation properties are never followed, so a related entity set is an object of its |
| 143 | + own. |
| 144 | +- **The service document is read lazily.** `connect()` asks the service nothing, and |
| 145 | + `$metadata` is read once, at the first request. A wrong `serviceRoot` or an unknown |
| 146 | + entity set shows up on the first read of the object, not at boot. |
| 147 | +- **`checkOnBoot: false`** keeps the boot from depending on the service being up. The |
| 148 | + driver's tests do not cover the boot-time drift check over an OData service, so test |
| 149 | + it against your service before you turn it on. |
| 150 | + |
| 151 | +## 3. Credentials |
| 152 | + |
| 153 | +⛔ Never put a credential in the stack, in the plugin options, or in the `serviceRoot` |
| 154 | +URL. The secret is the datasource's `external.credentialsRef`, and the plugin resolves it |
| 155 | +through the `secrets` store you pass: |
| 156 | + |
| 157 | +- **`bearer`** sends `Authorization: Bearer <secret>`. |
| 158 | +- **`basic`** takes `username` from the options and the password from the secret. |
| 159 | +- **`none`** sends nothing. A `credentialsRef` declared beside `none` is refused, so a |
| 160 | + credential you meant to send is never left out by mistake. |
| 161 | + |
| 162 | +The plugin resolves the secret at the **first request**, not at boot, and again after a |
| 163 | +`401`. So a token rotated in the secret store is picked up by the next request that the |
| 164 | +old token fails. The driver does not obtain or refresh tokens itself. A service that |
| 165 | +issues short-lived OAuth2 access tokens needs something that keeps the stored secret |
| 166 | +current. |
| 167 | + |
| 168 | +`secrets` is any object with `resolve(credentialsRef)`, answering the secret or |
| 169 | +`undefined`: |
| 170 | + |
| 171 | +- **A host that composes its own kernel** passes the same binder it gives |
| 172 | + `DatasourceAdminServicePlugin`. A `sys_secret:<id>` handle that the Add Datasource |
| 173 | + wizard minted then resolves, as on any federated datasource |
| 174 | + ([Credentials](/docs/data-modeling/external-datasources#4-credentials)). |
| 175 | +- **`os serve`** builds its secret store internally and does not hand it to a stack's |
| 176 | + plugins. Pass a `resolve` backed by your host's own secret handling instead, and |
| 177 | + declare `credentialsRef` as a handle that it resolves. |
| 178 | + |
| 179 | +Resolution fails closed. Each of these refuses the request rather than send it without |
| 180 | +the credential: an auth scheme with no `credentialsRef`, a `credentialsRef` with no |
| 181 | +`secrets`, and a handle that does not resolve. The caller is answered |
| 182 | +`503 ERR_DATASOURCE_UNAVAILABLE`, and the cause goes to the server log. |
| 183 | + |
| 184 | +## 4. What the service is asked |
| 185 | + |
| 186 | +Behind the floor, a list read is one `GET` on the entity set: |
| 187 | + |
| 188 | +```text |
| 189 | +GET <serviceRoot>SalesOrders?$filter=OverallStatus eq 'open'&$orderby=NetAmount desc,SalesOrderID&$top=1001 |
| 190 | +Prefer: odata.maxpagesize=500 |
| 191 | +``` |
| 192 | + |
| 193 | +- **`$filter`** carries every part of the `where` the driver can translate: `$eq`, |
| 194 | + `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$null`, `$contains`, |
| 195 | + `$startsWith`, `$endsWith` and `$icontains` (ASCII comparands), under `$and` and `$or`. |
| 196 | +- **The rest is left to the engine**, which re-applies the whole query to the rows the |
| 197 | + service returns: `$not`, `$like`, `$exists`, field references, and ordering or |
| 198 | + negation on text. The driver only ever asks a **wider** question. It drops a member of |
| 199 | + an `$and` it cannot translate, and drops a whole `$or` when one branch cannot be |
| 200 | + translated, so the service never returns fewer rows than match. This is also why a |
| 201 | + service that compares text case-insensitively still answers correctly. |
| 202 | +- **`$orderby`** carries the sort, then the entity key, so pages partition the result. |
| 203 | +- **Paging** follows `@odata.nextLink` until 1001 rows are read. A link to another |
| 204 | + origin is refused, so the credential never goes to a host you did not configure. |
| 205 | +- **The page size and the total** are the engine's, applied to the rows read. A count |
| 206 | + is computed the same way. The engine aggregates in memory. |
| 207 | + |
| 208 | +**The 1000-row cap applies.** A read that matches more than 1000 rows answers |
| 209 | +`422 EXTERNAL_ROW_CAP_EXCEEDED` rather than a partial set |
| 210 | +([the cap](/docs/data-modeling/virtual-objects#the-engine-re-applies-every-query)). Every |
| 211 | +`$filter` the driver can push down makes the service return only matching rows, so the cap |
| 212 | +counts matching rows, not the whole entity set. Give list views a filter the service |
| 213 | +can run. For an entity set users must browse whole, mirror it into a real object |
| 214 | +instead. |
| 215 | + |
| 216 | +## 5. Writes |
| 217 | + |
| 218 | +Writes are the same double opt-in as federation: `external.allowWrites` on the |
| 219 | +datasource and `external.writable` on the object |
| 220 | +([Writes](/docs/data-modeling/external-datasources#5-writes-double-opt-in)). |
| 221 | + |
| 222 | +| The platform does | The service is sent | |
| 223 | +| :--- | :--- | |
| 224 | +| `POST /data/sales_order` | `POST SalesOrders` | |
| 225 | +| `PATCH /data/sales_order/:id` | `PATCH SalesOrders(<key>)` | |
| 226 | +| `DELETE /data/sales_order/:id` | `DELETE SalesOrders(<key>)` | |
| 227 | + |
| 228 | +- **`If-Match`** carries the entity's `@odata.etag` when the driver's last read of that |
| 229 | + record returned one, so a write over someone else's change is refused by the service. |
| 230 | +- **Only properties the entity type declares are sent.** The fields the engine stamps |
| 231 | + itself (`updated_at`, `updated_by`) are not the service's and are left out. |
| 232 | +- **The service's refusal reaches the caller.** A `400`, `409`, `412` or `422` |
| 233 | + from the service answers `422 EXTERNAL_WRITE_REFUSED` with the service's own message. |
| 234 | + Let the ERP's validation be the rule, and do not retry around it. |
| 235 | +- **Refused by name:** `upsert`, the bulk writes, and transactions. Without `$batch` |
| 236 | + there is no atomic multi-row write. A write that selects its rows by a filter is |
| 237 | + refused too. Read the ids, then write each one. |
| 238 | + |
| 239 | +### Choose the sharing model |
| 240 | + |
| 241 | +A record from an OData service has no `owner_id` and no `created_by`, so the default |
| 242 | +sharing model admits no row for a member, and an owner-keyed write is refused. The rule |
| 243 | +and the ways out are the same as on any plugin driver |
| 244 | +([Choose the sharing model on purpose](/docs/data-modeling/virtual-objects#3-the-datasource-the-object-the-hook-the-views)): |
| 245 | + |
| 246 | +- **Read-only data:** leave `allowWrites` off and declare `sharingModel: 'public_read'`. |
| 247 | + Who may read is the permission sets' decision. |
| 248 | +- **Editable data:** declare `sharingModel: 'public_read_write'`, and decide who may edit |
| 249 | + in a `beforeUpdate` hook. A member's delete is still refused on this model. |
| 250 | + |
| 251 | +## When the service fails |
| 252 | + |
| 253 | +| What happened | The caller is answered | |
| 254 | +| :--- | :--- | |
| 255 | +| A read the service refuses for its own sake (`400`, `404`, `422`) | `400 INVALID_QUERY`, with a message naming the entity set, the status and the service's own text | |
| 256 | +| A write the service refuses | `422 EXTERNAL_WRITE_REFUSED`, with the service's message | |
| 257 | +| A `5xx`, a timeout, an unreachable service, a `401` after the retry, a `403` or a `429` | `503 ERR_DATASOURCE_UNAVAILABLE`; the cause goes to the server log | |
| 258 | +| A read that matches more than 1000 rows | `422 EXTERNAL_ROW_CAP_EXCEEDED` | |
| 259 | + |
| 260 | +An outage is never answered as an empty list. A read refusal's message is withheld, and |
| 261 | +goes to the server log, when the read carries a predicate the caller did not write, such |
| 262 | +as a sharing scope. The full mapping is in |
| 263 | +[Virtual objects](/docs/data-modeling/virtual-objects#1-the-driver). |
| 264 | + |
| 265 | +## What it does not do |
| 266 | + |
| 267 | +- **OData v2.** Only the v4 dialect ships. |
| 268 | +- **`$expand` and navigation properties.** Bind each related entity set as an object of |
| 269 | + its own. |
| 270 | +- **Actions and functions** of the service. `execute` is refused. |
| 271 | +- **Transactions and `$batch`.** Each write is one request. |
| 272 | +- **More than 1000 matching rows** in one read (the cap above). |
| 273 | +- **A read scoped by a parent's access** (an object `controlled_by_parent`): it is |
| 274 | + refused, because `$filter` has no form of that join. |
| 275 | + |
| 276 | +## See also |
| 277 | + |
| 278 | +- [Virtual objects](/docs/data-modeling/virtual-objects): which way in, the engine's |
| 279 | + floor, the sharing-model callout, and writing a driver for a system with no OData. |
| 280 | +- [External datasources](/docs/data-modeling/external-datasources): the datasource and |
| 281 | + `external` binding, federation over SQL, the write gates and credentials. |
| 282 | +- [Pulling a mapping](/docs/automation/jobs#pulling-a-mapping): a real object fed from a |
| 283 | + remote API on a schedule. |
| 284 | +- `packages/drivers/driver-odata/README.md`: the package and its exports. |
0 commit comments