Chapter 20Lesson 02240–330 min

REST API, Repository Provisioning, Security Automation, Pagination, and Infrastructure-as-Code Patterns: Guided Hands-On Workflow and Core Operations

Practice the complete API lifecycle on disposable resources: inspect health and repositories, create and update a Raw hosted repository, enumerate content through continuation-token pagination, create a narrow lab role/user, and convert the workflow into a rerunnable reconciler.

Hands-onRepositories APISecurity APIPaginationPython

Learning objectives

  • Perform read-only API preflight and preserve evidence before mutation.
  • Create, update, verify, and safely delete a disposable Raw hosted repository through REST.
  • Enumerate components/assets across every continuation-token page.
  • Create a narrow local role/user from current security APIs and test authorization.
  • Write and run an idempotent Python reconciler twice with a no-op second run.

Version baseline (26 August 2026). These lessons use Sonatype Nexus Repository 3.95.2-01 as the dated self-hosted reference point, verified from Sonatype's public release repository, with Java 21 as the current runtime requirement. The mandatory lab assumes a small, disposable, loopback-only self-hosted Community Edition installation using H2 and a file blob store. The local instance's own /service/rest/swagger.json is authoritative for the exact endpoint schema you are about to call; API fields, repository recipes, security endpoints, and edition entitlements can change between releases.

Automation safety boundary. Do not put real passwords, user-token passcodes, Authorization headers, production base URLs, or employer namespaces into source files or lesson evidence. The examples use environment variables, synthetic names, loopback URLs, and disposable resources. User Tokens are a Pro capability in current self-hosted Nexus Repository; Community labs therefore use a disposable local account/password only for bootstrap and immediately reduce privileges for the service identity.

Pagination documentation note. Current Sonatype pages agree on the continuationToken contract but currently disagree on the documented default item count for components/assets. The robust automation rule is independent of page size: process every returned items array, pass the returned token unchanged, and stop only when continuationToken is null.

1. Disposable lab contract

Use the same local self-hosted Community instance from earlier chapters or a fresh archive installation. This lesson requires no Docker, Kubernetes, cloud account, paid CI, Nexus Pro, or public DNS.

Item Lab value
Nexus URL http://127.0.0.1:8081
Repository learner-api-hosted (Raw hosted)
Blob store default on disposable file storage
Role learner-api-publisher
Service user svc-learner-api
Artifact namespace learner-example/ch20/

Set bootstrap credentials only in the current shell. Use the actual disposable lab password; never commit it.

export NEXUS_URL='http://127.0.0.1:8081'
export NEXUS_ADMIN_USER='admin'
read -r -s -p 'Disposable Nexus admin password: ' NEXUS_ADMIN_PASSWORD
export NEXUS_ADMIN_PASSWORD
printf '\n'
test -n "$NEXUS_ADMIN_PASSWORD" && echo 'bootstrap secret is present'

2. Preflight: prove the target before a write

set -euo pipefail
curl --fail --silent --show-error "$NEXUS_URL/service/rest/swagger.json" --output /tmp/nexus-swagger.json
curl --fail --silent --show-error -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" "$NEXUS_URL/service/rest/v1/status"
curl --fail --silent --show-error -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" "$NEXUS_URL/service/rest/v1/status/writable"
curl --fail --silent --show-error -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" "$NEXUS_URL/service/rest/v1/repositories" | python -m json.tool

If the base URL is not loopback/private, writability fails, or OpenAPI lacks /v1/repositories/raw/hosted, stop and reconcile the lab rather than modifying commands until they “work.”

3. Read the repository schema before creating it

Use Swagger to confirm the request model for POST /v1/repositories/raw/hosted. In the 3.95.x family, a representative hosted payload is:

{
  "name": "learner-api-hosted",
  "online": true,
  "storage": {
    "blobStoreName": "default",
    "strictContentTypeValidation": true,
    "writePolicy": "ALLOW_ONCE"
  }
}

If your local schema differs, follow the local schema and document the difference. ALLOW_ONCE reinforces the chapter's immutable-release mindset for this lab; it is not a universal policy.

4. Create only when missing

repo_url="$NEXUS_URL/service/rest/v1/repositories/raw/hosted/learner-api-hosted"
if curl --silent --fail -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" "$repo_url" >/tmp/repo-current.json; then
  echo 'Repository already exists; no POST performed.'
else
  status=$(curl --silent --show-error -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" \
    -H 'Content-Type: application/json' -o /tmp/repo-create.out -w '%{http_code}' \
    -X POST "$NEXUS_URL/service/rest/v1/repositories/raw/hosted" --data-binary @repo.json)
  case "$status" in 2*) echo "created ($status)" ;; *) cat /tmp/repo-create.out; exit 1 ;; esac
fi

The GET before POST is the key operation. A blind POST rerun models an action, not desired state.

5. Verify configuration and functional behavior

curl --fail --silent --show-error -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" "$repo_url" | python -m json.tool
printf 'chapter20 artifact\n' > /tmp/ch20-demo.txt
sha256sum /tmp/ch20-demo.txt
curl --fail --silent --show-error -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" --upload-file /tmp/ch20-demo.txt \
  "$NEXUS_URL/repository/learner-api-hosted/learner-example/ch20/demo-1.0.0.txt"
curl --fail --silent --show-error -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" \
  "$NEXUS_URL/repository/learner-api-hosted/learner-example/ch20/demo-1.0.0.txt" --output /tmp/ch20-downloaded.txt
sha256sum /tmp/ch20-downloaded.txt

The REST create changes repository configuration. Upload creates repository metadata plus blob content. Download verifies the data plane independently.

6. Update in place rather than delete-and-recreate

Read existing JSON, compare owned fields, and use the format/type-specific PUT only if desired values differ. Toggling online is a configuration change; deleting the repository merely to change one field expands blast radius.

python - <<'PY'
import json
p=json.load(open('repo.json'))
p['online']=False
json.dump(p, open('/tmp/repo-offline.json','w'), indent=2)
PY
curl --fail --silent --show-error -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" \
  -H 'Content-Type: application/json' -X PUT "$repo_url" --data-binary @/tmp/repo-offline.json
curl --fail --silent --show-error -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" "$repo_url" | python -m json.tool
curl --fail --silent --show-error -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" \
  -H 'Content-Type: application/json' -X PUT "$repo_url" --data-binary @repo.json

7. Enumerate every asset page

Use the endpoint your local Swagger documents. The following pattern handles any items/continuationToken response correctly:

import os, requests
base = os.environ["NEXUS_URL"]
auth = (os.environ["NEXUS_ADMIN_USER"], os.environ["NEXUS_ADMIN_PASSWORD"])
url = f"{base}/service/rest/v1/assets"
base_params = {"repository": "learner-api-hosted"}
items, token = [], None
while True:
    params = dict(base_params)
    if token is not None:
        params["continuationToken"] = token
    r = requests.get(url, params=params, auth=auth, timeout=15)
    r.raise_for_status()
    body = r.json()
    items.extend(body.get("items", []))
    token = body.get("continuationToken")
    if token is None:
        break
print("complete_count=", len(items))
for item in items:
    print(item.get("path"), item.get("downloadUrl"))

Do not log the auth tuple, Authorization headers, or full debug traces containing credentials.

8. Create a narrow role and local service user

Inventory current privilege IDs instead of inventing them:

curl --fail --silent --show-error -u "$NEXUS_ADMIN_USER:$NEXUS_ADMIN_PASSWORD" \
  "$NEXUS_URL/service/rest/v1/security/privileges" > /tmp/privileges.json
python - <<'PY'
import json
for p in json.load(open('/tmp/privileges.json')):
    name=p.get('name','')
    if 'learner-api-hosted' in name:
        print(name)
PY

Select only the exact browse/read/add privileges needed. A representative role payload is:

{
  "id": "learner-api-publisher",
  "name": "Chapter 20 disposable publisher",
  "description": "Scope: learner-api-hosted only",
  "privileges": [
    "nx-repository-view-raw-learner-api-hosted-browse",
    "nx-repository-view-raw-learner-api-hosted-read",
    "nx-repository-view-raw-learner-api-hosted-add"
  ],
  "roles": []
}

POST the role and local user using the request models in your current Swagger. Inject the user password at runtime. Verify the service user can upload/read the intended repository but receives 403 for administrative APIs.

9. A small idempotent reconciler

import os, requests
BASE = os.environ["NEXUS_URL"]
AUTH = (os.environ["NEXUS_ADMIN_USER"], os.environ["NEXUS_ADMIN_PASSWORD"])
NAME = "learner-api-hosted"
URL = f"{BASE}/service/rest/v1/repositories/raw/hosted/{NAME}"
CREATE_URL = f"{BASE}/service/rest/v1/repositories/raw/hosted"
DESIRED = {"name": NAME, "online": True, "storage": {
    "blobStoreName": "default", "strictContentTypeValidation": True, "writePolicy": "ALLOW_ONCE"}}
def owned_view(obj):
    s = obj.get("storage", {})
    return {"name": obj.get("name"), "online": obj.get("online"),
            "storage": {k: s.get(k) for k in DESIRED["storage"]}}
r = requests.get(URL, auth=AUTH, timeout=15)
if r.status_code == 404:
    c = requests.post(CREATE_URL, json=DESIRED, auth=AUTH, timeout=15); c.raise_for_status(); print("changed=create")
else:
    r.raise_for_status()
    if owned_view(r.json()) == DESIRED:
        print("changed=no")
    else:
        u = requests.put(URL, json=DESIRED, auth=AUTH, timeout=15); u.raise_for_status(); print("changed=update")
v = requests.get(URL, auth=AUTH, timeout=15); v.raise_for_status()
assert owned_view(v.json()) == DESIRED
print("verified=true")

Run twice. The first run may create/update; the second must report changed=no unless drift occurred.

10. Challenge: choose the correct control

Requirement: developers may read and upload only to the disposable hosted repository, while provisioning must be rerunnable. Decide which state belongs in repository configuration, a role, the user, and the external secret store. Explain why delete/recreate is the wrong update strategy.

11. Cleanup and rollback

  1. Download and checksum the lab artifact one final time.
  2. Delete only svc-learner-api from the local lab realm.
  3. Delete only learner-api-publisher.
  4. Delete learner-api-hosted only after confirming its exact name and synthetic contents.
  5. Verify all managed objects are absent.
  6. Unset secret environment variables and remove temporary API output.

Repository deletion is destructive. Do not generalize this sequence to a shared or production instance.

12. Knowledge check

Why query privileges after creating the repository?

The second reconciler run sends another POST. What is missing?

Why should the service user receive 403 on repository creation?

A list response contains many items and a token. Is it complete?

13. Summary and next step

You performed the full lifecycle: inspect, create, verify, update, paginate, build a narrow identity, reconcile twice, and clean up only managed resources. Lesson 3 turns these mechanics into design policy.

Official references and version notes

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.