Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,25 @@ jobs:
java-version: ${{ matrix.java }}
cache: maven

- name: Check supported-client wire generation
run: |
python3 -m pip install -r scripts/requirements.txt
python3 scripts/generate-wire.py --check
python3 scripts/test-wire-generation.py

- name: Reject breaking contract mutations
if: matrix.java == '17'
run: python3 scripts/check-wire-drift.py

- name: Build, test, coverage and javadoc
run: mvn -B -ntp install

- name: Compile the example against the installed SDK
run: mvn -B -ntp -f examples/httpserver/pom.xml verify

- name: Check a consumer of the packaged jar
run: bash scripts/verify-package.sh

generated:
name: Generated API types
runs-on: ubuntu-latest
Expand All @@ -46,6 +59,8 @@ jobs:
with:
persist-credentials: false
- name: Rebuild generated files
run: ./generate.sh
run: |
python3 -m pip install -r scripts/requirements.txt
./generate.sh
- name: Fail if generated files drifted
run: git diff --exit-code
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,19 @@ jobs:
exit 1
fi

- name: Check supported-client contract
run: |
python3 -m pip install -r scripts/requirements.txt
python3 scripts/generate-wire.py --check
python3 scripts/test-wire-generation.py
python3 scripts/check-wire-drift.py

- name: Build, test, coverage and javadoc
run: mvn -B -ntp verify

- name: Check a consumer of the packaged jar
run: bash scripts/verify-package.sh

- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: jars
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Build output
target/
*.class
__pycache__/

# IDE and OS files
.idea/
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ All notable changes to this project are documented in this file. The format foll

### Added

- `sync.sh` downloads the OpenAPI description and `generate.sh` rebuilds `generated/` from it. The supported client is unchanged.
- `sync.sh` downloads the OpenAPI description and `generate.sh` rebuilds the generated clients.
- Connect the supported client to OpenAPI-generated wire views, preserving tolerant normalization.
- Check generated freshness, breaking schema mutations and installed-jar consumption in CI.

## [1.0.0] - 2026-09-30

Expand Down
23 changes: 22 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,28 @@ mvn install -DskipTests
mvn -f examples/httpserver/pom.xml verify
```

## Guidelines
## OpenAPI contract updates

Install the pinned generator dependency with `python3 -m pip install -r scripts/requirements.txt`.
After updating `resources/shieldlabs-api.yaml`, run `python3 scripts/generate-wire.py`, then
`python3 scripts/generate-wire.py --check` and `python3 scripts/check-wire-drift.py`.
The latter compiles the supported client against renamed and retyped fields and an optional additive
field. Use `--docker` if Maven is available only in the documented container. Run `mvn verify` and
`bash scripts/verify-package.sh` afterward.

Run `python3 scripts/test-wire-generation.py` for operation-boundary regression checks. New required
parameters on either consumed operation and a changed Management profile route require an adapter
update. Optional new query/header parameters remain compatible. Ping and scored webhook envelope
fields must retain compatible names and declared kinds because the runtime shares envelope parsing.

`WireModels.java` contains generated, package-private schema views. `WireValue` keeps the original
JSON value behind a declared-kind wrapper. Normalization requires the matching kind, so type drift
fails compilation without introducing strict deserialization of old or unexpected server values.
Enums remain strings on responses; unknown fields remain in `raw()`. New wire shapes or schema
constructs not supported by the narrow generator fail explicitly and need an adapter change.
The `generated/` directory is a separate reference client, not the supported runtime.

## Code guidelines

- Keep the public API small and the runtime dependencies to Jackson Databind only. Public methods
need javadoc.
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,11 +335,21 @@ Retries apply to GET requests only (every SDK call is a GET): exponential backof

## Development

Refresh the generated client when the API description changes. This does not replace the supported library in this repository.
The supported client consumes schema-generated wire views for History, profiles, webhooks and
lookup types. The generator retains raw values so existing tolerant normalization and unknown-field
handling remain unchanged. Renaming a consumed field or changing its declared type requires the
client adapter to be updated: contract mutation checks exercise this in CI.

The standalone client in `generated/` remains a reference implementation; its transport is not
used by the supported library. Retries, polling and webhook verification remain in this library.

```bash
./sync.sh # download the current OpenAPI description into resources/
./generate.sh # rebuild generated/ from that file
python3 -m pip install -r scripts/requirements.txt
python3 scripts/generate-wire.py # supported client's wire views
python3 scripts/generate-wire.py --check # fail on stale views
python3 scripts/check-wire-drift.py # real compiler checks (requires Maven)
./generate.sh # also rebuild the standalone reference client (requires Docker)
```


Expand Down
2 changes: 2 additions & 0 deletions generate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ set -euo pipefail

cd "$(dirname "${BASH_SOURCE[0]}")"

python3 scripts/generate-wire.py

if ! docker info >/dev/null 2>&1; then
echo "Docker is not running. Start Docker and run this script again." >&2
exit 1
Expand Down
84 changes: 84 additions & 0 deletions scripts/check-wire-drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Compile schema mutations against the real supported client, in disposable directories."""
import argparse
import copy
import importlib.util
from pathlib import Path
import shutil
import subprocess
import tempfile

import yaml

ROOT = Path(__file__).resolve().parents[1]
module = importlib.util.spec_from_file_location('wire_generator', ROOT / 'scripts/generate-wire.py')
generator = importlib.util.module_from_spec(module)
module.loader.exec_module(generator)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--docker', action='store_true')
args = parser.parse_args()
original = yaml.safe_load((ROOT / 'resources/shieldlabs-api.yaml').read_text())
cases = [('baseline', None, None, None)]
for model, field in [('HistoryRow', 'score'), ('HistoryRow', 'request_id'),
('DomainProfile', 'Weight'), ('IdentificationScoredData', 'risk_score'),
('IdentificationScoredEvent', 'event_type'), ('DetectionFlags', 'vpn'),
('TrafficSource', 'channel'), ('HistoryPage', 'total')]:
cases.append((model + '.' + field + ' rename', model, field, 'rename'))
cases.append((model + '.' + field + ' type', model, field, 'type'))
cases.extend([('query rename', None, None, 'parameter'),
('query type', None, None, 'parameter-type'),
('search type enum removed', None, None, 'search-type'),
('profile header renamed', None, None, 'header'),
('profile header type', None, None, 'header-type'),
('optional additive', 'HistoryRow', None, 'add')])
for label, model, field, change in cases:
doc = copy.deepcopy(original)
if model:
props = doc['components']['schemas'][model]['properties']
if change == 'rename':
props[field + '_changed'] = props.pop(field)
elif change == 'type':
props[field] = {'type': 'object'}
else:
props['future_optional'] = {'type': 'string'}
elif change == 'parameter':
doc['components']['parameters']['HistoryLimit']['name'] = 'changed_limit'
elif change == 'parameter-type':
doc['components']['parameters']['HistoryLimit']['schema'] = {'type': 'string'}
elif change == 'search-type':
doc['components']['parameters']['HistorySearchType']['schema']['enum'].remove('request_id')
elif change == 'header':
doc['components']['parameters']['ShieldDomain']['name'] = 'X-Changed-Domain'
elif change == 'header-type':
doc['components']['parameters']['ShieldDomain']['schema'] = {'type': 'integer'}
expected_success = change is None or change == 'add'
try:
output = generator.generate(doc)
except (KeyError, ValueError) as exc:
if expected_success:
raise
print('PASS rejected schema: ' + label + ' (' + str(exc) + ')', flush=True)
continue
with tempfile.TemporaryDirectory(prefix='shieldlabs-java-contract-') as tmp:
work = Path(tmp)
shutil.copy2(ROOT / 'pom.xml', work / 'pom.xml')
shutil.copytree(ROOT / 'src/main', work / 'src/main')
(work / generator.OUTPUT).write_text(output)
command = ['mvn', '-B', '-ntp', 'compile', '-DskipTests']
if args.docker:
command = ['docker', 'run', '--rm', '-v', str(work) + ':/src',
'-v', 'shieldlabs-sdk-m2:/root/.m2', '-w', '/src',
'maven:3.9-eclipse-temurin-17'] + command
run = subprocess.run(command, cwd=work, capture_output=True, text=True, timeout=180)
if (run.returncode == 0) != expected_success:
raise RuntimeError(label + '\n' + run.stdout + run.stderr)
if not expected_success and '[ERROR] COMPILATION ERROR' not in run.stdout:
raise RuntimeError('Not a compiler rejection: ' + label + '\n' + run.stdout + run.stderr)
print('PASS ' + ('compiled: ' if expected_success else 'compiler rejected: ') + label, flush=True)


if __name__ == '__main__':
main()
68 changes: 68 additions & 0 deletions scripts/consumer/Consumer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import ai.shieldlabs.DomainProfile;
import ai.shieldlabs.HistoryPage;
import ai.shieldlabs.Identification;
import ai.shieldlabs.IdentificationScoredEvent;
import ai.shieldlabs.LookupType;
import ai.shieldlabs.ManagementClient;
import ai.shieldlabs.ShieldLabsClient;
import ai.shieldlabs.Webhooks;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/** Compiled and run using only the packaged jar and its published runtime dependencies. */
public final class Consumer {
private Consumer() {}

public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange -> {
String body = exchange.getRequestURI().getPath().startsWith("/v1/profile")
? "{\"Domain\":\"example.com\",\"Weight\":123,\"PublicKey\":\"****abcd\",\"extra\":42}"
: "{\"data\":[{\"request_id\":\"00000000-0000-0000-0000-000000000001\","
+ "\"score\":999,\"connection_type\":\"future\",\"future_field\":42}],\"total\":1}";
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, bytes.length);
try (java.io.OutputStream output = exchange.getResponseBody()) {
output.write(bytes);
}
});
server.start();
try {
URI origin = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
ShieldLabsClient client = ShieldLabsClient.builder().apiKey("sec_fixture_only").baseUrl(origin).build();
HistoryPage page = client.history().search(LookupType.USER_HID, "anonymous");
Identification row = page.getIdentifications().get(0);
require(row.getRiskScore() == 999 && row.getConnectionType().equals("future"), "History");
require(row.raw().containsKey("future_field"), "unknown History field");
DomainProfile profile = ManagementClient.builder().secretKey("fixture_only")
.domain("example.com").baseUrl(origin).build().getProfile();
require(profile.getRemainingIdentifications() == 123 && profile.raw().containsKey("extra"), "profile");
String body = "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\","
+ "\"data\":{\"risk_score\":30,\"connection_type\":\"future\",\"extra\":42}}";
String secret = "whsec_fixture_only";
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
StringBuilder signature = new StringBuilder("sha256=");
for (byte b : mac.doFinal(body.getBytes(StandardCharsets.UTF_8))) {
signature.append(String.format("%02x", b & 255));
}
IdentificationScoredEvent event = (IdentificationScoredEvent) Webhooks.constructEvent(body, signature.toString(), secret);
require(event.getIdentification().getRiskScore() == 30
&& event.getIdentification().raw().containsKey("extra"), "webhook");
System.out.println("Packaged consumer: History, profile, verified webhook and unknown fields passed");
} finally {
server.stop(0);
}
}

private static void require(boolean condition, String label) {
if (!condition) {
throw new AssertionError(label);
}
}
}
Loading
Loading