Call an HTTP API through a domain port
Use this chapter when an HTTP integration needs handwritten request construction or policy beyond the declarative REST model. It combines adapter behavior, generated DTOs/mappings and the HTTP runtime. A declarative REST alternative is now available. Declarative error rules are also available. This chapter shows the lower-level Java boundary for cases requiring explicit control; the port remains independent of HTTP.
Add org.vernac:vernac-runtime-http at the same version as your Vernac compiler
to the application dependencies. It brings the DTO/JSON runtime transitively.
No HTTP or Jackson dependency is added to the domain runtime.
This is an alternative Catalog implementation; use a fresh namespace/project if
you already followed the declarative Catalog exercise. Place this file under src/main/vernac/example/catalog/catalog.vernac:
namespace example.catalog;
value Title(String) validates {
require(!self.string().isBlank(), "Title must not be blank");
};
value CatalogRejection = BLOCKED;
port CatalogSource {
Title load();
Title? lookup();
void rename(Title);
}
adapter HttpCatalog implements CatalogSource {
configuration {
URI base from "catalog.base-url";
}
behavior {
java imports {
java.net.http.HttpRequest;
org.vernac.runtime.http.HttpJson;
org.vernac.runtime.http.HttpFailures;
org.vernac.runtime.ports.PortRejectedException;
example.catalog.adapter.outbound.dto.HttpCatalogDtos.Payload;
example.catalog.adapter.outbound.dto.HttpCatalogDtos.CarrierError;
example.catalog.adapter.outbound.mapping.HttpCatalogMappings;
}
java uses {
org.vernac.runtime.http.HttpAdapterClient http;
}
public Title load() {
return self.http().required(self.invocation(),
() -> HttpRequest.newBuilder(self.base().resolve("/catalog")).GET().build(),
response -> HttpCatalogMappings.toTitle(HttpJson.read(response, Payload.class)),
(invocation, response) -> {
var error = HttpJson.errorBody(response, CarrierError.class);
if (response.status() == 403
&& error.test(value -> value.code().equals("ACCOUNT_BLOCKED")).matches()) {
return new PortRejectedException(invocation, CatalogRejection.BLOCKED,
"Catalog account is blocked", response.failureDetails("X-Correlation-ID"), null);
}
var failure = HttpFailures.defaultFailure(invocation, response);
error.decodingFailure().ifPresent(failure::addSuppressed);
return failure;
});
}
public Title? lookup() {
return self.http().optionalOn404(self.invocation(),
() -> HttpRequest.newBuilder(self.base().resolve("/catalog")).GET().build(),
response -> HttpCatalogMappings.toTitle(HttpJson.read(response, Payload.class)),
HttpFailures::defaultFailure);
}
public void rename(Title title) {
self.http().execute(self.invocation(),
() -> HttpRequest.newBuilder(self.base().resolve("/catalog"))
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofByteArray(
HttpJson.write(HttpCatalogMappings.toPayload(title))))
.build(),
HttpFailures::defaultFailure);
}
}
schema Payload(String title);
schema CarrierError(String code);
mapping toTitle: Title <- Payload { string <- source.title; }
mapping toPayload: Payload <- Title { title <- source.string; }
}
Provide the transport and HTTP runtime beans in your application configuration:
@Bean(destroyMethod = "close")
HttpClient catalogTransport() {
return HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NEVER)
.build();
}
@Bean
HttpAdapterClient catalogHttp(HttpClient catalogTransport) {
return new HttpAdapterClient(catalogTransport, Duration.ofSeconds(10), 1_048_576);
}
The Java snippets use java.net.http.HttpClient, java.time.Duration,
org.springframework.context.annotation.Bean and org.vernac.runtime.http.HttpAdapterClient.
The application owns and closes the JDK client. With multiple HTTP clients, select
collaborators explicitly through Spring wiring; Vernac does not guess matching beans.
The runtime client itself is not automatically a Spring bean.
Configure the endpoint without a URI bean:
catalog:
base-url: ${CATALOG_BASE_URL:http://localhost:8088}
self.base() works in inline and external implementations. This Java-backed
adapter accepts a general URI; unlike a declarative REST base, Java code owns
protocol-specific endpoint validation. See adapter configuration.
Inject CatalogSource into your application. A response like
{"title":"Notebook","newRemoteField":42} returns Title.of("Notebook").
The generated DTO ignores the added field. Missing title, wrong JSON types,
malformed JSON and a blank title all fail as invalid responses. A domain
validation failure during the response mapping retains its cause/path.
lookup() returns Optional.empty() only for the explicitly requested 404 policy.
load() instead fails on 404. A successful empty response, JSON null, or invalid
body never becomes absence. rename() maps in the opposite direction, sends PUT
with a JSON payload, and accepts any 2xx status (including 204) as void success.
Do not use a payload decoder for a 204 response.
Recognize a business rejection
The error body uses its own generated DTO. A 403 with code ACCOUNT_BLOCKED
becomes PortRejectedException with the actual CatalogRejection.BLOCKED enum.
The external string is not the domain code. Consumers can use
failure.code(CatalogRejection.class) without parsing messages.
Malformed/absent error bodies produce unavailable data, not an empty DTO. The HTTP status remains available to status-only rules. Negating an unavailable body comparison does not make it match. The example attaches sanitized decoding diagnostics as suppressed exceptions to the original status failure.
Neither 401 nor 403 automatically proves a business rejection or a local configuration mistake. Without explicit translation these are technical failures with unassigned responsibility. See the HTTP runtime contract.
Request details and failures
self.invocation() is already isolated per call. To deliberately add a safe
identifier, pass self.invocation().withDetail("catalog", "public") instead.
Do not add credentials or whole payloads. No request URL, authorization header,
cookies or raw response is automatically attached to exceptions. The explicit
correlation-header selection in this example is for a known non-secret header.
The shared exception hierarchy also works for robot, filesystem or other adapters:
throw new LocalIntegrationException(self.invocation(),
IntegrationCategory.CONFIGURATION, "Robot calibration is unavailable");
These types are in org.vernac.runtime.ports; they have no HTTP dependencies.
Try it and verify it
HttpAdapterExecutionTest extracts the first Vernac block from this tutorial,
generates/compiles the Java classes, and calls the adapter against a local HTTP
server. It exercises round-trip DTO mapping, unknown fields, failed validation,
explicit absence and business/technical error translation. No Docker or external
service is needed. HttpAdapterClientTest additionally covers HTTP methods,
concurrent calls, bounded response bodies, timeout and interruption.
Continue with declarative REST generation to reduce the request/response boilerplate while retaining the same domain port and failure semantics.