openapi: 3.0.3

info:
  title: WRF Drupal API — NetSuite Integration
  version: "1.0.0"
  description: |
    This is the **inbound** API: the surface NetSuite calls on waterrf.org.
    (Activity records that Drupal pushes *back* into NetSuite are a separate,
    outbound OAuth 1.0a integration and are not documented here.)

    ## 60-second quickstart

    1. `POST /oauth/token` with `grant_type=client_credentials` → get an `access_token`.
    2. Send it as `Authorization: Bearer <access_token>` on every request.
    3. Append `?_format=json` to **every** URL. Without it you get `406 Not Acceptable`.
    4. Send `Content-Type: application/json` on every POST/PATCH. Without it you get `415`.
    5. Tokens live **300 seconds (5 minutes)**. There is no refresh token on this
       grant — when it expires, request a new one.

    ## Before anything else: is it the WAF or is it Drupal?

    Both environments sit behind Cloudflare, and Cloudflare currently answers
    non-browser clients with a **managed JavaScript challenge**. Verified
    2026-09-01 from an unallowlisted network: `GET /oauth/jwks` and
    `POST /oauth/token` both returned `403` with an HTML body on test *and*
    production, for every `User-Agent` tried.

    A WAF `403` and a Drupal `403` are the same status code and completely
    different problems:

    | | WAF challenge | Drupal denial |
    |---|---|---|
    | **Header (decisive)** | `cf-mitigated: challenge` + `server: cloudflare` | no `cf-mitigated`; `server: nginx` |
    | Body | HTML, `<title>Just a moment...</title>` | JSON `{"message": "…"}` — **or** Drupal's HTML access-denied page |
    | Happens on | any path, including `/oauth/token` | only access-controlled paths |
    | Fix | WAF skip rule / allowlist the caller's egress IPs | grant permissions or fix the scope |
    | You can fix it | no — needs the Cloudflare admin | yes — Drupal config |

    Do **not** use "HTML means WAF" as the test. Drupal serves an HTML
    access-denied page too — `GET /oauth/userinfo` without a token returns
    `403` HTML from Drupal, no WAF involved. Only the `cf-mitigated` header
    settles it, so always capture response headers (`curl -D -`).

    SuiteScript's `N/https` cannot execute JavaScript, so it can never satisfy a
    challenge. If NetSuite's calls are being challenged, the integration is
    blocked at the edge and no amount of credential rotation will help. Confirm
    the Cloudflare zone administrator — not Acquia Support — that these paths
    are skipped: `/api*`, `/openapi/*`, `/oauth/*`, `/.well-known/jwks.json`,
    `/node*`, `/taxonomy/term*`, `/entity/node_type/*`.

    Excluding only `/api*` is not enough. `/api` is the site's own Swagger UI
    page; the spec it loads (`/openapi/rest`) and every endpoint it calls live
    outside `/api`. Ready-to-apply rules are in `cloudflare/` in this repo.

    Run `./health-check.sh test` — its first check distinguishes these two cases
    for you before it looks at credentials at all.

    ## Read this before you debug anything

    | Fact | Consequence |
    |---|---|
    | A Cloudflare challenge returns `403` **HTML** | Not a credential problem. See the section above. |
    | Access tokens expire after **300 s** | A long-running SuiteScript job must re-request a token mid-run, not cache one. |
    | `client_credentials` issues **no refresh token** | `grant_type=refresh_token` will fail. Re-authenticate instead. |
    | The `default` scope is bound to the **`administrator`** role | The Drupal user behind the consumer must hold that role or every write returns `403`. |
    | `_format=json` is a **query parameter**, not a header | `Accept: application/json` alone is not enough. |
    | `PUT` is silently rewritten to `PATCH` | Legacy NetSuite scripts sending `PUT` still work (`wrf_restapi_put`). |
    | Node writes require `"type": [{"target_id": "..."}]` in the body | Omitting the bundle on POST **or** PATCH returns `422`. |
    | `X-CSRF-Token` is **not** required | CSRF applies only to cookie auth. Bearer auth is stateless. |
    | CORS allows only named origins | Browser testing works from `https://wrf-api.robs.ws`, not from `file://`. |
    | All REST-created nodes are re-attributed to a service account | Author/revision user is forced to the configured service account UID, so `uid` in your payload is ignored. |

    ## Browser "Try it out" — testing mode

    *Try it out* is **enabled**. Two conditions have to hold for it to work, and
    both are outside this document:

    1. **Serve these docs from an allowlisted origin.** CORS now permits
       `https://wrf-api.robs.ws` and `https://sandbox.waterrf.org` alongside the
       existing `wrftechlink` origins, with the `Authorization` header and the
       `GET`/`POST`/`PATCH`/`PUT` methods. Opening this page from `file://`
       sends `Origin: null` and will always fail — use the hosted URL.
    2. **The Cloudflare WAF must not challenge the API paths.** CORS governs
       what the *browser* permits; the WAF decides whether the request reaches
       Drupal at all. Until the skip rule described above is in place, *Try it
       out* will fail with an opaque network error while `curl` from an
       allowlisted host succeeds.

    A failed *Try it out* shows only "TypeError: Failed to fetch" — the browser
    withholds the real reason. Reproduce with `curl -i` to see the actual status
    and headers before concluding anything.

    ### Do not paste production secrets into the Authorize dialog

    The `clientCredentials` flow in the *Authorize* dialog sends your
    `client_secret` from the browser and keeps the resulting token in
    `localStorage`. That is acceptable for **test/sandbox** credentials on a
    machine you control. Use `curl` or `health-check.sh` for production —
    never a browser page.

    `curl`, `health-check.sh`, and SuiteScript's `N/https` remain the primary
    path: they are server-to-server and unaffected by CORS entirely.

    ## Environments

    | Environment | Base URL | NetSuite side |
    |---|---|---|
    | Test | `https://e3test.waterrf.org` | Sandbox (`9336228-SB1`) |
    | Production | `https://www.waterrf.org` | Production (`9336228`) |

    Credentials are **not** shared between environments. A `401 invalid_client`
    is most often a production secret pointed at test, or the reverse.

servers:
  - url: https://e3test.waterrf.org
    description: Test — use this for all integration work and troubleshooting
  - url: https://www.waterrf.org
    description: Production

tags:
  - name: Authentication
    description: |
      Obtain and inspect OAuth2 access tokens. Everything else on this page
      depends on getting this right first.
  - name: Health
    description: |
      Cheap, side-effect-free calls, in the order that isolates a fault fastest:

      1. `GET /oauth/jwks` — is the edge letting me through at all? (no credentials)
      2. `GET /entity/node_type/{type}` — is REST serving JSON? (also anonymous)
      3. `POST /oauth/token` — can I get a token?
      4. `GET /oauth/userinfo` — does Drupal accept the token? **This is the
         authentication proof.**
      5. `GET /oauth/debug` — does the token carry the `administrator` role?
  - name: Content
    description: Create and update Project and Resource nodes.
  - name: Taxonomy
    description: Create and update taxonomy terms referenced by content.

security:
  - bearerAuth: []
  - oauth2: [default]

paths:

  /oauth/token:
    post:
      tags: [Authentication]
      summary: Get an access token
      operationId: getToken
      security: []
      description: |
        Exchanges consumer credentials for a short-lived bearer token using the
        **client credentials** grant.

        The body must be `application/x-www-form-urlencoded`. Sending JSON here
        is the single most common setup mistake — it returns
        `400 unsupported_grant_type` because Drupal never sees `grant_type`.

        ### curl

        ```bash
        curl -sS -X POST 'https://e3test.waterrf.org/oauth/token' \
          -H 'Content-Type: application/x-www-form-urlencoded' \
          -d 'grant_type=client_credentials' \
          -d 'client_id=YOUR_CLIENT_ID' \
          -d 'client_secret=YOUR_CLIENT_SECRET' \
          -d 'scope=default'
        ```

        ### SuiteScript 2.1

        ```javascript
        define(['N/https', 'N/encode'], (https, encode) => {
          const BASE = 'https://e3test.waterrf.org';

          function getToken() {
            const res = https.post({
              url: BASE + '/oauth/token',
              headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
              body: {
                grant_type: 'client_credentials',
                client_id: 'YOUR_CLIENT_ID',
                client_secret: 'YOUR_CLIENT_SECRET',
                scope: 'default'
              }
            });
            if (res.code !== 200) {
              throw new Error('Token request failed: ' + res.code + ' ' + res.body);
            }
            return JSON.parse(res.body).access_token;
          }

          return { getToken };
        });
        ```

        Because the token is valid for only 300 seconds, fetch it immediately
        before the write batch it authorises — do not stash it in a script
        parameter or cache between scheduled runs.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id, client_secret]
              properties:
                grant_type:
                  type: string
                  enum: [client_credentials]
                  description: Must be `client_credentials`. No other grant is enabled for the NetSuite consumer.
                client_id:
                  type: string
                  description: Consumer UUID from `/admin/config/services/consumer`. Environment-specific.
                client_secret:
                  type: string
                  format: password
                  description: Consumer secret. Environment-specific.
                scope:
                  type: string
                  default: default
                  description: |
                    The `default` scope. Omitting it falls back to the consumer's
                    configured scopes, which is usually the same thing — send it
                    explicitly so failures are unambiguous.
            example:
              grant_type: client_credentials
              client_id: 2f9c1b7e-4d3a-4f21-9c8e-11a2b3c4d5e6
              client_secret: "••••••••••••••••"
              scope: default
      responses:
        "200":
          description: Token issued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
        "400":
          description: |
            Malformed request. `unsupported_grant_type` almost always means the
            body was sent as JSON instead of form-encoded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
              examples:
                unsupported_grant_type:
                  value:
                    error: unsupported_grant_type
                    error_description: The authorization grant type is not supported by the authorization server.
                    hint: Check that all required parameters have been provided
                invalid_scope:
                  value:
                    error: invalid_scope
                    error_description: "The requested scope is invalid, unknown, or malformed"
                    hint: "Check the `default` scope"
        "401":
          description: |
            `invalid_client` — wrong `client_id`/`client_secret`, or credentials
            from the other environment. Verify which environment you are hitting
            before rotating anything.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
              example:
                error: invalid_client
                error_description: Client authentication failed
        "500":
          description: |
            Usually a key-material problem: the OAuth private/public key pair at
            `../private/private.key` is missing, unreadable, or has wrong
            permissions on the environment. Not a client-side fault — escalate.

  /oauth/debug:
    get:
      tags: [Health, Authentication]
      summary: Inspect the token you just used
      operationId: debugToken
      description: |
        Echoes back the identity, roles, and scopes Drupal resolved from your
        bearer token. This is the fastest way to distinguish the three failure
        modes that all look alike from NetSuite:

        - **Bad token** → `401`
        - **Valid token, under-privileged user** → `403`
        - **Valid token, correct user** → `200` with roles listed

        Requires the `debug simple_oauth tokens` permission on the consumer's
        user. If you get a `403` here but writes succeed, the permission is
        simply not granted — that is a documentation-convenience gap, not an
        integration fault.

        ```bash
        curl -sS 'https://e3test.waterrf.org/oauth/debug?_format=json' \
          -H "Authorization: Bearer $TOKEN"
        ```
      parameters:
        - $ref: "#/components/parameters/format"
      responses:
        "200":
          description: Token is valid. Confirm `roles` contains `administrator`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer, description: Drupal user ID behind the consumer. }
                  roles:
                    type: array
                    items: { type: string }
                  scopes:
                    type: array
                    items: { type: string }
                  expires: { type: integer, description: Unix timestamp of token expiry. }
              example:
                id: 1
                roles: [authenticated, administrator]
                scopes: [default]
                expires: 1788206498
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Token valid, but the user lacks `debug simple_oauth tokens`.

  /oauth/userinfo:
    get:
      tags: [Health]
      summary: Who am I
      operationId: userinfo
      description: |
        OpenID Connect user-info endpoint. A lighter alternative to `/oauth/debug`
        that needs no special permission — any authenticated token works. Use it
        to prove a token is live when `/oauth/debug` is returning `403`.

        ```bash
        curl -sS 'https://e3test.waterrf.org/oauth/userinfo' \
          -H "Authorization: Bearer $TOKEN"
        ```
      responses:
        "200":
          description: Token is live.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sub: { type: string }
                  name: { type: string }
                  email: { type: string, format: email }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /oauth/jwks:
    get:
      tags: [Health]
      summary: Public keys (unauthenticated reachability check)
      operationId: jwks
      security: []
      description: |
        The only endpoint here that needs no credentials. Because of that it is
        the correct **first** check when an integration breaks.

        **This path returns `301` to `/.well-known/jwks.json`.** Verified
        2026-09-01. A client that does not follow redirects sees a bare `301`
        and may misread it as a failure — use `curl -L`, or request
        `/.well-known/jwks.json` directly.

        Interpretation:

        - `200` with a `keys` array → the site is up, routing is intact, and
          OAuth key material is deployed. The problem is your credentials.
        - `403`/`404`/`503` → the environment itself is the problem. Stop
          debugging tokens and escalate.

        ```bash
        curl -sS -o /dev/null -w '%{http_code}\n' \
          'https://e3test.waterrf.org/oauth/jwks'
        ```
      responses:
        "200":
          description: |
            Site reachable and OAuth keys present. Returned after following the
            redirect to `/.well-known/jwks.json`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { type: object }
        "301":
          description: |
            Expected. Redirects to `/.well-known/jwks.json`. Follow it.
          headers:
            Location:
              schema: { type: string, format: uri }
        "404":
          description: |
            Route missing — `simple_oauth` is disabled or not deployed on this
            environment. Escalate; no client change will help.
        "503":
          description: |
            Environment unavailable (maintenance mode, Acquia platform issue).
            Retry with backoff before escalating.

  /entity/node_type/{node_type}:
    get:
      tags: [Health, Content]
      summary: Read a content-type definition (reachability probe)
      operationId: getNodeType
      description: |
        A read-only, zero-side-effect call that proves the REST layer and JSON
        serializer are working end to end.

        **This resource is readable anonymously.** Verified 2026-09-01: a
        request with no `Authorization` header returned `200`. So a `200` here
        proves the request reached Drupal and REST is functioning — it does
        **not** prove your token is valid. Use `GET /oauth/userinfo` for that.

        Where it is still useful: if `/oauth/userinfo` returns `200` (token
        good) and a node `POST` returns `403`, the fault is specifically
        **content permissions** on the consumer's user, not the token.

        ```bash
        curl -sS 'https://e3test.waterrf.org/entity/node_type/resource?_format=json' \
          -H "Authorization: Bearer $TOKEN"
        ```
      parameters:
        - name: node_type
          in: path
          required: true
          description: Machine name of the content type.
          schema:
            type: string
            enum: [resource, project]
        - $ref: "#/components/parameters/format"
      responses:
        "200":
          description: Content type definition.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: No such content type. Check the machine name.
        "406": { $ref: "#/components/responses/NotAcceptable" }

  /node:
    post:
      tags: [Content]
      summary: Create a Resource or Project node
      operationId: createNode
      description: |
        Creates a node. The bundle is chosen by the `type` field in the body —
        there is no bundle in the URL.

        ### Required in every body

        `type` and `title`. Everything else is optional, though a NetSuite-owned
        node without `field_netsuite_id` is effectively orphaned from the sync
        and should be considered a bug in the calling script.

        ### Drupal's field format

        Every field value is an **array of objects**, even single-value fields:

        | Field kind | Shape |
        |---|---|
        | string / integer / float / boolean / datetime | `[{"value": ...}]` |
        | formatted text | `[{"value": "<p>…</p>", "format": "full_html"}]` |
        | entity reference | `[{"target_id": 123}]` |
        | multi-value | `[{"value": "A"}, {"value": "B"}]` |

        Scalars written as bare values (`"title": "Foo"`) return `422`.

        ### Author attribution

        Any `uid` you send is overwritten. `wrf_netsuite_node_presave()` detects
        the `Bearer` header and forces owner and revision user to the configured
        service account, then stamps the revision log with
        `Automated sync from NetSuite via REST API`. That log line is your audit
        trail when reconciling what the integration actually wrote.

        ### curl

        ```bash
        curl -sS -X POST 'https://e3test.waterrf.org/node?_format=json' \
          -H "Authorization: Bearer $TOKEN" \
          -H 'Content-Type: application/json' \
          -d '{
            "type":               [{"target_id": "resource"}],
            "title":              [{"value": "Nutrient Recovery from Sidestreams"}],
            "field_netsuite_id":  [{"value": "5581102"}],
            "field_product_code": [{"value": "4901"}],
            "field_description":  [{"value": "<p>Project summary.</p>", "format": "full_html"}]
          }'
        ```

        ### SuiteScript 2.1

        ```javascript
        define(['N/https'], (https) => {
          const BASE = 'https://e3test.waterrf.org';

          function createResource(token, data) {
            const res = https.post({
              url: BASE + '/node?_format=json',
              headers: {
                'Authorization': 'Bearer ' + token,
                'Content-Type': 'application/json'
              },
              body: JSON.stringify({
                type:               [{ target_id: 'resource' }],
                title:              [{ value: data.name }],
                field_netsuite_id:  [{ value: String(data.internalId) }],
                field_product_code: [{ value: data.itemId }],
                field_description:  [{ value: data.description, format: 'full_html' }]
              })
            });
            if (res.code !== 201) {
              throw new Error('Create failed: ' + res.code + ' ' + res.body);
            }
            // Keep the returned nid — you need it for every later PATCH.
            return JSON.parse(res.body).nid[0].value;
          }

          return { createResource };
        });
        ```

        **Store the returned `nid` in NetSuite.** Updates address nodes by `nid`,
        not by `field_netsuite_id`; without it a re-sync creates duplicates
        instead of updating.
      parameters:
        - $ref: "#/components/parameters/format"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              anyOf:
                - $ref: "#/components/schemas/ResourceNode"
                - $ref: "#/components/schemas/ProjectNode"
            examples:
              resource:
                summary: Resource (a published deliverable)
                value:
                  type: [{ target_id: resource }]
                  title: [{ value: Nutrient Recovery from Wastewater Sidestreams }]
                  status: [{ value: true }]
                  field_netsuite_id: [{ value: "5581102" }]
                  field_product_code: [{ value: "4901" }]
                  field_product_id: [{ value: 5581102 }]
                  field_project_number: [{ value: "4901" }]
                  field_subtitle: [{ value: Full technical report }]
                  field_description:
                    - value: <p>Evaluates mainstream and sidestream nutrient recovery.</p>
                      format: full_html
                  field_available_date: [{ value: "2026-09-01T12:00:00" }]
                  field_members_only: [{ value: false }]
                  field_outbound_water_category:
                    - value: Wastewater
                    - value: Resource Recovery
                  field_resource_type: [{ target_id: 42 }]
                  field_access: [{ target_id: 17 }]
                  field_project: [{ target_id: 1893 }]
              project:
                summary: Project (a research effort)
                value:
                  type: [{ target_id: project }]
                  title: [{ value: Sidestream Deammonification at Scale }]
                  status: [{ value: true }]
                  field_netsuite_id: [{ value: "5580017" }]
                  field_project_number: [{ value: "4901" }]
                  field_werf_id: [{ value: "U4R14" }]
                  field_abstract:
                    - value: <p>Three-year evaluation across six utilities.</p>
                      format: full_html
                  field_project_status: [{ value: FUNDED }]
                  field_start_date: [{ value: "2026-01-15T00:00:00" }]
                  field_projected_completed_date: [{ value: "2028-06-30T00:00:00" }]
                  field_funding_amount: [{ value: 750000 }]
                  field_research_program_type: [{ value: Research }]
                  field_web_enabled: [{ value: "Yes" }]
      responses:
        "201":
          description: |
            Created. The full serialized node is returned — read `nid[0].value`
            and persist it in NetSuite.
          headers:
            Location:
              description: Canonical URL of the new node.
              schema: { type: string, format: uri }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NodeResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /node/{nid}:
    parameters:
      - name: nid
        in: path
        required: true
        description: Drupal node ID returned when the node was created.
        schema: { type: integer }
      - $ref: "#/components/parameters/format"
    get:
      tags: [Content]
      summary: Read a node
      operationId: getNode
      description: |
        Returns the full serialized node. Useful for confirming that a write
        landed and that field values look the way NetSuite intended.

        ```bash
        curl -sS 'https://e3test.waterrf.org/node/1893?_format=json' \
          -H "Authorization: Bearer $TOKEN"
        ```
      responses:
        "200":
          description: The node.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NodeResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: No node with that ID. }
        "406": { $ref: "#/components/responses/NotAcceptable" }
    patch:
      tags: [Content]
      summary: Update a node
      operationId: patchNode
      description: |
        Partial update — send only the fields that changed, **plus** `type`.
        Omitting `type` returns `422` even though the bundle cannot change.

        Fields absent from the body are left untouched. To clear a field, send
        it as an empty array: `"field_subtitle": []`.

        `PUT` is accepted: `wrf_restapi_put` rewrites the method to `PATCH`
        before routing, so legacy NetSuite scripts continue to work unchanged.
        New scripts should send `PATCH` directly.

        ### curl

        ```bash
        curl -sS -X PATCH 'https://e3test.waterrf.org/node/1893?_format=json' \
          -H "Authorization: Bearer $TOKEN" \
          -H 'Content-Type: application/json' \
          -d '{
            "type":               [{"target_id": "resource"}],
            "field_product_code": [{"value": "4901-A"}],
            "field_description":  [{"value": "<p>Revised summary.</p>", "format": "full_html"}]
          }'
        ```

        ### SuiteScript 2.1

        ```javascript
        // N/https has no patch() helper — use request() with method 'PATCH'.
        const res = https.request({
          method: https.Method.PATCH,
          url: BASE + '/node/' + nid + '?_format=json',
          headers: {
            'Authorization': 'Bearer ' + token,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            type:               [{ target_id: 'resource' }],
            field_product_code: [{ value: '4901-A' }]
          })
        });
        if (res.code !== 200) {
          throw new Error('Update failed: ' + res.code + ' ' + res.body);
        }
        ```
      requestBody:
        required: true
        content:
          application/json:
            schema:
              anyOf:
                - $ref: "#/components/schemas/ResourceNodeFields"
                - $ref: "#/components/schemas/ProjectNodeFields"
            examples:
              minimal:
                summary: Change one field
                value:
                  type: [{ target_id: resource }]
                  field_product_code: [{ value: "4901-A" }]
              clearField:
                summary: Clear a field
                value:
                  type: [{ target_id: resource }]
                  field_subtitle: []
              unpublish:
                summary: Unpublish
                value:
                  type: [{ target_id: resource }]
                  status: [{ value: false }]
      responses:
        "200":
          description: Updated. The full node is returned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NodeResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: No node with that ID. }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    put:
      tags: [Content]
      summary: Update a node (legacy alias for PATCH)
      operationId: putNode
      deprecated: true
      description: |
        Kept only for existing NetSuite scripts. `wrf_restapi_put` rewrites the
        method to `PATCH` at the kernel-request level, so behaviour, body format,
        and responses are **identical to PATCH** — including the requirement to
        include `type`.

        Do not use for new work.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              anyOf:
                - $ref: "#/components/schemas/ResourceNodeFields"
                - $ref: "#/components/schemas/ProjectNodeFields"
      responses:
        "200":
          description: Updated, handled as PATCH.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NodeResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /taxonomy/term:
    post:
      tags: [Taxonomy]
      summary: Create a taxonomy term
      operationId: createTerm
      description: |
        Creates a term in a vocabulary. Terms back the `field_access`,
        `field_resource_type`, and `field_principle_investigator` references on
        content, so a term usually has to exist before the node that points at it.

        ```bash
        curl -sS -X POST 'https://e3test.waterrf.org/taxonomy/term?_format=json' \
          -H "Authorization: Bearer $TOKEN" \
          -H 'Content-Type: application/json' \
          -d '{
            "vid":  [{"target_id": "topics"}],
            "name": [{"value": "Nutrient Recovery"}]
          }'
        ```
      parameters:
        - $ref: "#/components/parameters/format"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TaxonomyTerm" }
            example:
              vid: [{ target_id: topics }]
              name: [{ value: Nutrient Recovery }]
              description:
                - value: <p>Recovery of nitrogen and phosphorus.</p>
                  format: full_html
      responses:
        "201":
          description: Created. Read `tid[0].value` for the reference target.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /taxonomy/term/{tid}:
    parameters:
      - name: tid
        in: path
        required: true
        schema: { type: integer }
      - $ref: "#/components/parameters/format"
    get:
      tags: [Taxonomy]
      summary: Read a taxonomy term
      operationId: getTerm
      description: |
        Confirm a `target_id` before referencing it from a node. A node write
        pointing at a non-existent term returns `422`, not `404` — so verify
        here first when a create fails with a validation error you cannot place.
      responses:
        "200": { description: The term. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: No term with that ID. }
        "406": { $ref: "#/components/responses/NotAcceptable" }
    patch:
      tags: [Taxonomy]
      summary: Update a taxonomy term
      operationId: patchTerm
      description: |
        Partial update. Include `vid` alongside the changed fields, for the same
        reason nodes need `type`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TaxonomyTerm" }
            example:
              vid: [{ target_id: topics }]
              name: [{ value: Nutrient Recovery and Reuse }]
      responses:
        "200": { description: Updated. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "422": { $ref: "#/components/responses/Unprocessable" }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        `Authorization: Bearer <access_token>` from `POST /oauth/token`.
        Valid for 300 seconds.
    oauth2:
      type: oauth2
      description: |
        Client credentials grant. Documented for completeness — the token
        endpoint cannot be exercised from a browser because of the CORS policy
        described in the introduction.
      flows:
        clientCredentials:
          tokenUrl: /oauth/token
          scopes:
            default: Full API access, bound to the `administrator` role.

  parameters:
    format:
      name: _format
      in: query
      required: true
      description: |
        **Mandatory on every request.** Drupal's REST layer selects its
        serializer from this parameter, not from the `Accept` header. Omit it
        and you get `406 Not Acceptable` with an empty body — the single most
        frequently misdiagnosed error on this API.
      schema:
        type: string
        enum: [json]
        default: json

  responses:
    Unauthorized:
      description: |
        Missing, malformed, or expired bearer token.

        Given the 300-second lifetime, an integration that worked five minutes
        ago and now returns `401` has an **expired token**, not revoked
        credentials. Re-request before investigating anything else.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/DrupalError" }
          example:
            message: ""
    Forbidden:
      description: |
        **First, check whether Drupal answered at all.** If the body is HTML and
        the response carries `cf-mitigated: challenge`, this is the Cloudflare
        WAF, not Drupal — see "Is it the WAF or is it Drupal?" in the
        introduction. A JSON `{"message": …}` body means Drupal answered.

        For a genuine Drupal `403`: the token is valid but the user behind it may
        not perform this operation.

        Check, in order:
        1. `GET /oauth/debug` — does `roles` include `administrator`?
        2. Is the requested scope `default`? That scope is role-bound to
           `administrator`; a different scope resolves to a user without
           content permissions.
        3. Does the target node's content type allow the operation?

        A `403` on writes while `GET /entity/node_type/resource` returns `200`
        isolates the fault to content permissions rather than the token.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/DrupalError" }
          example:
            message: "The 'administer nodes' permission is required."
    NotAcceptable:
      description: |
        `?_format=json` was omitted. The response body is typically empty, which
        makes this look like a network fault. It is not — add the parameter.
    UnsupportedMediaType:
      description: |
        `Content-Type: application/json` was missing on a POST/PATCH. Some HTTP
        clients drop the header when the body is empty or when a helper method
        sets it implicitly; SuiteScript's `https.request()` requires you to set
        it yourself.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/DrupalError" }
          example:
            message: 'No route found that matches "Content-Type: text/plain"'
    Unprocessable:
      description: |
        The body reached Drupal but failed entity validation. The `message`
        names the offending field — read it before changing anything.

        Frequent causes:
        - `type` (or `vid`) missing from the body.
        - A scalar sent bare instead of as `[{"value": …}]`.
        - `target_id` pointing at a node or term that does not exist.
        - A `list_string` value outside its allowed set — e.g.
          `field_project_status` accepts only `CANCELLED`, `CLOSED`, `FUNDED`,
          `UNFUNDED`, `PUBLISHED`.
        - A datetime not in `YYYY-MM-DDTHH:MM:SS` form.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/DrupalError" }
          examples:
            missingBundle:
              value:
                message: "Entity type 'node' does not support bundles, or no bundle was provided."
            badValue:
              value:
                message: "Unprocessable Entity: validation failed.\nfield_project_status.0.value: The value you selected is not a valid choice."

  schemas:

    TokenResponse:
      type: object
      properties:
        token_type:
          type: string
          example: Bearer
        expires_in:
          type: integer
          example: 300
          description: Seconds until expiry. Configured at 300 on this site.
        access_token:
          type: string
          description: JWT to send as the bearer credential.
      example:
        token_type: Bearer
        expires_in: 300
        access_token: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIyZjljMWI3ZS00ZDNh...

    OAuthError:
      type: object
      description: RFC 6749 error body returned by the token endpoint.
      properties:
        error: { type: string }
        error_description: { type: string }
        hint: { type: string }
        message: { type: string }

    DrupalError:
      type: object
      description: |
        Drupal REST error body. `message` is the field to read; it is often the
        only diagnostic available.
      properties:
        message: { type: string }

    FieldValue:
      type: object
      description: A plain field item — string, number, boolean, or datetime.
      properties:
        value:
          description: The scalar value.
      required: [value]

    FormattedTextValue:
      type: object
      description: A formatted-text field item.
      properties:
        value:
          type: string
          description: HTML markup.
        format:
          type: string
          description: |
            Text format machine name, e.g. `full_html`. Deprecated `<i>` and
            `<b>` tags in `field_description`, `field_abstract`, `body`, and
            `field_access_only_body` are rewritten to `<em>` and `<strong>` on
            save — expect the stored markup to differ from what you sent.
      required: [value]

    ReferenceValue:
      type: object
      description: An entity-reference field item.
      properties:
        target_id:
          description: |
            Numeric entity ID for content/taxonomy references; machine name
            for bundle references such as `type` and `vid`.
          oneOf:
            - type: integer
            - type: string
      required: [target_id]

    ResourceNode:
      description: |
        Resource node as sent on **create**. Same fields as
        `ResourceNodeFields`, with `title` additionally required.
      allOf:
        - $ref: "#/components/schemas/ResourceNodeFields"
        - type: object
          required: [title]

    ResourceNodeFields:
      type: object
      description: |
        Resource field catalogue — a published deliverable (report, webcast,
        case study). NetSuite is the source of truth for the commerce fields;
        Drupal treats them as a read-mostly mirror.

        Only `type` is required, which makes this the correct shape for a
        partial `PATCH`.
      required: [type]
      properties:
        type:
          type: array
          description: 'Bundle. Required on POST **and** PATCH: `[{"target_id": "resource"}]`.'
          items: { $ref: "#/components/schemas/ReferenceValue" }
        title:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        status:
          type: array
          description: 'Published flag: `[{"value": true}]` or `[{"value": false}]`.'
          items: { $ref: "#/components/schemas/FieldValue" }
        field_netsuite_id:
          type: array
          description: NetSuite internal ID, as a string. The correlation key — always send it.
          items: { $ref: "#/components/schemas/FieldValue" }
        field_product_code:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_product_id:
          type: array
          description: Integer.
          items: { $ref: "#/components/schemas/FieldValue" }
        field_werf_product_code:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_project_number:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_subtitle:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_description:
          type: array
          items: { $ref: "#/components/schemas/FormattedTextValue" }
        body:
          type: array
          items: { $ref: "#/components/schemas/FormattedTextValue" }
        field_available_date:
          type: array
          description: '`YYYY-MM-DDTHH:MM:SS`, stored as UTC.'
          items: { $ref: "#/components/schemas/FieldValue" }
        field_members_only:
          type: array
          description: Boolean.
          items: { $ref: "#/components/schemas/FieldValue" }
        field_outbound_water_category:
          type: array
          description: Multi-value string.
          items: { $ref: "#/components/schemas/FieldValue" }
        field_resource_type:
          type: array
          description: Taxonomy term reference.
          items: { $ref: "#/components/schemas/ReferenceValue" }
        field_access:
          type: array
          description: |
            Taxonomy term reference that drives gated-content access. Changing
            it changes who can see the asset — treat writes here as sensitive.
          items: { $ref: "#/components/schemas/ReferenceValue" }
        field_project:
          type: array
          description: Reference to the parent Project **node** (not a term).
          items: { $ref: "#/components/schemas/ReferenceValue" }
        field_topics:
          type: array
          description: Multi-value reference to Topic **nodes**.
          items: { $ref: "#/components/schemas/ReferenceValue" }

    ProjectNode:
      description: |
        Project node as sent on **create**. Same fields as
        `ProjectNodeFields`, with `title` additionally required.
      allOf:
        - $ref: "#/components/schemas/ProjectNodeFields"
        - type: object
          required: [title]

    ProjectNodeFields:
      type: object
      description: |
        Project field catalogue — a research effort, with Resources hanging off
        it. Only `type` is required, so this is also the `PATCH` shape.
      required: [type]
      properties:
        type:
          type: array
          description: '`[{"target_id": "project"}]`.'
          items: { $ref: "#/components/schemas/ReferenceValue" }
        title:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        status:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_netsuite_id:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_project_number:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_werf_id:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_abstract:
          type: array
          items: { $ref: "#/components/schemas/FormattedTextValue" }
        field_project_status:
          type: array
          description: |
            Constrained list. Allowed values: `CANCELLED`, `CLOSED`, `FUNDED`,
            `UNFUNDED`, `PUBLISHED`. Anything else returns `422`.
          items:
            type: object
            properties:
              value:
                type: string
                enum: [CANCELLED, CLOSED, FUNDED, UNFUNDED, PUBLISHED]
        field_start_date:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_project_completed_date:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_projected_completed_date:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_funding_amount:
          type: array
          description: Float.
          items: { $ref: "#/components/schemas/FieldValue" }
        field_research_investment_amount:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_research_program_type:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        field_web_enabled:
          type: array
          description: String, not boolean.
          items: { $ref: "#/components/schemas/FieldValue" }
        field_principle_investigator:
          type: array
          description: Taxonomy term reference.
          items: { $ref: "#/components/schemas/ReferenceValue" }
        field_topics:
          type: array
          items: { $ref: "#/components/schemas/ReferenceValue" }

    TaxonomyTerm:
      type: object
      required: [vid, name]
      properties:
        vid:
          type: array
          description: 'Vocabulary machine name, e.g. `[{"target_id": "topics"}]`.'
          items: { $ref: "#/components/schemas/ReferenceValue" }
        name:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        description:
          type: array
          items: { $ref: "#/components/schemas/FormattedTextValue" }
        weight:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }

    NodeResponse:
      type: object
      description: |
        A fully serialized node. Every field is an array of items, matching the
        request format. `nid[0].value` is the ID to store in NetSuite.
      properties:
        nid:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        uuid:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        type:
          type: array
          items: { $ref: "#/components/schemas/ReferenceValue" }
        title:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        status:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        changed:
          type: array
          items: { $ref: "#/components/schemas/FieldValue" }
        revision_log:
          type: array
          description: |
            Stamped by the integration as
            `Automated sync from NetSuite via REST API (OAuth user: …)`.
          items: { $ref: "#/components/schemas/FieldValue" }
      additionalProperties: true
      example:
        nid: [{ value: 1893 }]
        uuid: [{ value: 8c1f4d2a-71b3-4e9c-a0d5-6f2e3b4c5d6e }]
        type: [{ target_id: resource }]
        title: [{ value: Nutrient Recovery from Wastewater Sidestreams }]
        status: [{ value: true }]
        changed: [{ value: "2026-09-01T15:22:41+00:00" }]
        revision_log: [{ value: "Automated sync from NetSuite via REST API (OAuth user: netsuite_service)" }]
