First working prototype

This commit is contained in:
2026-05-11 21:30:32 +02:00
parent aa21432060
commit f9d92bc327
24 changed files with 1401 additions and 1 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# ---> Java # ---> Java
# Compiled class file # Compiled class file
bin
*.class *.class
# Log file # Log file
@@ -25,6 +26,7 @@ hs_err_pid*
replay_pid* replay_pid*
# ---> VisualStudioCode # ---> VisualStudioCode
.vscode
.vscode/* .vscode/*
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
@@ -38,3 +40,4 @@ replay_pid*
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix
.env

View File

@@ -1,2 +1,18 @@
# emission-api-lib-java ## Getting Started
Welcome to the VS Code Java world. Here is a guideline to help you get started to write Java code in Visual Studio Code.
## Folder Structure
The workspace contains two folders by default, where:
- `src`: the folder to maintain sources
- `lib`: the folder to maintain dependencies
Meanwhile, the compiled output files will be generated in the `bin` folder by default.
> If you want to customize the folder structure, open `.vscode/settings.json` and update the related settings there.
## Dependency Management
The `JAVA PROJECTS` view allows you to manage your dependencies. More details can be found [here](https://github.com/microsoft/vscode-java-dependency#manage-dependencies).

97
src/App.java Normal file
View File

@@ -0,0 +1,97 @@
import java.io.InputStream;
import java.time.ZonedDateTime;
import java.util.Map;
import de.addideas.api.emission.EmissionEngine;
import de.addideas.api.emission.EnvLoader;
import de.addideas.api.emission.ProvidersConfigLoader;
import de.addideas.api.emission.descriptors.ProviderDescriptor;
import de.addideas.api.emission.descriptors.ResponseValue;
import de.addideas.api.emission.model.AirportInfo;
import de.addideas.api.emission.model.AirportSearch;
import de.addideas.api.emission.model.EmissionEstimate;
import de.addideas.api.emission.model.FlightLeg;
import de.addideas.api.emission.model.FlightRequest;
public class App {
public static void main(String[] args) throws Exception {
// 1) Load XML
InputStream xml = App.class.getClassLoader().getResourceAsStream("providers.xml");
if (xml == null) {
throw new IllegalStateException("providers.xml not found on classpath");
}
ProvidersConfigLoader loader = new ProvidersConfigLoader(xml);
Map<String, ProviderDescriptor> providers = loader.getProviders();
// 2) Construct engine
EmissionEngine engine = new EmissionEngine(providers);
// 3) Check if calco2la.to supports flight estimation
String providerId = "calco2lato";
boolean supported = engine.isOperationSupported(
providerId, EmissionEngine.OP_FLIGHT_ESTIMATE);
System.out.println("Provider " + providerId + " supports flight estimate: " + supported);
if (!supported) {
return;
}
// 4) Build a sample FlightRequest
FlightLeg leg = new FlightLeg();
leg.setOriginIata("FRA");
leg.setDestinationIata("JFK");
leg.setDepartureTime(ZonedDateTime.now()); // optional
FlightRequest request = new FlightRequest();
request.getLegs().add(leg);
request.setCabinClass("economy");
request.setPassengers(1);
request.setIncludeNonCo2(true);
// 5) API key inject from env or config
Map<String, String> env = EnvLoader.loadEnv(".env");
String apiKeyName = engine.getProvider(providerId).getAuth().getEnvKey();
String apiKey = env.get(apiKeyName);
if (apiKey == null) {
System.err.println(apiKeyName + " missing in .env");
return;
}
// 6) Call engine
ResponseValue<EmissionEstimate> estimate = engine.estimateFlight(providerId, request, apiKey);
if (!estimate.isList()) {
EmissionEstimate est = estimate.asSingle();
System.out.println("Vendor: " + est.getVendor());
System.out.println("Raw response:");
System.out.println(est.getVendorRaw());
System.out.println("CO2: " + est.getCo2eKg());
} else {
// if some provider decides to return multiple estimates for some reason
for(EmissionEstimate est : estimate.asList()) {
System.out.println("Vendor: " + est.getVendor());
System.out.println("Raw response:");
System.out.println(est.getVendorRaw());
System.out.println("CO2: " + est.getCo2eKg());
}
}
// Airport search demo
AirportSearch asr = new AirportSearch();
asr.setQuery("FRA");
asr.setLimit(5);
ResponseValue<AirportInfo> airports = engine.airportSearch(providerId, asr, apiKey);
if(airports.isList()) {
for (AirportInfo ai : airports.asList()) {
System.out.printf("%s (%s) - %s; %s,%s%n",
ai.getName(), ai.getIataCode(), ai.getCity(), ai.getLatitude(), ai.getLongitude());
}
} else {
AirportInfo single = airports.asSingle();
System.out.printf("%s (%s) - %s; %s,%s%n",
single.getName(), single.getIataCode(), single.getCity(), single.getLatitude(), single.getLongitude());
}
}
}

View File

@@ -0,0 +1,368 @@
package de.addideas.api.emission;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.List;
import java.util.ArrayList;
import de.addideas.api.emission.descriptors.AuthConfig;
import de.addideas.api.emission.descriptors.FieldMapping;
import de.addideas.api.emission.descriptors.ListMapping;
import de.addideas.api.emission.descriptors.ModelRegistry;
import de.addideas.api.emission.descriptors.OperationDescriptor;
import de.addideas.api.emission.descriptors.ProviderDescriptor;
import de.addideas.api.emission.descriptors.ResponseMapping;
import de.addideas.api.emission.descriptors.ResponseValue;
import de.addideas.api.emission.model.AirportInfo;
import de.addideas.api.emission.model.AirportSearch;
import de.addideas.api.emission.model.EmissionEstimate;
import de.addideas.api.emission.model.FlightRequest;
import de.addideas.api.emission.model.LegEstimate;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class EmissionEngine {
private final Map<String, ProviderDescriptor> providers;
private final ModelRegistry modelRegistry = new ModelRegistry();
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
public static final String OP_FLIGHT_ESTIMATE = "travel.flight.estimate_emissions";
public static final String OP_AIRPORT_SEARCH = "travel.airport.search";
public EmissionEngine(Map<String, ProviderDescriptor> providers) {
modelRegistry.register("EmissionEstimate", EmissionEstimate.class);
modelRegistry.register("LegEstimate", LegEstimate.class);
modelRegistry.register("AirportInfo", AirportInfo.class);
this.providers = providers;
}
public boolean isOperationSupported(String providerId, String operationId) {
ProviderDescriptor p = providers.get(providerId);
if (p == null) return false;
return p.getOperation(operationId) != null;
}
public ProviderDescriptor getProvider(String providerId) {
return providers.get(providerId);
}
public <T> ResponseValue<T> callOperation(String providerId,
String operationId,
Object request,
String apiKey,
Class<T> resultClass) throws Exception {
ProviderDescriptor provider = providers.get(providerId);
if (provider == null) {
throw new IllegalArgumentException("Unknown provider: " + providerId);
}
OperationDescriptor op = provider.getOperation(operationId);
if (op == null) {
throw new IllegalArgumentException("Provider " + providerId
+ " does not support " + operationId);
}
HttpResponse<String> response = performHttpCall(provider, op, request, apiKey);
String json = response.body();
ResponseMapping rm = op.getResponseMapping();
if (rm == null) {
throw new IllegalStateException("No responseMapping defined for " + operationId);
}
switch (rm.getMode()) {
case SINGLE -> {
T obj = mapSingle(rm, json, resultClass);
return ResponseValue.single(obj);
}
case LIST -> {
List<T> list = mapList(rm, json, resultClass);
return ResponseValue.list(list);
}
default -> throw new IllegalStateException("Unsupported response mode: " + rm.getMode());
}
}
private HttpResponse<String> performHttpCall(ProviderDescriptor provider, OperationDescriptor op, Object request,
String apiKey) throws Exception {
String url = provider.getBaseUrl() + op.getPath();
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url));
applyAuth(builder, provider.getAuth(), apiKey);
// body from template (if defined)
String body = "";
if (op.getRequestBody() != null) {
body = SimpleTemplateEngine.render(op.getRequestBody().getTemplate(), request);
builder.header("Content-Type", "application/json");
}
String method = op.getMethod() != null ? op.getMethod().toUpperCase() : "POST";
if ("POST".equals(method)) {
builder.POST(HttpRequest.BodyPublishers.ofString(body));
} else if ("GET".equals(method)) {
builder.GET();
} else {
builder.method(method, HttpRequest.BodyPublishers.ofString(body));
}
HttpRequest httpRequest = builder.build();
return httpClient.send(
httpRequest, HttpResponse.BodyHandlers.ofString()
);
}
public ResponseValue<EmissionEstimate> estimateFlight(String providerId, FlightRequest request, String apiKey) throws Exception {
return callOperation(providerId, OP_FLIGHT_ESTIMATE, request, apiKey, EmissionEstimate.class);
}
public ResponseValue<AirportInfo> airportSearch(String providerId, AirportSearch request, String apiKey) throws Exception {
return callOperation(providerId, OP_AIRPORT_SEARCH, request, apiKey, AirportInfo.class);
}
private void applyAuth(HttpRequest.Builder builder, AuthConfig auth, String apiKey) {
if (auth == null || auth.getType() == AuthConfig.Type.NONE) return;
switch (auth.getType()) {
case APIKEY -> {
String header = auth.getHeader() != null ? auth.getHeader() : "Authorization";
String format = auth.getFormat() != null ? auth.getFormat() : "${API_KEY}";
String value = format.replace("${API_KEY}", apiKey);
builder.header(header, value);
}
case BASIC -> {
if (auth.isUsernameIsApiKey()) {
String token = Base64.getEncoder().encodeToString((apiKey + ":")
.getBytes(StandardCharsets.UTF_8));
builder.header("Authorization", "Basic " + token);
}
}
case OAUTH, NONE -> {
// not implemented yet
}
}
}
private Object applyTransform(String transform, Object value) {
if (value == null) return null;
switch (transform) {
case "divideBy1000" -> {
double d = Double.parseDouble(value.toString());
return d / 1000.0;
}
// you can add more transforms here
default -> {
return value;
}
}
}
private <T> T mapSingle(ResponseMapping rm, String json, Class<T> expectedClass) throws Exception {
JsonNode root = objectMapper.readTree(json);
// Choose the node for the root object
JsonNode rootNode = evaluateJsonNode(root, rm.getRootSource());
if (rootNode == null) rootNode = root;
// Create root object
String typeName = rm.getRootType();
if (typeName == null || typeName.isEmpty()) {
typeName = modelRegistry.getNameForClass(expectedClass);
}
T target = modelRegistry.newInstance(typeName);
// Apply simple field mappings
for (FieldMapping fm : rm.getFieldMappings()) {
Object value = resolveFieldValue(rootNode, fm);
setTargetFieldOnObject(target, fm.getTargetFieldPath(), value);
}
// Apply nested list mappings
for (ListMapping lm : rm.getListMappings()) {
List<Object> list = new ArrayList<>();
List<JsonNode> items = evaluateJsonPathList(rootNode, lm.getSourceJsonPath());
for (JsonNode itemNode : items) {
Object item = modelRegistry.newInstance(lm.getItemType());
for (FieldMapping fm : lm.getItemFieldMappings()) {
Object value = resolveFieldValue(itemNode, fm);
setTargetFieldOnObject(item, fm.getTargetFieldPath(), value);
}
list.add(item);
}
setTargetFieldOnObject(target, lm.getTargetFieldPath(), list);
}
return target;
}
private <T> List<T> mapList(ResponseMapping rm, String json, Class<T> expectedClass) throws Exception {
JsonNode root = objectMapper.readTree(json);
String typeName = rm.getRootType();
if (typeName == null || typeName.isEmpty()) {
typeName = modelRegistry.getNameForClass(expectedClass);
}
List<T> result = new ArrayList<>();
List<JsonNode> nodes = evaluateJsonPathList(root, rm.getRootSource());
for (JsonNode node : nodes) {
T item = modelRegistry.newInstance(typeName);
for (FieldMapping fm : rm.getFieldMappings()) {
Object value = resolveFieldValue(node, fm);
setTargetFieldOnObject(item, fm.getTargetFieldPath(), value);
}
// nested lists *inside* each element, if any
for (ListMapping lm : rm.getListMappings()) {
List<Object> list = new ArrayList<>();
List<JsonNode> subItems = evaluateJsonPathList(node, lm.getSourceJsonPath());
for (JsonNode subNode : subItems) {
Object sub = modelRegistry.newInstance(lm.getItemType());
for (FieldMapping fm : lm.getItemFieldMappings()) {
Object value = resolveFieldValue(subNode, fm);
setTargetFieldOnObject(sub, fm.getTargetFieldPath(), value);
}
list.add(sub);
}
setTargetFieldOnObject(item, lm.getTargetFieldPath(), list);
}
result.add(item);
}
return result;
}
private Object resolveFieldValue(JsonNode context, FieldMapping fm) {
Object value;
if (fm.getConstantValue() != null && !fm.getConstantValue().isEmpty()) {
value = fm.getConstantValue();
} else {
value = evaluateJsonPath(context, fm.getSourceJsonPath());
}
if (fm.getTransform() != null && value != null) {
value = applyTransform(fm.getTransform(), value);
}
return value;
}
// Returns JsonNode for JSONPath, for both scalar and array
private JsonNode evaluateJsonNode(JsonNode root, String path) {
if (path == null || path.isEmpty()) return null;
String p = path.trim();
if (p.equals("$")) return root;
if (!p.startsWith("$.")) return null;
String remainder = p.substring(2); // skip "$."
String[] tokens = remainder.split("\\.");
JsonNode current = root;
for (String token : tokens) {
if (current == null) return null;
int idxStart = token.indexOf('[');
if (idxStart != -1 && token.endsWith("]")) {
String fieldName = token.substring(0, idxStart);
String idxPart = token.substring(idxStart + 1, token.length() - 1); // inside []
if (idxPart.equals("*")) {
// wildcard array handled by caller
current = current.get(fieldName);
break; // caller will process array
} else {
int index = Integer.parseInt(idxPart);
current = current.get(fieldName);
if (current == null || !current.isArray()) return null;
if (index < 0 || index >= current.size()) return null;
current = current.get(index);
}
} else {
current = current.get(token);
}
}
return current;
}
private Object evaluateJsonPath(JsonNode root, String path) {
JsonNode node = evaluateJsonNode(root, path);
if (node == null) return null;
if (node.isNumber()) return node.numberValue();
if (node.isBoolean()) return node.booleanValue();
if (node.isTextual()) return node.textValue();
return node.toString();
}
private List<JsonNode> evaluateJsonPathList(JsonNode root, String path) {
if (path == null || !path.endsWith("[*]")) return java.util.List.of();
String basePath = path.substring(0, path.length() - 3); // remove [*]
JsonNode arrNode = evaluateJsonNode(root, basePath);
if (arrNode == null || !arrNode.isArray()) return java.util.List.of();
List<JsonNode> items = new ArrayList<>();
arrNode.forEach(items::add);
return items;
}
private void setTargetFieldOnObject(Object target,
String targetFieldPath,
Object value) {
if (targetFieldPath == null || targetFieldPath.isEmpty()) return;
String fieldName = targetFieldPath;
int dot = targetFieldPath.indexOf('.');
if (dot != -1) {
fieldName = targetFieldPath.substring(dot + 1); // strip "AirportInfo."
}
try {
Class<?> cls = target.getClass();
// try setter
String setterName = "set" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1);
for (var m : cls.getMethods()) {
if (m.getName().equals(setterName) && m.getParameterCount() == 1) {
Class<?> paramType = m.getParameterTypes()[0];
Object converted = convertValue(value, paramType);
m.invoke(target, converted);
return;
}
}
// try field
var f = cls.getDeclaredField(fieldName);
f.setAccessible(true);
Object converted = convertValue(value, f.getType());
f.set(target, converted);
} catch (Exception e) {
e.printStackTrace();
}
}
private Object convertValue(Object value, Class<?> targetType) {
if (value == null) return null;
if (targetType.isAssignableFrom(value.getClass())) return value;
String s = value.toString();
if (targetType == Double.class || targetType == double.class) {
return Double.parseDouble(s);
}
if (targetType == Integer.class || targetType == int.class) {
return Integer.parseInt(s);
}
if (targetType == Boolean.class || targetType == boolean.class) {
return Boolean.parseBoolean(s);
}
if (targetType == String.class) {
return s;
}
return value;
}
}

View File

@@ -0,0 +1,27 @@
package de.addideas.api.emission;
import java.io.IOException;
import java.nio.file.*;
import java.util.HashMap;
import java.util.Map;
public class EnvLoader {
public static Map<String, String> loadEnv(String filename) {
Map<String, String> map = new HashMap<>();
try {
for (String line : Files.readAllLines(Paths.get(filename))) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) continue;
int eq = line.indexOf('=');
if (eq == -1) continue;
String key = line.substring(0, eq).trim();
String value = line.substring(eq + 1).trim();
map.put(key, value);
}
} catch (IOException e) {
throw new RuntimeException("Failed to load .env file: " + filename, e);
}
return map;
}
}

View File

@@ -0,0 +1,184 @@
package de.addideas.api.emission;
import de.addideas.api.emission.descriptors.AuthConfig;
import de.addideas.api.emission.descriptors.FieldMapping;
import de.addideas.api.emission.descriptors.ListMapping;
import de.addideas.api.emission.descriptors.OperationDescriptor;
import de.addideas.api.emission.descriptors.ProviderDescriptor;
import de.addideas.api.emission.descriptors.RequestBodyTemplate;
import de.addideas.api.emission.descriptors.ResponseMapping;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
public class ProvidersConfigLoader {
private final Map<String, ProviderDescriptor> providers = new HashMap<>();
public ProvidersConfigLoader(InputStream xmlInput) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(xmlInput);
Element root = doc.getDocumentElement();
NodeList providerNodes = root.getElementsByTagNameNS(root.getNamespaceURI(), "provider");
for (int i = 0; i < providerNodes.getLength(); i++) {
Element p = (Element) providerNodes.item(i);
ProviderDescriptor desc = parseProvider(p, root.getNamespaceURI());
providers.put(desc.getId(), desc);
}
}
private ProviderDescriptor parseProvider(Element p, String ns) {
ProviderDescriptor desc = new ProviderDescriptor();
desc.setId(p.getAttribute("id"));
desc.setName(getChildText(p, ns, "name"));
desc.setBaseUrl(getChildText(p, ns, "baseUrl"));
Element authElem = getChildElement(p, ns, "auth");
if (authElem != null) {
AuthConfig auth = new AuthConfig();
String typeStr = authElem.getAttribute("type");
AuthConfig.Type type = AuthConfig.Type.NONE;
if (typeStr != null && !typeStr.isEmpty()) {
type = AuthConfig.Type.valueOf(typeStr.toUpperCase());
}
auth.setType(type);
auth.setHeader(getChildText(authElem, ns, "header"));
auth.setFormat(getChildText(authElem, ns, "format"));
auth.setUsernameIsApiKey("true".equalsIgnoreCase(getChildText(authElem, ns, "usernameIsApiKey")));
// NEW: read <envKey>
auth.setEnvKey(getChildText(authElem, ns, "envKey"));
desc.setAuth(auth);
}
Element opsElem = getChildElement(p, ns, "operations");
if (opsElem != null) {
NodeList opNodes = opsElem.getElementsByTagNameNS(ns, "operation");
for (int i = 0; i < opNodes.getLength(); i++) {
Element opElem = (Element) opNodes.item(i);
OperationDescriptor op = parseOperation(opElem, ns);
desc.getOperations().put(op.getId(), op);
}
}
return desc;
}
private OperationDescriptor parseOperation(Element opElem, String ns) {
OperationDescriptor op = new OperationDescriptor();
op.setId(opElem.getAttribute("id"));
Element httpElem = getChildElement(opElem, ns, "http");
if (httpElem != null) {
op.setMethod(httpElem.getAttribute("method"));
op.setPath(httpElem.getAttribute("path"));
}
Element bodyElem = getChildElement(opElem, ns, "requestBody");
if (bodyElem != null) {
RequestBodyTemplate tmpl = new RequestBodyTemplate();
tmpl.setFormat(bodyElem.getAttribute("format"));
tmpl.setTemplate(bodyElem.getTextContent().trim());
op.setRequestBody(tmpl);
}
Element respElem = getChildElement(opElem, ns, "responseMapping");
if (respElem != null) {
ResponseMapping rm = new ResponseMapping();
String modeStr = respElem.getAttribute("mode");
if (modeStr != null && !modeStr.isEmpty()) {
rm.setMode(ResponseMapping.Mode.valueOf(modeStr.toUpperCase()));
}
rm.setRootType(respElem.getAttribute("rootType"));
String rootSource = respElem.getAttribute("rootSource");
if (rootSource == null || rootSource.isEmpty()) {
rm.setRootSource("$");
} else {
rm.setRootSource(rootSource);
}
// direct fields
NodeList fieldNodes = respElem.getElementsByTagNameNS(ns, "field");
for (int i = 0; i < fieldNodes.getLength(); i++) {
Element f = (Element) fieldNodes.item(i);
// skip fields that are inside <list> handle those separately
if (!f.getParentNode().equals(respElem)) continue;
FieldMapping fm = new FieldMapping();
fm.setSourceJsonPath(f.getAttribute("source"));
fm.setTargetFieldPath(f.getAttribute("target"));
String tr = f.getAttribute("transform");
if (!tr.isEmpty()) fm.setTransform(tr);
String constant = f.getAttribute("value");
if (!constant.isEmpty()) fm.setConstantValue(constant);
rm.addFieldMapping(fm);
}
// nested lists
NodeList listNodes = respElem.getElementsByTagNameNS(ns, "list");
for (int i = 0; i < listNodes.getLength(); i++) {
Element le = (Element) listNodes.item(i);
ListMapping lm = new ListMapping();
lm.setSourceJsonPath(le.getAttribute("source"));
lm.setTargetFieldPath(le.getAttribute("target"));
lm.setItemType(le.getAttribute("itemType"));
NodeList itemFields = le.getElementsByTagNameNS(ns, "field");
for (int j = 0; j < itemFields.getLength(); j++) {
Element f = (Element) itemFields.item(j);
FieldMapping fm = new FieldMapping();
fm.setSourceJsonPath(f.getAttribute("source"));
fm.setTargetFieldPath(f.getAttribute("target"));
String tr = f.getAttribute("transform");
if (!tr.isEmpty()) fm.setTransform(tr);
String constant = f.getAttribute("value");
if (!constant.isEmpty()) fm.setConstantValue(constant);
lm.addItemFieldMapping(fm);
}
rm.addListMapping(lm);
}
op.setResponseMapping(rm);
}
return op;
}
private static Element getChildElement(Element parent, String ns, String localName) {
NodeList children = parent.getElementsByTagNameNS(ns, localName);
if (children.getLength() == 0) return null;
return (Element) children.item(0);
}
private static String getChildText(Element parent, String ns, String localName) {
Element child = getChildElement(parent, ns, localName);
return (child != null) ? child.getTextContent().trim() : null;
}
public Map<String, ProviderDescriptor> getProviders() {
return providers;
}
public ProviderDescriptor getProvider(String id) {
return providers.get(id);
}
}

View File

@@ -0,0 +1,191 @@
package de.addideas.api.emission;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class SimpleTemplateEngine {
private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([^}]+)}");
public static String render(String template, Object request) {
// First handle loops, then scalar placeholders
String withLoops = renderLoops(template, request);
return renderScalars(withLoops, request);
}
// ----- Loops -----
private static String renderLoops(String template, Object request) {
StringBuilder result = new StringBuilder();
int pos = 0;
while (true) {
int forIndex = template.indexOf("#for", pos);
if (forIndex == -1) {
result.append(template.substring(pos));
break;
}
// copy up to #for
result.append(template, pos, forIndex);
int lineEnd = template.indexOf('\n', forIndex);
if (lineEnd == -1) lineEnd = template.length();
String forLine = template.substring(forIndex, lineEnd).trim();
// expected: "#for leg in request.legs"
String[] parts = forLine.split("\\s+");
if (parts.length < 4 || !parts[0].equals("#for") || !parts[2].equals("in")) {
// Not a valid for-line, just copy literal
result.append(template.substring(forIndex, lineEnd));
pos = lineEnd;
continue;
}
String varName = parts[1]; // "leg"
String expr = parts[3]; // "request.legs"
// find #end corresponding
int endIndex = template.indexOf("#end", lineEnd);
if (endIndex == -1) {
throw new IllegalStateException("Missing #end for loop starting at: " + forLine);
}
// loop body between for-line end and "#end"
String loopBody = template.substring(lineEnd, endIndex);
// inside loop body, optional "#sep" line
String bodyWithoutSep;
String sep = "";
int sepIndex = loopBody.indexOf("#sep");
if (sepIndex != -1) {
int sepLineEnd = loopBody.indexOf('\n', sepIndex);
if (sepLineEnd == -1) sepLineEnd = loopBody.length();
// content before #sep line is body chunk
bodyWithoutSep = loopBody.substring(0, sepIndex);
// separator is substring after #sep line end
sep = loopBody.substring(sepLineEnd);
} else {
bodyWithoutSep = loopBody;
}
// evaluate expr on request -> should be List
Object collectionObj = resolveExpression(expr, request);
if (collectionObj instanceof List<?> list) {
for (int i = 0; i < list.size(); i++) {
Object item = list.get(i);
String chunk = bodyWithoutSep;
// Render scalar placeholders in this chunk, with "varName" bound
chunk = renderScalarsWithVar(chunk, request, varName, item);
result.append(chunk);
if (i < list.size() - 1 && !sep.isEmpty()) {
result.append(sep);
}
}
}
pos = endIndex + "#end".length();
}
return result.toString();
}
// ----- Scalar rendering -----
private static String renderScalars(String template, Object request) {
return renderScalarsWithVar(template, request, null, null);
}
private static String renderScalarsWithVar(String template,
Object request,
String varName,
Object varValue) {
Matcher m = PLACEHOLDER.matcher(template);
StringBuffer sb = new StringBuffer();
while (m.find()) {
String expr = m.group(1).trim(); // e.g. "request.cabinClass" or "leg.originIata"
Object value;
if (varName != null && expr.startsWith(varName + ".")) {
// expression relative to loop variable, e.g. leg.originIata
String subPath = expr.substring(varName.length() + 1); // skip "leg."
value = resolveExpression(subPath, varValue);
} else {
// regular expression starting with "request."
value = resolveExpression(expr, request);
}
String replacement = (value == null) ? "null" : value.toString();
replacement = replacement.replace("\\", "\\\\").replace("$", "\\$");
m.appendReplacement(sb, replacement);
}
m.appendTail(sb);
return sb.toString();
}
// ----- Expression resolution -----
// Supports:
// request.cabinClass
// request.legs[0].originIata
// And for loop var:
// originIata (when called with varValue)
private static Object resolveExpression(String expr, Object rootObj) {
if (rootObj == null || expr == null || expr.isEmpty()) return null;
String path = expr;
if (expr.startsWith("request.")) {
path = expr.substring("request.".length());
}
Object current = rootObj;
String[] parts = path.split("\\.");
for (String part : parts) {
if (current == null) return null;
int idxStart = part.indexOf('[');
if (idxStart != -1 && part.endsWith("]")) {
String fieldName = part.substring(0, idxStart);
int index = Integer.parseInt(part.substring(idxStart + 1, part.length() - 1));
current = getFieldValue(current, fieldName);
if (!(current instanceof List<?> list)) {
return null;
}
if (index < 0 || index >= list.size()) return null;
current = list.get(index);
} else {
current = getFieldValue(current, part);
}
}
return current;
}
private static Object getFieldValue(Object obj, String name) {
Class<?> cls = obj.getClass();
// Try getter
String getterName = "get" + Character.toUpperCase(name.charAt(0)) + name.substring(1);
try {
Method m = cls.getMethod(getterName);
return m.invoke(obj);
} catch (Exception ignored) {}
// Try boolean isX
String isName = "is" + Character.toUpperCase(name.charAt(0)) + name.substring(1);
try {
Method m = cls.getMethod(isName);
return m.invoke(obj);
} catch (Exception ignored) {}
// Try field
try {
Field f = cls.getDeclaredField(name);
f.setAccessible(true);
return f.get(obj);
} catch (Exception ignored) {}
return null;
}
}

View File

@@ -0,0 +1,26 @@
package de.addideas.api.emission.descriptors;
public class AuthConfig {
public enum Type { APIKEY, BASIC, OAUTH, NONE }
private Type type = Type.NONE;
private String header;
private String format;
private boolean usernameIsApiKey;
private String envKey;
public Type getType() { return type; }
public void setType(Type type) { this.type = type; }
public String getHeader() { return header; }
public void setHeader(String header) { this.header = header; }
public String getFormat() { return format; }
public void setFormat(String format) { this.format = format; }
public boolean isUsernameIsApiKey() { return usernameIsApiKey; }
public void setUsernameIsApiKey(boolean usernameIsApiKey) { this.usernameIsApiKey = usernameIsApiKey; }
public String getEnvKey() { return envKey; }
public void setEnvKey(String envKey) { this.envKey = envKey; }
}

View File

@@ -0,0 +1,20 @@
package de.addideas.api.emission.descriptors;
public class FieldMapping {
private String sourceJsonPath; // e.g. "$.co2_kg"
private String targetFieldPath; // e.g. "EmissionEstimate.co2Kg"
private String transform; // optional, e.g. "divideBy1000"
private String constantValue; // optional for constant mappings
public String getSourceJsonPath() { return sourceJsonPath; }
public void setSourceJsonPath(String sourceJsonPath) { this.sourceJsonPath = sourceJsonPath; }
public String getTargetFieldPath() { return targetFieldPath; }
public void setTargetFieldPath(String targetFieldPath) { this.targetFieldPath = targetFieldPath; }
public String getTransform() { return transform; }
public void setTransform(String transform) { this.transform = transform; }
public String getConstantValue() { return constantValue; }
public void setConstantValue(String constantValue) { this.constantValue = constantValue; }
}

View File

@@ -0,0 +1,29 @@
package de.addideas.api.emission.descriptors;
import java.util.ArrayList;
import java.util.List;
public class ListMapping {
// JSON array to pull items from, relative to the current JSON node
private String sourceJsonPath; // e.g. "$.legs[*]"
// Which field on the current object receives the list: "EmissionEstimate.legs" or "legs"
private String targetFieldPath;
// Type name for each list element, must be registered in ModelRegistry
private String itemType;
private final List<FieldMapping> itemFieldMappings = new ArrayList<>();
public String getSourceJsonPath() { return sourceJsonPath; }
public void setSourceJsonPath(String sourceJsonPath) { this.sourceJsonPath = sourceJsonPath; }
public String getTargetFieldPath() { return targetFieldPath; }
public void setTargetFieldPath(String targetFieldPath) { this.targetFieldPath = targetFieldPath; }
public String getItemType() { return itemType; }
public void setItemType(String itemType) { this.itemType = itemType; }
public List<FieldMapping> getItemFieldMappings() { return itemFieldMappings; }
public void addItemFieldMapping(FieldMapping fm) { itemFieldMappings.add(fm); }
}

View File

@@ -0,0 +1,37 @@
package de.addideas.api.emission.descriptors;
import java.util.HashMap;
import java.util.Map;
public class ModelRegistry {
private final Map<String, Class<?>> nameToClass = new HashMap<>();
private final Map<Class<?>, String> classToName = new HashMap<>();
public <T> void register(String name, Class<T> clazz) {
nameToClass.put(name, clazz);
classToName.put(clazz, name);
}
public Class<?> getClassForName(String name) {
return nameToClass.get(name);
}
public String getNameForClass(Class<?> clazz) {
return classToName.get(clazz);
}
@SuppressWarnings("unchecked")
public <T> T newInstance(String name) {
Class<?> cls = getClassForName(name);
if (cls == null) {
throw new IllegalArgumentException("Unknown model type: " + name);
}
try {
return (T) cls.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("Failed to instantiate model type: " + name, e);
}
}
}

View File

@@ -0,0 +1,29 @@
package de.addideas.api.emission.descriptors;
import java.util.List;
public class OperationDescriptor {
private String id; // e.g. "travel.flight.estimate_emissions"
private String method; // "GET", "POST"
private String path; // e.g. "/v1/flight/estimate"
private RequestBodyTemplate requestBody; // may be null
private List<QueryParamMapping> queryParams;
private ResponseMapping responseMapping;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getMethod() { return method; }
public void setMethod(String method) { this.method = method; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public RequestBodyTemplate getRequestBody() { return requestBody; }
public void setRequestBody(RequestBodyTemplate requestBody) { this.requestBody = requestBody; }
public List<QueryParamMapping> getQueryParams() { return queryParams; }
public ResponseMapping getResponseMapping() { return responseMapping; }
public void setResponseMapping(ResponseMapping responseMapping) { this.responseMapping = responseMapping; }
}

View File

@@ -0,0 +1,31 @@
package de.addideas.api.emission.descriptors;
import java.util.HashMap;
import java.util.Map;
public class ProviderDescriptor {
private String id;
private String name;
private String baseUrl;
private AuthConfig auth;
private Map<String, OperationDescriptor> operations = new HashMap<>();
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
public AuthConfig getAuth() { return auth; }
public void setAuth(AuthConfig auth) { this.auth = auth; }
public Map<String, OperationDescriptor> getOperations() { return operations; }
public void setOperations(Map<String, OperationDescriptor> operations) { this.operations = operations; }
public OperationDescriptor getOperation(String opId) {
return operations.get(opId);
}
}

View File

@@ -0,0 +1,7 @@
package de.addideas.api.emission.descriptors;
public class QueryParamMapping {
private String name; // query param name
private String fromPath; // e.g. "AirportSearchRequest.query"
// getters/setters...
}

View File

@@ -0,0 +1,12 @@
package de.addideas.api.emission.descriptors;
public class RequestBodyTemplate {
private String format; // "json"
private String template; // templated JSON
public String getFormat() { return format; }
public void setFormat(String format) { this.format = format; }
public String getTemplate() { return template; }
public void setTemplate(String template) { this.template = template; }
}

View File

@@ -0,0 +1,31 @@
package de.addideas.api.emission.descriptors;
import java.util.ArrayList;
import java.util.List;
public class ResponseMapping {
public enum Mode { SINGLE, LIST }
private Mode mode = Mode.SINGLE;
private String rootType; // e.g. "EmissionEstimate", "AirportInfo"
private String rootSource; // e.g. "$" or "$.results[*]"
private final List<FieldMapping> fieldMappings = new ArrayList<>();
private final List<ListMapping> listMappings = new ArrayList<>();
public Mode getMode() { return mode; }
public void setMode(Mode mode) { this.mode = mode; }
public String getRootType() { return rootType; }
public void setRootType(String rootType) { this.rootType = rootType; }
public String getRootSource() { return rootSource; }
public void setRootSource(String rootSource) { this.rootSource = rootSource; }
public List<FieldMapping> getFieldMappings() { return fieldMappings; }
public void addFieldMapping(FieldMapping fm) { fieldMappings.add(fm); }
public List<ListMapping> getListMappings() { return listMappings; }
public void addListMapping(ListMapping lm) { listMappings.add(lm); }
}

View File

@@ -0,0 +1,42 @@
package de.addideas.api.emission.descriptors;
import java.util.List;
public class ResponseValue<T> {
private final T single;
private final List<T> list;
private final boolean listMode;
private ResponseValue(T single, List<T> list) {
this.single = single;
this.list = list;
this.listMode = (single == null && list != null);
}
public static <T> ResponseValue<T> single(T value) {
return new ResponseValue<>(value, null);
}
public static <T> ResponseValue<T> list(List<T> values) {
return new ResponseValue<>(null, values);
}
public boolean isList() {
return listMode;
}
public T asSingle() {
if (listMode) {
throw new IllegalStateException("Response is a list, not a single value");
}
return single;
}
public List<T> asList() {
if (!listMode) {
throw new IllegalStateException("Response is a single value, not a list");
}
return list;
}
}

View File

@@ -0,0 +1,37 @@
package de.addideas.api.emission.model;
public class AirportInfo {
private String iataCode;
private String icaoCode;
private String name;
private String city;
private String country;
private Double latitude;
private Double longitude;
private String vendorRaw; // raw JSON as string for now
public String getIataCode() { return iataCode; }
public void setIataCode(String iataCode) { this.iataCode = iataCode; }
public String getIcaoCode() { return icaoCode; }
public void setIcaoCode(String icaoCode) { this.icaoCode = icaoCode; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getCountry() { return country; }
public void setCountry(String country) { this.country = country; }
public Double getLatitude() { return latitude; }
public void setLatitude(Double latitude) { this.latitude = latitude; }
public Double getLongitude() { return longitude; }
public void setLongitude(Double longitude) { this.longitude = longitude; }
public String getVendorRaw() { return vendorRaw; }
public void setVendorRaw(String vendorRaw) { this.vendorRaw = vendorRaw; }
}

View File

@@ -0,0 +1,12 @@
package de.addideas.api.emission.model;
public class AirportSearch {
private String query;
private int limit = 10;
public String getQuery() { return query; }
public void setQuery(String query) { this.query = query; }
public int getLimit() { return limit; }
public void setLimit(int limit) { this.limit = limit; }
}

View File

@@ -0,0 +1,48 @@
package de.addideas.api.emission.model;
import java.util.ArrayList;
import java.util.List;
public class EmissionEstimate {
private List<LegEstimate> legs = new ArrayList<>();
private Double co2Kg;
private Double co2eKg;
private Double nonCo2Multiplier;
private String vendor;
private String methodName;
private String methodVersion;
private String standard;
private String documentationUrl;
private String vendorRaw; // raw JSON as string for now
public List<LegEstimate> getLegs() { return legs; }
public void setLegs(List<LegEstimate> legs) { this.legs = legs; }
public Double getCo2Kg() { return co2Kg; }
public void setCo2Kg(Double co2Kg) { this.co2Kg = co2Kg; }
public Double getCo2eKg() { return co2eKg; }
public void setCo2eKg(Double co2eKg) { this.co2eKg = co2eKg; }
public Double getNonCo2Multiplier() { return nonCo2Multiplier; }
public void setNonCo2Multiplier(Double nonCo2Multiplier) { this.nonCo2Multiplier = nonCo2Multiplier; }
public String getVendor() { return vendor; }
public void setVendor(String vendor) { this.vendor = vendor; }
public String getMethodName() { return methodName; }
public void setMethodName(String methodName) { this.methodName = methodName; }
public String getMethodVersion() { return methodVersion; }
public void setMethodVersion(String methodVersion) { this.methodVersion = methodVersion; }
public String getStandard() { return standard; }
public void setStandard(String standard) { this.standard = standard; }
public String getDocumentationUrl() { return documentationUrl; }
public void setDocumentationUrl(String documentationUrl) { this.documentationUrl = documentationUrl; }
public String getVendorRaw() { return vendorRaw; }
public void setVendorRaw(String vendorRaw) { this.vendorRaw = vendorRaw; }
}

View File

@@ -0,0 +1,30 @@
package de.addideas.api.emission.model;
import java.time.ZonedDateTime;
public class FlightLeg {
private String originIata;
private String destinationIata;
private ZonedDateTime departureTime;
private String marketingCarrier;
private String operatingCarrier;
private String flightNumber;
public String getOriginIata() { return originIata; }
public void setOriginIata(String originIata) { this.originIata = originIata; }
public String getDestinationIata() { return destinationIata; }
public void setDestinationIata(String destinationIata) { this.destinationIata = destinationIata; }
public ZonedDateTime getDepartureTime() { return departureTime; }
public void setDepartureTime(ZonedDateTime departureTime) { this.departureTime = departureTime; }
public String getMarketingCarrier() { return marketingCarrier; }
public void setMarketingCarrier(String marketingCarrier) { this.marketingCarrier = marketingCarrier; }
public String getOperatingCarrier() { return operatingCarrier; }
public void setOperatingCarrier(String operatingCarrier) { this.operatingCarrier = operatingCarrier; }
public String getFlightNumber() { return flightNumber; }
public void setFlightNumber(String flightNumber) { this.flightNumber = flightNumber; }
}

View File

@@ -0,0 +1,31 @@
package de.addideas.api.emission.model;
import java.util.ArrayList;
import java.util.List;
public class FlightRequest {
private List<FlightLeg> legs = new ArrayList<>();
private String cabinClass = "economy";
private int passengers = 1;
private boolean roundtrip = false;
private boolean includeNonCo2 = true;
private String currency;
public List<FlightLeg> getLegs() { return legs; }
public void setLegs(List<FlightLeg> legs) { this.legs = legs; }
public String getCabinClass() { return cabinClass; }
public void setCabinClass(String cabinClass) { this.cabinClass = cabinClass; }
public int getPassengers() { return passengers; }
public void setPassengers(int passengers) { this.passengers = passengers; }
public boolean isRoundtrip() { return roundtrip; }
public void setRoundtrip(boolean roundtrip) { this.roundtrip = roundtrip; }
public boolean isIncludeNonCo2() { return includeNonCo2; }
public void setIncludeNonCo2(boolean includeNonCo2) { this.includeNonCo2 = includeNonCo2; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
}

View File

@@ -0,0 +1,12 @@
package de.addideas.api.emission.model;
public class LegEstimate {
private Double co2Kg;
private Double distanceKm;
public Double getCo2Kg() { return co2Kg; }
public void setCo2Kg(Double co2Kg) { this.co2Kg = co2Kg; }
public Double getDistanceKm() { return distanceKm; }
public void setDistanceKm(Double distanceKm) { this.distanceKm = distanceKm; }
}

80
src/providers.xml Normal file
View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<providers xmlns="https://calco2la.to/schema/providers/v1">
<provider id="calco2lato">
<name>calco2la.to</name>
<baseUrl>https://api.calco2la.to</baseUrl>
<auth type="apiKey">
<header>Authorization</header>
<format>Bearer ${API_KEY}</format>
<envKey>CALCO2LATO_API_KEY</envKey>
</auth>
<operations>
<operation id="travel.flight.estimate_emissions">
<http method="POST" path="/latest/transport/flight"/>
<requestBody format="json">
{
"flights": [
#for leg in request.legs
{
"departure": "${leg.originIata}",
"arrival": "${leg.destinationIata}",
"travelClass": "${request.cabinClass}",
"passengerCount": ${request.passengers}
}#sep,
#end
]
}
</requestBody>
<responseMapping mode="single" rootType="EmissionEstimate">
<!-- adjust source paths to whatever calco2la.to actually returns -->
<field source="$.co2WithoutRfi" target="EmissionEstimate.co2Kg"/>
<field source="$.co2" target="EmissionEstimate.co2eKg"/>
<list source="$.flights[*]" target="EmissionEstimate.legs" itemType="LegEstimate">
<field source="$.co2" target="LegEstimate.co2Kg"/>
<field source="$.distance" target="LegEstimate.distanceKm"/>
<!-- etc. -->
</list>
<!--
<field source="$.rf_factor" target="EmissionEstimate.nonCo2Multiplier"/>
<field source="$.method.name" target="EmissionEstimate.methodName"/>
<field source="$.method.version" target="EmissionEstimate.methodVersion"/>
<field source="$.standard" target="EmissionEstimate.standard"/>
<field source="$.documentation_url" target="EmissionEstimate.documentationUrl"/>
-->
<!-- store full raw JSON -->
<field source="$" target="EmissionEstimate.vendorRaw"/>
</responseMapping>
</operation>
<operation id="travel.airport.search">
<http method="POST" path="/latest/transport/airports"/>
<requestBody format="json">
{
"iata": "${request.query}",
"per_page": ${request.limit}
}
</requestBody>
<responseMapping mode="list" rootType="AirportInfo" rootSource="$.results[*]">
<field source="$.iata" target="AirportInfo.iataCode"/>
<field source="$.icao" target="AirportInfo.icaoCode"/>
<field source="$.name" target="AirportInfo.name"/>
<field source="$.city" target="AirportInfo.city"/>
<!-- <field source="$.country" target="AirportInfo.country"/> -->
<field source="$.lat" target="AirportInfo.latitude"/>
<field source="$.lon" target="AirportInfo.longitude"/>
<!-- store full raw JSON -->
<field source="$" target="AirportInfo.vendorRaw"/>
</responseMapping>
</operation>
</operations>
</provider>
</providers>