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
47 changes: 32 additions & 15 deletions .agents/skills/naftiko-capability/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ for *how*.
| "I want to proxy an API today and encapsulate it incrementally" | Read `references/proxy-then-customize.md` |
| "I want to chain multiple HTTP calls to consumed APIs and expose the result into a single REST operation" | Read `references/chain-api-calls.md` |
| "I need to go from local test credentials to production secrets" | Read `references/dev-to-production.md` |
| "I want to prototype a tool or endpoint before the backend exists" or "I want to return static or dynamic mock data" | Read `references/mock-capability.md` |
| "I want to build a full-featured capability that does all of the above" | Read all stories in order, then use `assets/capability-example.yml` as structural reference |
| "I have a YAML validation error" | Run `scripts/lint-capability.sh` — see **Lint workflow** below |
| "I'm done writing — what should I check before shipping?" | Read `references/design-guidelines.md`, then run lint |
Expand Down Expand Up @@ -95,20 +96,30 @@ Specification directly.
For the full rule list, read the Spectral ruleset file directly.
3. Fix and re-lint. Repeat until clean.

## Hard Constraints

1. The root field `naftiko` must be `"1.0.0-alpha1"` — no other version string is
valid for this spec revision.
2. Every `consumes` entry must have both `baseUri` and `namespace`.
3. Every `exposes` entry must have a `namespace`.
4. Namespaces must be unique across all `exposes`, `consumes` and `binds` objects.
5. At least one of `exposes` or `consumes` must be present in `capability`.
6. `call` references must follow `{namespace}.{operationName}` and point
to a valid consumed operation.
7. `{exposeNamespace}.{paramName}` is the only syntax for referencing
exposed input parameters in `with` injectors — do not invent alternatives.
8. `variable` expressions resolve from `binds` keys.
9. `ForwardConfig` requires `targetNamespace` (single string, not array)
### Mock a Capability

Use mock mode when the upstream API does not exist yet, or you want to
prototype a contract-first design. Read `references/mock-capability.md`
before writing any mock output parameters.

**Key rules:**

1. Omit `call`, `steps`, and the entire `consumes` block — they are not
needed for a pure mock capability.
2. Use `value` on `MappedOutputParameter` for static strings
(`value: "Hello"`) or Mustache templates (`value: "Hello, {{name}}!"`).
Do NOT use `const` — it is schema-only and is never used at runtime.
3. Mustache placeholders in `value` are resolved against the tool's or
endpoint's input parameters by name. Only top-level input parameter
names are in scope — no nesting, no `with` remapping.
4. `value` and `mapping` are mutually exclusive on a scalar output
parameter — never set both.
5. Object and array type output parameters in mock mode must carry
`value` on each leaf scalar descendant; the container itself has
no `value`.
6. When the mock is ready to be wired to a real API, add `consumes`,
replace `value` with `mapping`, and add `call` or `steps` — the
exposed contract does not change.
and `trustedHeaders` (at least one entry).
10. MCP tools must have `name` and `description`. MCP tool input parameters
must have `name`, `type`, and `description`. Tools may declare optional
Expand All @@ -129,4 +140,10 @@ Specification directly.
outputs are dead declarations that add noise without value.
16. Do not prefix variable names with the capability, namespace, or
resource name — variables are already scoped to their context.
Redundant prefixes reduce readability without adding disambiguation.
Redundant prefixes reduce readability without adding disambiguation.
17. In mock mode, use `value` on output parameters — never `const`.
`const` is a JSON Schema keyword retained for validation and linting
only; it has no effect at runtime.
18. In mock mode, Mustache templates in `value` fields resolve only against
top-level input parameter names. Do not reference `with`-remapped
consumed parameter names — those are not in scope for output resolution.
149 changes: 149 additions & 0 deletions .agents/skills/naftiko-capability/references/mock-capability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Story: Mock a Capability

## User problem

The backend API doesn't exist yet, or you want to prototype a contract-first
design — defining the exposed shape before any upstream service is available.
You need the MCP tool or REST endpoint to return realistic, shaped data so
consumers (agents, developers, tests) can start integrating immediately.

## Naftiko pattern

A **mock capability** is a capability that omits `consumes`, `call`, and
`steps`. Output parameters carry a `value` field instead of a `mapping` field.
The runtime returns the value directly without making any HTTP call.

`value` accepts:
- A **static string**: `value: "Hello, World!"`
- A **Mustache template**: `value: "Hello, {{name}}!"` — resolved against the
tool's or endpoint's input parameters by name at request time.

Both REST and MCP adapters support mock mode identically — the same output
parameter shape works for both adapter types.

## When to use each

| Situation | `value` |
|---|---|
| Fixed stub data, no inputs needed | Static string |
| Response echoes or interpolates an input | Mustache template `{{paramName}}` |
| Nested object with static leaves | Static string on each leaf scalar |
| Nested object with dynamic leaves | Mustache template on each leaf scalar |

## Minimal MCP mock example

```yaml
naftiko: "1.0.0-alpha1"

capability:
exposes:
- type: mcp
port: 3001
namespace: mock-tools
description: Mock MCP server for prototyping
tools:
- name: say-hello
description: Returns a greeting using the provided name
inputParameters:
- name: name
type: string
required: true
description: Name to greet
outputParameters:
- name: message
type: string
value: "Hello, {{name}}! Welcome aboard."
```

## Minimal REST mock example

```yaml
naftiko: "1.0.0-alpha1"

capability:
exposes:
- type: rest
address: localhost
port: 8080
namespace: mock-api
resources:
- path: /greet
operations:
- method: GET
inputParameters:
- name: name
in: query
type: string
required: true
outputParameters:
- name: message
type: string
value: "Hello, {{name}}!"
```

## Nested object mock

Nest `type: object` with scalar children carrying `value`. The container
itself has no `value` — only scalar leaves do.

```yaml
outputParameters:
- name: user
type: object
properties:
- id:
type: string
value: "usr-001"
- displayName:
type: string
value: "{{name}}"
- role:
type: string
value: "viewer"
```

## Array mock

Use `type: array` with a single `items` descriptor. The runtime returns
one representative item.

```yaml
outputParameters:
- name: results
type: array
items:
type: object
properties:
- id:
type: string
value: "item-001"
- label:
type: string
value: "Sample item for {{query}}"
```

## Migrating from mock to real

When the upstream API is ready:

1. Add a `consumes` block with `baseUri`, `namespace`, resources, and operations.
2. Replace `value` with `mapping` on each output parameter (JSONPath expression).
3. Add `call: {namespace}.{operationName}` to the exposed tool or operation.
4. Remove any inputs that were only needed for Mustache interpolation if they
are now resolved from the upstream response instead.

The exposed contract (tool/resource names, input parameters, output parameter
names and types) does not need to change — consumers see no difference.

## Hard constraints for mock mode

- Use `value`, not `const`. `const` is a JSON Schema keyword for validation;
it has no runtime effect.
- `value` and `mapping` are mutually exclusive on a scalar output parameter.
- Mustache placeholders resolve against top-level input parameter names only.
Names remapped via `with` are not in scope for output value resolution.
- Object and array output parameters in mock mode must NOT carry `value` on
the container — only on the scalar leaf descendants.
- If `call` or `steps` is present alongside output `value` fields and an
upstream response is available, the upstream response takes precedence;
`value` is the fallback when no response body exists.
113 changes: 100 additions & 13 deletions src/main/java/io/naftiko/engine/Resolver.java
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ public static Object resolveInputParameterFromRequest(InputParameterSpec spec, R
return null;
}

// constant takes precedence
if (spec.getConstant() != null) {
return spec.getConstant();
// value provides a static override when not a JSONPath expression
if (spec.getValue() != null && !spec.getValue().trim().startsWith("$")) {
return spec.getValue();
}

String in = spec.getIn() == null ? "body" : spec.getIn();
Expand Down Expand Up @@ -182,10 +182,9 @@ public static Object resolveInputParameterFromRequest(InputParameterSpec spec, R
*
* 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
* 3. 'template' field - resolved with Mustache syntax
* 4. Parameters map - direct lookup by parameter name
* 5. Environment variables - for 'environment' location
* 2. 'template' field - resolved with Mustache syntax
* 3. Parameters map - direct lookup by parameter name
* 4. Environment variables - for 'environment' location
*/
public static void resolveInputParametersToRequest(Request clientRequest,
List<InputParameterSpec> specs, Map<String, Object> parameters) {
Expand All @@ -202,9 +201,6 @@ public static void resolveInputParametersToRequest(Request clientRequest,
if (spec.getValue() != null) {
// 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);
} else if (parameters != null && parameters.containsKey(spec.getName())) {
Expand Down Expand Up @@ -245,15 +241,27 @@ public static void resolveInputParametersToRequest(Request clientRequest,
*/
public static JsonNode resolveOutputMappings(OutputParameterSpec spec, JsonNode clientRoot,
ObjectMapper mapper) {
return resolveOutputMappings(spec, clientRoot, mapper, null);
}

/**
* Build a mapped JSON node from the output parameter specification and the client response
* root, optionally resolving Mustache templates in {@code value} fields.
*
* @param parameters input parameters for Mustache resolution (may be null)
*/
public static JsonNode resolveOutputMappings(OutputParameterSpec spec, JsonNode clientRoot,
ObjectMapper mapper, Map<String, Object> parameters) {
if (spec == null) {
return NullNode.instance;
}

String type = spec.getType();

// constant value takes precedence
if (spec.getConstant() != null) {
return mapper.getNodeFactory().textNode(spec.getConstant());
// value takes precedence — used for static/mock values
if (spec.getValue() != null) {
String resolved = resolveMustacheTemplate(spec.getValue(), parameters);
return mapper.getNodeFactory().textNode(resolved);
}

if ("array".equalsIgnoreCase(type)) {
Expand Down Expand Up @@ -357,5 +365,84 @@ public static JsonNode resolveOutputMappings(OutputParameterSpec spec, JsonNode
}
}

/**
* Build a mock JSON object from output parameter {@code value} fields.
* Mustache templates in values are resolved against the given parameters.
*
* @param outputParameters the output parameter specs
* @param mapper Jackson mapper
* @param parameters input parameters for Mustache resolution (may be null)
* @return a JSON object, or {@code null} if no values could be built
*/
public static JsonNode buildMockData(List<OutputParameterSpec> outputParameters,
ObjectMapper mapper, Map<String, Object> parameters) {
if (outputParameters == null || outputParameters.isEmpty()) {
return null;
}

ObjectNode result = mapper.createObjectNode();

for (OutputParameterSpec param : outputParameters) {
JsonNode paramValue = buildMockValue(param, mapper, parameters);
if (paramValue != null && !(paramValue instanceof NullNode)) {
String fieldName = param.getName() != null ? param.getName() : "value";
result.set(fieldName, paramValue);
}
}

return result.size() > 0 ? result : null;
}

/**
* Build a mock JSON node for a single output parameter. Resolves Mustache templates
* in {@code value} fields and recurses into nested objects and arrays.
*
* @param param the output parameter spec
* @param mapper Jackson mapper
* @param parameters input parameters for Mustache resolution (may be null)
* @return the mock JSON node, or {@link NullNode} if no value could be built
*/
public static JsonNode buildMockValue(OutputParameterSpec param, ObjectMapper mapper,
Map<String, Object> parameters) {
if (param == null) {
return NullNode.instance;
}

if (param.getValue() != null) {
String resolved = resolveMustacheTemplate(param.getValue(), parameters);
return mapper.getNodeFactory().textNode(resolved);
}

String type = param.getType();

if ("array".equalsIgnoreCase(type)) {
ArrayNode arrayNode = mapper.createArrayNode();
OutputParameterSpec items = param.getItems();
if (items != null) {
JsonNode itemValue = buildMockValue(items, mapper, parameters);
if (itemValue != null && !(itemValue instanceof NullNode)) {
arrayNode.add(itemValue);
}
}
return arrayNode;
}

if ("object".equalsIgnoreCase(type)) {
ObjectNode objectNode = mapper.createObjectNode();
if (param.getProperties() != null) {
for (OutputParameterSpec prop : param.getProperties()) {
JsonNode propValue = buildMockValue(prop, mapper, parameters);
if (propValue != null && !(propValue instanceof NullNode)) {
String propName = prop.getName() != null ? prop.getName() : "property";
objectNode.set(propName, propValue);
}
}
}
return objectNode.size() > 0 ? objectNode : NullNode.instance;
}

return NullNode.instance;
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ public HttpClientOperationSpec getOperationSpec(String operationName) {
public void setHeaders(Request request) {
// Set any default headers from the input parameters
for (InputParameterSpec param : getHttpClientSpec().getInputParameters()) {
if ("header".equalsIgnoreCase(param.getIn()) && param.getConstant() != null) {
request.getHeaders().set(param.getName(), param.getConstant());
if ("header".equalsIgnoreCase(param.getIn()) && param.getValue() != null) {
request.getHeaders().set(param.getName(), param.getValue());
}
}
}
Expand Down
Loading
Loading