{
  "info": {
    "_postman_id": "7b0f3c2e-6a1d-4e0b-9f51-2f8c1d6e4a90",
    "name": "Foldr API v1",
    "description": "Every operation of Foldr's API v1, in the order you'd try them, with the OAuth sign-in set up once on the collection.\n\n**Before you start:** connected apps must be on for the site (**Settings > Connected apps**), and the person you sign in as must be allowed to connect apps. See the developer guide's *Getting access* page.\n\n1. Import this collection and the *Foldr site* environment, choose the environment, and set `site` to your Foldr site's address.\n2. Run **0. Set up > Register this collection as an app**. It saves `client_id`.\n3. Open the collection's **Authorization** tab, choose **Get New Access Token**, sign in to Foldr and allow. Then **Use Token**.\n4. Run **Who am I**, then **List shares**, and carry on down the folders.\n\nA self-registered app can only read. Folders 3 to 6 change things and need scopes an administrator allows in the app's policy; add them to `scopes` and get a new token. They work in a folder of their own (`scratch_folder`), which the last request deletes.\n\nEach request saves what the next one needs (a share id, a path, an ETag) in the environment. Refusals show their problem type in the test results.\n",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "oauth2",
    "oauth2": [
      {
        "key": "grant_type",
        "value": "authorization_code_with_pkce",
        "type": "string"
      },
      {
        "key": "authUrl",
        "value": "{{site}}/oauth/authorize",
        "type": "string"
      },
      {
        "key": "accessTokenUrl",
        "value": "{{site}}/oauth/token",
        "type": "string"
      },
      {
        "key": "refreshTokenUrl",
        "value": "{{site}}/oauth/token",
        "type": "string"
      },
      {
        "key": "clientId",
        "value": "{{client_id}}",
        "type": "string"
      },
      {
        "key": "clientSecret",
        "value": "{{client_secret}}",
        "type": "string"
      },
      {
        "key": "scope",
        "value": "{{scopes}}",
        "type": "string"
      },
      {
        "key": "state",
        "value": "{{oauth_state}}",
        "type": "string"
      },
      {
        "key": "redirect_uri",
        "value": "https://oauth.pstmn.io/v1/callback",
        "type": "string"
      },
      {
        "key": "useBrowser",
        "value": false,
        "type": "boolean"
      },
      {
        "key": "challengeAlgorithm",
        "value": "S256",
        "type": "string"
      },
      {
        "key": "client_authentication",
        "value": "body",
        "type": "string"
      },
      {
        "key": "tokenName",
        "value": "Foldr {{site}}",
        "type": "string"
      },
      {
        "key": "addTokenTo",
        "value": "header",
        "type": "string"
      },
      {
        "key": "headerPrefix",
        "value": "Bearer",
        "type": "string"
      },
      {
        "key": "accessToken",
        "value": "{{access_token}}",
        "type": "string"
      },
      {
        "key": "authRequestParams",
        "value": [
          {
            "key": "resource",
            "value": "{{site}}/api/v1",
            "enabled": true,
            "send_as": "request_url"
          }
        ],
        "type": "any"
      },
      {
        "key": "tokenRequestParams",
        "value": [
          {
            "key": "resource",
            "value": "{{site}}/api/v1",
            "enabled": true,
            "send_as": "request_body"
          }
        ],
        "type": "any"
      }
    ]
  },
  "item": [
    {
      "name": "0. Set up",
      "description": "Register this collection as an app on your Foldr site, check the site's sign-in addresses, then get a token (the collection's **Authorization** tab, **Get New Access Token**).",
      "item": [
        {
          "name": "Register this collection as an app",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{site}}/oauth/register",
              "host": [
                "{{site}}"
              ],
              "path": [
                "oauth",
                "register"
              ]
            },
            "description": "Registers Postman with your Foldr site as a public app (dynamic client registration, RFC 7591) and saves its `client_id` in the environment. Run it once per site.\n\nA self-registered app can only read. To try the changes in folders 3 to 6, an administrator opens the app under **Settings > Connected apps > Policy** and allows them. If the site requires approval of self-registered apps, an administrator approves it there first.\n\nIf an administrator has already added Postman by hand, skip this and put its client ID in the environment's `client_id` instead.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"client_name\": \"Postman\",\n  \"redirect_uris\": [\n    \"https://oauth.pstmn.io/v1/callback\",\n    \"https://oauth.pstmn.io/v1/browser-callback\"\n  ],\n  \"token_endpoint_auth_method\": \"none\",\n  \"grant_types\": [\n    \"authorization_code\",\n    \"refresh_token\"\n  ],\n  \"response_types\": [\n    \"code\"\n  ],\n  \"scope\": \"foldr.read offline_access profile\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 201', () => pm.expect(pm.response.code).to.be.oneOf([201]));",
                  "if (pm.response.code === 201) {",
                  "    pm.environment.set('client_id', pm.response.json().client_id);",
                  "    console.log('client_id saved to the environment: ' + pm.response.json().client_id);",
                  "}"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "The site's sign-in addresses",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{site}}/.well-known/oauth-authorization-server",
              "host": [
                "{{site}}"
              ],
              "path": [
                ".well-known",
                "oauth-authorization-server"
              ]
            },
            "description": "The authorisation server's own description (RFC 8414). The collection's OAuth settings use `{{site}}/oauth/authorize` and `{{site}}/oauth/token`; this shows they are right for your site.",
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Who am I (getMe)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/me",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "me"
              ]
            },
            "description": "`getMe`: the person the token acts for. Needs `profile`. The first call to make once you have a token."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        }
      ]
    },
    {
      "name": "1. Shares",
      "description": "The shares the person can see through the app. Run **List shares** first: it picks a share for the rest of the collection.",
      "item": [
        {
          "name": "List shares (listShares)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/shares?limit=100",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "100"
                }
              ]
            },
            "description": "`listShares`. Saves the first share the person can change (and that is reachable) as `share_id`, unless you have set `share_id` yourself. To work in another share, copy its `id` into the environment's `share_id`."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));",
                  "if (pm.response.code === 200 && !pm.environment.get('share_id')) {",
                  "    const shares = pm.response.json().items;",
                  "    const pick = shares.find(s => !s.read_only && s.reachable) || shares[0];",
                  "    if (pick) {",
                  "        pm.environment.set('share_id', pick.id);",
                  "        console.log('share_id set to ' + pick.id + ' (' + pick.name + ')');",
                  "    }",
                  "}"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Get a share (getShare)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}"
              ]
            },
            "description": "`getShare`: one share, by id."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        }
      ]
    },
    {
      "name": "2. Reading",
      "description": "Listings, metadata, downloads, search, custom fields and versions. Everything here needs only `foldr.read`.\n\nSet `folder` to the folder to list (`/` is the top of the share). **List a folder** sets `item_path` to the first file it sees, unless you have set it.",
      "item": [
        {
          "name": "List a folder (listChildren)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/children?path={{folder}}&limit=100",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "children"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{folder}}"
                },
                {
                  "key": "limit",
                  "value": "100"
                }
              ]
            },
            "description": "`listChildren`. Pages come back with `next_cursor`: to get the next page, add `cursor` with that value (and the same `path`)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));",
                  "if (pm.response.code === 200 && !pm.environment.get('item_path')) {",
                  "    const file = pm.response.json().items.find(i => i.kind === 'file');",
                  "    if (file) pm.environment.set('item_path', file.path);",
                  "}"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Get an item (getItem)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/item?path={{item_path}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "item"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{item_path}}"
                }
              ]
            },
            "description": "`getItem`: one file or folder, by path. Its `etag` is what later changes send back as `If-Match`."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Download a file (getContent)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/content?path={{item_path}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "content"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{item_path}}"
                }
              ]
            },
            "description": "`getContent`: the file's bytes. Add `direct=1` to be redirected straight to the storage where the share allows it (Postman follows the redirect)."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200 or 206', () => pm.expect(pm.response.code).to.be.oneOf([200, 206]));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Get a download link (getDownloadUrl)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/download-url?path={{item_path}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "download-url"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{item_path}}"
                }
              ]
            },
            "description": "`getDownloadUrl`: a short-lived address that downloads the file with no token. Treat it as a secret."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Search (search)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/search?q={{search_query}}&limit=20",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "search"
              ],
              "query": [
                {
                  "key": "q",
                  "value": "{{search_query}}"
                },
                {
                  "key": "limit",
                  "value": "20"
                }
              ]
            },
            "description": "`search`: full-text search across the person's indexed shares. Add `share_id` to search one share. A hit's `etag` is `null`; `getItem` gives the live one."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "List custom fields (listFields)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/fields",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "fields"
              ]
            },
            "description": "`listFields`: the custom fields on the person's shares. Saves the first one the person may set as `field_id`, unless you have set it."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));",
                  "if (pm.response.code === 200 && !pm.environment.get('field_id')) {",
                  "    const field = pm.response.json().find(f => !f.read_only);",
                  "    if (field) pm.environment.set('field_id', field.id);",
                  "}"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Get a file's field values (getFieldValues)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/field-values?path={{item_path}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "field-values"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{item_path}}"
                }
              ]
            },
            "description": "`getFieldValues`: the custom field values on one file."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "List a file's versions (listVersions)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/versions?path={{item_path}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "versions"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{item_path}}"
                }
              ]
            },
            "description": "`listVersions`: the earlier versions the storage keeps, where it keeps any."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        }
      ]
    },
    {
      "name": "3. Making things",
      "description": "Needs `files.write`, which an administrator allows per app (**Settings > Connected apps > Policy**). Run these in order: they work in a folder of their own, `scratch_folder`, and **Delete the playground folder** at the end of folder 6 removes it.",
      "item": [
        {
          "name": "Create the playground folder (createFolder)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "A new key for every request. To retry one whose answer you never saw, send the same key again."
              }
            ],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/folders",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "folders"
              ]
            },
            "description": "`createFolder`. Makes `scratch_folder` (renamed if the name is taken) and saves its path as `scratch_path`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"path\": \"{{scratch_folder}}\",\n  \"on_conflict\": \"rename\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 201', () => pm.expect(pm.response.code).to.be.oneOf([201]));",
                  "if (pm.response.code === 201) pm.environment.set('scratch_path', pm.response.json().path);"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Upload a file (uploadFile)",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/octet-stream"
              },
              {
                "key": "If-None-Match",
                "value": "*",
                "description": "Create only: refuse (412) if the name is taken."
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "A new key for every request. To retry one whose answer you never saw, send the same key again."
              }
            ],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/files?path={{scratch_path}}/hello.txt",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "files"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{scratch_path}}/hello.txt"
                }
              ]
            },
            "description": "`uploadFile`: the body is the file's raw bytes, any size. It creates and never replaces, so `If-None-Match: *` is required. To send a file of your own, change the body to **binary** and pick it. Saves `file_path` and `file_etag`.",
            "body": {
              "mode": "raw",
              "raw": "Hello from Postman.\n"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 201', () => pm.expect(pm.response.code).to.be.oneOf([201]));",
                  "if (pm.response.code === 201) {",
                  "    pm.environment.set('file_path', pm.response.json().path);",
                  "    pm.environment.set('file_etag', pm.response.headers.get('ETag') || pm.response.json().etag);",
                  "}"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Rename the file (renameItem)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "If-Match",
                "value": "{{file_etag}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/rename",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "rename"
              ]
            },
            "description": "`renameItem`. Needs the file's current ETag as `If-Match`. Saves the new `file_path` and `file_etag`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"path\": \"{{file_path}}\",\n  \"new_name\": \"hello (renamed).txt\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));",
                  "if (pm.response.code === 200) {",
                  "    pm.environment.set('file_path', pm.response.json().path);",
                  "    pm.environment.set('file_etag', pm.response.headers.get('ETag') || pm.response.json().etag);",
                  "}"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Copy the file (copyItem)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "A new key for every request. To retry one whose answer you never saw, send the same key again."
              },
              {
                "key": "Prefer",
                "value": "respond-async"
              }
            ],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/copy",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "copy"
              ]
            },
            "description": "`copyItem`, asked for as an operation (`Prefer: respond-async`). A `202` saves `operation_id`: run **Check on the operation** until it answers `200`. A small copy may finish at once with `201`.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"path\": \"{{file_path}}\",\n  \"to_share\": \"{{share_id}}\",\n  \"to_path\": \"{{scratch_path}}/hello copy.txt\",\n  \"on_conflict\": \"rename\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 201 or 202', () => pm.expect(pm.response.code).to.be.oneOf([201, 202]));",
                  "if (pm.response.code === 202) pm.environment.set('operation_id', pm.response.json().id);",
                  "if (pm.response.code === 201) {",
                  "    pm.environment.set('copy_path', pm.response.json().path);",
                  "    pm.environment.set('copy_etag', pm.response.json().etag);",
                  "}"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Check on the operation (getOperation)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/operations/{{operation_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "operations",
                "{{operation_id}}"
              ]
            },
            "description": "`getOperation`. `202` while it runs (wait `Retry-After` seconds and send again); `200` once it has succeeded, with what it made in `result`; a problem if it failed."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200 or 202', () => pm.expect(pm.response.code).to.be.oneOf([200, 202]));",
                  "if (pm.response.code === 200 && pm.response.json().result) {",
                  "    pm.environment.set('copy_path', pm.response.json().result.path);",
                  "    pm.environment.set('copy_etag', pm.response.json().result.etag);",
                  "}"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "List operations (listOperations)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/operations?limit=50",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "operations"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                }
              ]
            },
            "description": "`listOperations`: this app's operations for this person, newest first."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Cancel the operation (cancelOperation)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/operations/{{operation_id}}/cancel",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "operations",
                "{{operation_id}}",
                "cancel"
              ]
            },
            "description": "`cancelOperation`. Only does something while the operation is waiting or running; one that has finished answers `409 conflict`, which is normal here."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 204', () => pm.expect(pm.response.code).to.be.oneOf([204]));"
                ]
              }
            }
          ],
          "response": []
        }
      ]
    },
    {
      "name": "3a. Uploading straight to the storage",
      "description": "Upload sessions: the bytes go from the app straight to the storage, on shares that support it (S3 and compatible, Microsoft 365, Dropbox). Elsewhere **Open an upload session** answers `409 no-direct-upload`. Needs `files.write`. Run **Create the playground folder** first.",
      "item": [
        {
          "name": "Open an upload session (createUploadSession)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{base_url}}/uploads",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "uploads"
              ]
            },
            "description": "`createUploadSession` for a 16-byte file. Saves the session's `id`, `url`, `method` and `headers` for the next request.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"share_id\": \"{{share_id}}\",\n  \"path\": \"{{scratch_path}}/direct.txt\",\n  \"size\": 16,\n  \"on_conflict\": \"rename\",\n  \"content_type\": \"text/plain\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 201', () => pm.expect(pm.response.code).to.be.oneOf([201]));",
                  "if (pm.response.code === 201) {",
                  "    const s = pm.response.json();",
                  "    pm.environment.set('upload_id', s.id);",
                  "    pm.environment.set('upload_url', s.url);",
                  "    pm.environment.set('upload_method', s.method);",
                  "    pm.environment.set('upload_headers', JSON.stringify(s.headers || {}));",
                  "}"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Send the bytes to the storage",
          "request": {
            "method": "PUT",
            "header": [],
            "url": {
              "raw": "{{upload_url}}",
              "host": [
                "{{upload_url}}"
              ]
            },
            "description": "Not a Foldr call: the session's `url`, with its `method` and exactly its `headers`, and no Foldr token (the URL is the credential; don't share it). The body is exactly 16 bytes, the `size` the session was opened with.",
            "body": {
              "mode": "raw",
              "raw": "0123456789abcdef"
            },
            "auth": {
              "type": "noauth"
            }
          },
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.request.method = pm.environment.get('upload_method') || 'PUT';",
                  "const headers = JSON.parse(pm.environment.get('upload_headers') || '{}');",
                  "Object.keys(headers).forEach(k => pm.request.headers.upsert({ key: k, value: headers[k] }));"
                ]
              }
            },
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('the storage accepted the bytes', () => pm.expect(pm.response.code).to.be.within(200, 299));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Complete the upload (completeUploadSession)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/uploads/{{upload_id}}/complete",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "uploads",
                "{{upload_id}}",
                "complete"
              ]
            },
            "description": "`completeUploadSession`: Foldr checks the storage holds exactly `size` bytes and answers with the new file."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 201', () => pm.expect(pm.response.code).to.be.oneOf([201]));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Cancel the upload (cancelUploadSession)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/uploads/{{upload_id}}/cancel",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "uploads",
                "{{upload_id}}",
                "cancel"
              ]
            },
            "description": "`cancelUploadSession`: abandon a session instead of completing it. One already completed answers `409 conflict`."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 204', () => pm.expect(pm.response.code).to.be.oneOf([204]));"
                ]
              }
            }
          ],
          "response": []
        }
      ]
    },
    {
      "name": "4. Custom fields",
      "description": "Needs `fields.write`, allowed per app by an administrator. Run **List custom fields** (folder 2) and **Upload a file** (folder 3) first, and set `field_value` to a value the field takes.",
      "item": [
        {
          "name": "Read the file's field values (getFieldValues)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/field-values?path={{file_path}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "field-values"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{file_path}}"
                }
              ]
            },
            "description": "`getFieldValues` on the playground file. Saves the values' ETag as `field_values_etag` for the next request."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));",
                  "if (pm.response.code === 200) pm.environment.set('field_values_etag', pm.response.headers.get('ETag'));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Set a field value (setFieldValue)",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "If-Match",
                "value": "{{field_values_etag}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/field-values/{{field_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "field-values",
                "{{field_id}}"
              ]
            },
            "description": "`setFieldValue`. `If-Match` is the field values' ETag (or `*` for whatever they are now).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"path\": \"{{file_path}}\",\n  \"value\": \"{{field_value}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        }
      ]
    },
    {
      "name": "5. Sharing",
      "description": "Needs `sharing`, allowed per app by an administrator. The app never gets a link: every request waits for an approver, who is told about it.",
      "item": [
        {
          "name": "Ask to share the file (createShareRequest)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "A new key for every request. To retry one whose answer you never saw, send the same key again."
              }
            ],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/share-requests",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "share-requests"
              ]
            },
            "description": "`createShareRequest` for a public link that ends after a day. Saves `share_request_id`. An approver is told about it, so expect someone to see it.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"path\": \"{{file_path}}\",\n  \"kind\": \"public_link\",\n  \"expiry_days\": 1,\n  \"message\": \"Trying the Foldr API from Postman\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 202', () => pm.expect(pm.response.code).to.be.oneOf([202]));",
                  "if (pm.response.code === 202) pm.environment.set('share_request_id', pm.response.json().id);"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "List share requests (listShareRequests)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/share-requests?limit=50",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "share-requests"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50"
                }
              ]
            },
            "description": "`listShareRequests`: this app's requests for this person."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Check on the share request (getShareRequest)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/share-requests/{{share_request_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "share-requests",
                "{{share_request_id}}"
              ]
            },
            "description": "`getShareRequest`: `pending_approval` until an approver decides."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));"
                ]
              }
            }
          ],
          "response": []
        }
      ]
    },
    {
      "name": "6. Replacing, moving and deleting",
      "description": "Each needs its own scope (`files.overwrite`, `files.move`, `files.delete`), which an administrator allows per app and only in shares they name. On storage that keeps no earlier versions or has no recycle bin, these can't be undone: run them in the playground folder only.",
      "item": [
        {
          "name": "Replace the file's contents (overwriteFile)",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/octet-stream"
              },
              {
                "key": "If-Match",
                "value": "{{file_etag}}"
              }
            ],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/content?path={{file_path}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "content"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{file_path}}"
                }
              ]
            },
            "description": "`overwriteFile`: new bytes over an existing file. `If-Match` must be the ETag you last saw (`*` is refused). Saves the new `file_etag`.",
            "body": {
              "mode": "raw",
              "raw": "Hello again from Postman.\n"
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));",
                  "if (pm.response.code === 200) pm.environment.set('file_etag', pm.response.headers.get('ETag') || pm.response.json().etag);"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "List the file's versions (listVersions)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/versions?path={{file_path}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "versions"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{file_path}}"
                }
              ]
            },
            "description": "`listVersions`. Saves the newest earlier version as `version_id`, where the storage keeps one."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));",
                  "if (pm.response.code === 200) {",
                  "    const earlier = pm.response.json().items.find(v => !v.current);",
                  "    if (earlier) pm.environment.set('version_id', earlier.id);",
                  "}"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Put an earlier version back (restoreVersion)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "If-Match",
                "value": "{{file_etag}}"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "A new key for every request. To retry one whose answer you never saw, send the same key again."
              }
            ],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/versions/restore",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "versions",
                "restore"
              ]
            },
            "description": "`restoreVersion`: puts `version_id` back over the file.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"path\": \"{{file_path}}\",\n  \"version_id\": \"{{version_id}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200 or 202', () => pm.expect(pm.response.code).to.be.oneOf([200, 202]));",
                  "if (pm.response.code === 200) pm.environment.set('file_etag', pm.response.headers.get('ETag') || pm.response.json().etag);"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Move the copy (moveItem)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "If-Match",
                "value": "{{copy_etag}}"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "A new key for every request. To retry one whose answer you never saw, send the same key again."
              }
            ],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/move",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "move"
              ]
            },
            "description": "`moveItem`: moves the copy made in folder 3 to a new name in the playground folder. Add `to_share` to move it to another share.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"path\": \"{{copy_path}}\",\n  \"to_path\": \"{{scratch_path}}/moved.txt\",\n  \"on_conflict\": \"rename\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200 or 202', () => pm.expect(pm.response.code).to.be.oneOf([200, 202]));"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Read the playground folder (getItem)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/item?path={{scratch_path}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "item"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{scratch_path}}"
                }
              ]
            },
            "description": "`getItem` on the playground folder, for the ETag the delete needs: a folder's ETag changes as its contents change."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200', () => pm.expect(pm.response.code).to.be.oneOf([200]));",
                  "if (pm.response.code === 200) pm.environment.set('scratch_etag', pm.response.headers.get('ETag') || pm.response.json().etag);"
                ]
              }
            }
          ],
          "response": []
        },
        {
          "name": "Delete the playground folder (deleteItem)",
          "request": {
            "method": "DELETE",
            "header": [
              {
                "key": "If-Match",
                "value": "{{scratch_etag}}"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "A new key for every request. To retry one whose answer you never saw, send the same key again."
              }
            ],
            "url": {
              "raw": "{{base_url}}/shares/{{share_id}}/items?path={{scratch_path}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "shares",
                "{{share_id}}",
                "items"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "{{scratch_path}}"
                }
              ]
            },
            "description": "`deleteItem`: deletes the playground folder and everything in it. The answer says where the storage keeps it now (a recycle bin, earlier versions) or that it is gone."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('answers 200 or 202', () => pm.expect(pm.response.code).to.be.oneOf([200, 202]));",
                  "if (pm.response.code === 200) {",
                  "    ['scratch_path', 'scratch_etag', 'file_path', 'file_etag', 'copy_path', 'copy_etag', 'version_id', 'field_values_etag'].forEach(k => pm.environment.unset(k));",
                  "}"
                ]
              }
            }
          ],
          "response": []
        }
      ]
    }
  ],
  "event": [
    {
      "listen": "test",
      "script": {
        "type": "text/javascript",
        "exec": [
          "// Every refusal is a problem document: show its type in the test results.",
          "const type = pm.response.headers.get('Content-Type') || '';",
          "if (type.indexOf('application/problem+json') === 0) {",
          "    const problem = pm.response.json();",
          "    const slug = String(problem.type || '').split('/').pop();",
          "    pm.test('problem: ' + slug + (problem.detail ? ' (' + problem.detail + ')' : ''), () => pm.expect.fail(slug));",
          "}"
        ]
      }
    }
  ],
  "variable": [
    {
      "key": "base_url",
      "value": "{{site}}/api/v1"
    }
  ]
}
