Skip to content
Open
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
46 changes: 44 additions & 2 deletions src/main/java/io/naftiko/Capability.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
package io.naftiko;

import java.io.File;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import io.naftiko.engine.ExternalRefResolver;
import io.naftiko.spec.ExecutionContext;
import io.naftiko.engine.consumes.ClientAdapter;
import io.naftiko.engine.consumes.HttpClientAdapter;
import io.naftiko.engine.exposes.ApiServerAdapter;
Expand All @@ -39,10 +43,36 @@ public class Capability {
private volatile NaftikoSpec spec;
private volatile List<ServerAdapter> serverAdapters;
private volatile List<ClientAdapter> clientAdapters;
private volatile Map<String, Object> externalRefVariables;

public Capability(NaftikoSpec spec) {
public Capability(NaftikoSpec spec) throws Exception {
this(spec, null);
}

/**
* Creates a capability with an optional capability directory for external ref file resolution.
*
* @param spec The Naftiko specification
* @param capabilityDir Directory containing the capability file (null for default)
* @throws Exception if external refs cannot be resolved
*/
public Capability(NaftikoSpec spec, String capabilityDir) throws Exception {
this.spec = spec;

// Resolve external references early for injection into adapters
ExternalRefResolver refResolver = new ExternalRefResolver();
ExecutionContext context = new ExecutionContext() {
@Override
public String getVariable(String key) {
return System.getenv(key);
}
};
Map<String, String> resolvedRefs = refResolver.resolve(
spec.getExternalRefs(),
context);
// Convert Map<String, String> to Map<String, Object> for compatibility
this.externalRefVariables = new HashMap<>(resolvedRefs);

// Initialize client adapters first
this.clientAdapters = new CopyOnWriteArrayList<>();

Expand Down Expand Up @@ -84,6 +114,16 @@ public List<ServerAdapter> getServerAdapters() {
return serverAdapters;
}

/**
* Returns the map of resolved external reference variables.
* These are injected into parameter resolution contexts.
*
* @return Map of variable name to resolved value
*/
public Map<String, Object> getExternalRefVariables() {
return externalRefVariables;
}

public void start() throws Exception {
for (ClientAdapter adapter : getClientAdapters()) {
adapter.start();
Expand Down Expand Up @@ -124,7 +164,9 @@ public static void main(String[] args) {
// Ignore unknown properties to handle potential Restlet framework classes
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
NaftikoSpec spec = mapper.readValue(file, NaftikoSpec.class);
Capability capability = new Capability(spec);
// Pass the capability directory for external ref file resolution
String capabilityDir = file.getParent();
Capability capability = new Capability(spec, capabilityDir);
capability.start();
System.out.println("Capability started successfully.");
} catch (Exception e) {
Expand Down
188 changes: 188 additions & 0 deletions src/main/java/io/naftiko/engine/ExternalRefResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* Copyright 2025-2026 Naftiko
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package io.naftiko.engine;

import java.io.IOException;
import java.lang.Iterable;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import io.naftiko.spec.ExecutionContext;
import io.naftiko.spec.ExternalRefKeysSpec;
import io.naftiko.spec.ExternalRefSpec;
import io.naftiko.spec.FileResolvedExternalRefSpec;
import io.naftiko.spec.RuntimeResolvedExternalRefSpec;

/**
* Resolver for external references that supports both file-based and runtime-based injection.
* File-based refs load configuration from JSON/YAML files.
* Runtime-based refs extract values from an ExecutionContext (environment variables, secrets, etc.).
*/
public class ExternalRefResolver {

private final ObjectMapper yamlMapper;
private final ObjectMapper jsonMapper;

public ExternalRefResolver() {
this.yamlMapper = new ObjectMapper(new YAMLFactory());
this.jsonMapper = new ObjectMapper();
}

/**
* Resolves all external references and returns a map of resolved variables.
*
* @param externalRefs List of external reference specifications to resolve
* @param context Execution context for runtime variable resolution
* @return Map of variable name to resolved value
* @throws IOException if file-based ref cannot be read
*/
public Map<String, String> resolve(Iterable<ExternalRefSpec> externalRefs, ExecutionContext context)
throws IOException {
Map<String, String> resolved = new HashMap<>();

if (externalRefs == null) {
return resolved;
}

for (ExternalRefSpec ref : externalRefs) {
if (ref instanceof FileResolvedExternalRefSpec) {
Map<String, String> fileVars = resolveFileReference((FileResolvedExternalRefSpec) ref);
resolved.putAll(fileVars);
} else if (ref instanceof RuntimeResolvedExternalRefSpec) {
Map<String, String> runtimeVars = resolveRuntimeReference((RuntimeResolvedExternalRefSpec) ref,
context);
resolved.putAll(runtimeVars);
}
}

return resolved;
}

/**
* Resolves a file-based external reference by reading the specified file and extracting variables
* according to the keys mapping. Supports JSON and YAML formats.
*
* @param ref The file-resolved external reference specification
* @return Map of variable name to resolved value
* @throws IOException if file cannot be read or key is missing
*/
public Map<String, String> resolveFileReference(FileResolvedExternalRefSpec ref) throws IOException {
if (ref.getUri() == null || ref.getUri().isEmpty()) {
throw new IOException("Invalid ExternalRef: missing uri");
}

ExternalRefKeysSpec keysSpec = ref.getKeys();
if (keysSpec == null || keysSpec.getKeys() == null || keysSpec.getKeys().isEmpty()) {
throw new IOException("Invalid ExternalRef: missing keys");
}

// Parse file content
Map<String, Object> fileContent = parseFileContent(ref.getUri());

// Extract variables using the key mappings
Map<String, String> resolved = new HashMap<>();
for (Map.Entry<String, String> mapping : keysSpec.getKeys().entrySet()) {
String variableName = mapping.getKey(); // e.g., "notion_token"
String fileKey = mapping.getValue(); // e.g., "NOTION_TOKEN"

Object value = fileContent.get(fileKey);
if (value == null) {
throw new IOException("Invalid ExternalRef: key '" + fileKey + "' not found in file");
}

resolved.put(variableName, String.valueOf(value));
}

return resolved;
}

/**
* Resolves a runtime-based external reference by extracting variables from an ExecutionContext.
*
* @param ref The runtime-resolved external reference specification
* @param context The execution context providing variable values
* @return Map of variable name to resolved value
* @throws IOException if variable is missing
*/
public Map<String, String> resolveRuntimeReference(RuntimeResolvedExternalRefSpec ref,
ExecutionContext context) throws IOException {
ExternalRefKeysSpec keysSpec = ref.getKeys();
if (keysSpec == null || keysSpec.getKeys() == null || keysSpec.getKeys().isEmpty()) {
throw new IOException("Invalid ExternalRef: missing keys");
}

Map<String, String> resolved = new HashMap<>();
for (Map.Entry<String, String> mapping : keysSpec.getKeys().entrySet()) {
String variableName = mapping.getKey(); // e.g., "api_key"
String contextKey = mapping.getValue(); // e.g., "API_KEY"

String value = context.getVariable(contextKey);
if (value == null) {
throw new IOException("Invalid ExternalRef: context variable '" + contextKey
+ "' not found");
}

resolved.put(variableName, value);
}

return resolved;
}

/**
* Parses file content (JSON or YAML) and returns a flat map of key-value pairs.
*
* @param uriString The file URI (file:// format)
* @return Map of keys to values
* @throws IOException if file cannot be read or parsed
*/
@SuppressWarnings("unchecked")
private Map<String, Object> parseFileContent(String uriString) throws IOException {
try {
URI uri = URI.create(uriString);
Path filePath = Paths.get(uri.getPath());

if (!Files.exists(filePath)) {
throw new IOException("File not found: " + filePath);
}

String content = Files.readString(filePath);

// Detect format by file extension
if (uriString.endsWith(".yaml") || uriString.endsWith(".yml")) {
Object parsed = yamlMapper.readValue(content, Object.class);
if (parsed instanceof Map) {
return (Map<String, Object>) parsed;
}
throw new IOException("YAML file does not contain a map at root level");
} else { // JSON by default
Object parsed = jsonMapper.readValue(content, Object.class);
if (parsed instanceof Map) {
return (Map<String, Object>) parsed;
}
throw new IOException("JSON file does not contain an object at root level");
}
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("Failed to parse external ref file: " + e.getMessage(), e);
}
}

}
11 changes: 10 additions & 1 deletion src/main/java/io/naftiko/engine/Resolver.java
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ public static Object resolveInputParameterFromRequest(InputParameterSpec spec, R

/**
* Apply a list of input parameter specs to a client request (headers and query params).
*
* Resolution priority for parameter values:
* 1. 'value' field - resolved with Mustache template syntax ({{paramName}}) for dynamic resolution
* 2. 'const' field - used as-is, no template resolution applied
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let us discuss, we should probably get rid of const

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to discuss. For me when a value is a known constant, "const" can enforce that and enable proper documentation, test generation, etc. It's a piece of JSON Structure semantics that we embed and enforce. We also know there is no need for template resolution so it is more efficient,

Copy link
Contributor

@eskenazit eskenazit Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"const" is indeed a piece of JSON Structure, but it is meant to be used schema side, not instances side. If you look at the json examples on the repos or site, you will not find const anywhere else that in schemas. I do not think we are following the intent by using this way =)

This bring more complexity to an already complex specification at the cost of more code to maintain and higher chance of bug (we already had one). The fact that we have to maintain the same behaviour for value: "v4" and const:"v4", while value :"{{v4}}" does something else is a way higher maintenance cost that just having a simple value option. I think we should only be willing to pay that price if we have a clear obvious value to do so and I do not see it to be the case here ^^'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed reply. Let's talk through this today in our spec weekly

* 3. 'template' field - resolved with Mustache syntax
* 4. Parameters map - direct lookup by parameter name
* 5. Environment variables - for 'environment' location
*/
public static void resolveInputParametersToRequest(Request clientRequest,
List<InputParameterSpec> specs, Map<String, Object> parameters) {
Expand All @@ -156,8 +163,10 @@ public static void resolveInputParametersToRequest(Request clientRequest,
Object val = null;

if (spec.getValue() != null) {
val = spec.getValue();
// Resolve Mustache templates in value, allowing dynamic parameter resolution
val = Resolver.resolveMustacheTemplate(spec.getValue(), parameters);
} else if (spec.getConstant() != null) {
// Use constant value as-is (no template resolution)
val = spec.getConstant();
} else if (spec.getTemplate() != null) {
val = Resolver.resolveMustacheTemplate(spec.getTemplate(), parameters);
Expand Down
63 changes: 36 additions & 27 deletions src/main/java/io/naftiko/engine/exposes/ApiResourceRestlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -390,33 +390,42 @@ private boolean handleFromForwardSpec(Request request, Response response) {

if (httpAdapter.getHttpClientSpec().getNamespace()
.equals(forwardSpec.getTargetNamespace())) {
// Prepare the HTTP client request
String path = (String) request.getAttributes().get("path");
String targetRef = httpAdapter.getHttpClientSpec().getBaseUri() + path;
Request clientRequest = new Request(request.getMethod(), targetRef);
clientRequest.setEntity(request.getEntity());

// Copy trusted headers from the original request to the client request
copyTrustedHeaders(request, clientRequest,
getResourceSpec().getForward().getTrustedHeaders());

// Resolve HTTP client input parameters for authentication template resolution
Map<String, Object> parameters = new ConcurrentHashMap<>();
Resolver.resolveInputParametersToRequest(clientRequest,
httpAdapter.getHttpClientSpec().getInputParameters(), parameters);

// Set any authentication needed on the client request
Response clientResponse = new Response(clientRequest);
httpAdapter.setChallengeResponse(request, clientRequest,
clientRequest.getResourceRef().toString(), parameters);
httpAdapter.setHeaders(clientRequest);

// Send the request to the target endpoint
httpAdapter.getHttpClient().handle(clientRequest, clientResponse);
response.setStatus(clientResponse.getStatus());
response.setEntity(clientResponse.getEntity());
response.commit();
return true;
try {
// Prepare the HTTP client request
String path = (String) request.getAttributes().get("path");
String targetRef = httpAdapter.getHttpClientSpec().getBaseUri() + path;
Request clientRequest = new Request(request.getMethod(), targetRef);
clientRequest.setEntity(request.getEntity());

// Copy trusted headers from the original request to the client request
copyTrustedHeaders(request, clientRequest,
getResourceSpec().getForward().getTrustedHeaders());

// Prepare parameters map for template resolution
Map<String, Object> parameters = new ConcurrentHashMap<>();

// Set any authentication needed on the client request
Response clientResponse = new Response(clientRequest);
httpAdapter.setChallengeResponse(request, clientRequest,
clientRequest.getResourceRef().toString(), parameters);
httpAdapter.setHeaders(clientRequest);

// Apply HTTP client input parameters last to ensure they take precedence
Resolver.resolveInputParametersToRequest(clientRequest,
httpAdapter.getHttpClientSpec().getInputParameters(), parameters);

// Send the request to the target endpoint
httpAdapter.getHttpClient().handle(clientRequest, clientResponse);
response.setStatus(clientResponse.getStatus());
response.setEntity(clientResponse.getEntity());
response.commit();
return true;
} catch (Exception e) {
response.setStatus(Status.SERVER_ERROR_INTERNAL);
response.setEntity("Error while handling an HTTP client call\n\n" + e.toString(),
MediaType.TEXT_PLAIN);
return true;
}
}
}
}
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/io/naftiko/spec/ExecutionContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Copyright 2025-2026 Naftiko
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package io.naftiko.spec;

/**
* Provides runtime variables for external reference resolution.
* Implementations can source variables from environment variables, secrets managers,
* or other context stores.
*/
public interface ExecutionContext {

/**
* Retrieves a variable value from the execution context.
*
* @param key The variable key to look up
* @return The variable value, or null if not found
*/
String getVariable(String key);

}
Loading