diff --git a/src/main/java/io/naftiko/Capability.java b/src/main/java/io/naftiko/Capability.java index dfc54a4..fc59716 100644 --- a/src/main/java/io/naftiko/Capability.java +++ b/src/main/java/io/naftiko/Capability.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import io.naftiko.engine.ExternalRefResolver; +import io.naftiko.engine.ConsumesImportResolver; import io.naftiko.spec.ExecutionContext; import io.naftiko.engine.consumes.ClientAdapter; import io.naftiko.engine.consumes.HttpClientAdapter; @@ -61,6 +62,12 @@ public Capability(NaftikoSpec spec) throws Exception { public Capability(NaftikoSpec spec, String capabilityDir) throws Exception { this.spec = spec; + // Resolve consumes imports early before initializing adapters + if (spec.getCapability() != null && spec.getCapability().getConsumes() != null) { + ConsumesImportResolver importResolver = new ConsumesImportResolver(); + importResolver.resolveImports(spec.getCapability().getConsumes(), capabilityDir); + } + // Resolve external references early for injection into adapters ExternalRefResolver refResolver = new ExternalRefResolver(); ExecutionContext context = new ExecutionContext() { diff --git a/src/main/java/io/naftiko/engine/ConsumesImportResolver.java b/src/main/java/io/naftiko/engine/ConsumesImportResolver.java new file mode 100644 index 0000000..ebd81a2 --- /dev/null +++ b/src/main/java/io/naftiko/engine/ConsumesImportResolver.java @@ -0,0 +1,207 @@ +/** + * 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.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import io.naftiko.spec.NaftikoSpec; +import io.naftiko.spec.consumes.ClientSpec; +import io.naftiko.spec.consumes.HttpClientSpec; +import io.naftiko.spec.consumes.ImportedConsumesHttpSpec; + +/** + * Resolver for global consumes imports. + * Loads standalone consumes files and resolves imported adapters. + * + * Design: + * - Detects imports by ClassType (ImportedConsumesHttpSpec) + * - Loads source consumes file (standalone YAML with 'consumes' array at root) + * - Finds matching namespace in source file + * - Replaces ImportedConsumesHttpSpec with resolved HttpClientSpec + * - Supports 'as' alias for namespace disambiguation + */ +public class ConsumesImportResolver { + + private final ObjectMapper yamlMapper; + + public ConsumesImportResolver() { + this.yamlMapper = new ObjectMapper(new YAMLFactory()); + this.yamlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + /** + * Resolves all imports in a capability's consumes array. + * Mutates the consumes list in-place, replacing imports with resolved clients. + * + * @param consumes List of client specs (may contain imports) + * @param capabilityDir Directory containing the capability file (for relative path resolution) + * @throws IOException if import file cannot be loaded or namespace not found + */ + public void resolveImports(List consumes, String capabilityDir) throws IOException { + if (consumes == null || consumes.isEmpty()) { + return; + } + + // Process all imports and replace in-place + for (int i = 0; i < consumes.size(); i++) { + ClientSpec client = consumes.get(i); + + if (client instanceof ImportedConsumesHttpSpec) { + ImportedConsumesHttpSpec importSpec = (ImportedConsumesHttpSpec) client; + + // Resolve the import + HttpClientSpec resolved = resolveImport(importSpec, capabilityDir); + + // Replace in list + consumes.set(i, resolved); + } + } + } + + /** + * Resolves a single import by loading the source consumes file and finding the namespace. + * + * @param importSpec The import specification + * @param capabilityDir Directory containing the importing capability + * @return Resolved HttpClientSpec with effective namespace set + * @throws IOException if file cannot be loaded or namespace not found + */ + private HttpClientSpec resolveImport(ImportedConsumesHttpSpec importSpec, String capabilityDir) + throws IOException { + String location = importSpec.getLocation(); + String importNamespace = importSpec.getImportNamespace(); + String alias = importSpec.getAlias(); + + if (location == null || location.isEmpty()) { + throw new IOException("Import 'location' is required"); + } + if (importNamespace == null || importNamespace.isEmpty()) { + throw new IOException("Import 'import' (namespace) is required"); + } + + // Resolve path: relative to capability directory + Path sourcePath = resolvePath(location, capabilityDir); + if (!Files.exists(sourcePath)) { + throw new IOException("Import source file not found: " + sourcePath); + } + + // Load source consumes file + NaftikoSpec sourceSpec = loadConsumesFile(sourcePath); + + // Find matching namespace in source + HttpClientSpec sourceClient = findNamespace(sourceSpec, importNamespace); + if (sourceClient == null) { + throw new IOException( + String.format( + "Namespace '%s' not found in source consumes file: %s", + importNamespace, + sourcePath + ) + ); + } + + // Create a copy of the resolved client with effective namespace + HttpClientSpec resolved = copyHttpClientSpec(sourceClient); + if (alias != null && !alias.isEmpty()) { + resolved.setNamespace(alias); + } + + return resolved; + } + + /** + * Resolves an import path relative to the capability directory. + * Examples: + * - "./shared-adapters.consumes.yml" → /path/to/parent/shared-adapters.consumes.yml + * - "../shared/notion.yml" → /path/to/shared/notion.yml + * + * @param location The location string from the import + * @param capabilityDir The directory of the importing capability (null = use current dir) + * @return Absolute path to the import source file + */ + private Path resolvePath(String location, String capabilityDir) { + Path basePath = (capabilityDir != null && !capabilityDir.isEmpty()) + ? Paths.get(capabilityDir) + : Paths.get("."); + + return basePath.resolve(location).normalize().toAbsolutePath(); + } + + /** + * Loads a standalone consumes file. + * The file should have 'consumes' array at root (no 'capability' key). + * + * @param filePath Path to the consumes file + * @return NaftikoSpec with consumes array populated (capability is null) + * @throws IOException if file cannot be read or parsed + */ + private NaftikoSpec loadConsumesFile(Path filePath) throws IOException { + try { + NaftikoSpec spec = yamlMapper.readValue(filePath.toFile(), NaftikoSpec.class); + + if (spec.getConsumes() == null || spec.getConsumes().isEmpty()) { + throw new IOException("Consumes file has no 'consumes' array: " + filePath); + } + + return spec; + } catch (IOException e) { + throw new IOException("Failed to load consumes file: " + filePath + " - " + e.getMessage(), e); + } + } + + /** + * Finds a namespace in the consumes array. + * Returns the first HttpClientSpec matching the namespace. + * + * @param spec The loaded NaftikoSpec + * @param namespace The namespace to find + * @return HttpClientSpec if found, null otherwise + */ + private HttpClientSpec findNamespace(NaftikoSpec spec, String namespace) { + if (spec.getConsumes() == null) { + return null; + } + + return spec.getConsumes().stream() + .filter(client -> client instanceof HttpClientSpec) + .map(client -> (HttpClientSpec) client) + .filter(client -> namespace.equals(client.getNamespace())) + .findFirst() + .orElse(null); + } + + /** + * Creates a deep copy of an HttpClientSpec. + * This prevents mutations to the original source spec. + * + * Implementation: serialize/deserialize via Jackson. + * + * @param original The spec to copy + * @return A new independent copy + * @throws IOException if serialization fails + */ + private HttpClientSpec copyHttpClientSpec(HttpClientSpec original) throws IOException { + // Serialize and deserialize to create independent copy + String yaml = yamlMapper.writeValueAsString(original); + return yamlMapper.readValue(yaml, HttpClientSpec.class); + } +} diff --git a/src/main/java/io/naftiko/spec/NaftikoSpec.java b/src/main/java/io/naftiko/spec/NaftikoSpec.java index 27f6c46..09459c6 100644 --- a/src/main/java/io/naftiko/spec/NaftikoSpec.java +++ b/src/main/java/io/naftiko/spec/NaftikoSpec.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import com.fasterxml.jackson.annotation.JsonInclude; +import io.naftiko.spec.consumes.ClientSpec; /** * Naftiko Specification Root, including version and capabilities @@ -30,12 +31,16 @@ public class NaftikoSpec { private final List externalRefs; private volatile CapabilitySpec capability; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List consumes; public NaftikoSpec(String naftiko, InfoSpec info, CapabilitySpec capability) { this.naftiko = naftiko; this.info = info; this.externalRefs = new CopyOnWriteArrayList<>(); this.capability = capability; + this.consumes = new CopyOnWriteArrayList<>(); } public NaftikoSpec() { @@ -69,5 +74,9 @@ public CapabilitySpec getCapability() { public void setCapability(CapabilitySpec capability) { this.capability = capability; } + + public List getConsumes() { + return consumes; + } } diff --git a/src/main/java/io/naftiko/spec/consumes/ClientSpec.java b/src/main/java/io/naftiko/spec/consumes/ClientSpec.java index c595910..d08d6fa 100644 --- a/src/main/java/io/naftiko/spec/consumes/ClientSpec.java +++ b/src/main/java/io/naftiko/spec/consumes/ClientSpec.java @@ -14,20 +14,12 @@ package io.naftiko.spec.consumes; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; /** - * Base Exposed Adapter Specification Element + * Base Consumed Adapter Specification Element */ -@JsonTypeInfo( - use = JsonTypeInfo.Id.NAME, - include = JsonTypeInfo.As.PROPERTY, // Include the type identifier as a property in the JSON - property = "type" // The name of the JSON property holding the type identifier -) -@JsonSubTypes({ - @JsonSubTypes.Type(value = HttpClientSpec.class, name = "http") -}) +@JsonDeserialize(using = ClientSpecDeserializer.class) public abstract class ClientSpec { private volatile String type; diff --git a/src/main/java/io/naftiko/spec/consumes/ClientSpecDeserializer.java b/src/main/java/io/naftiko/spec/consumes/ClientSpecDeserializer.java new file mode 100644 index 0000000..dbb8c83 --- /dev/null +++ b/src/main/java/io/naftiko/spec/consumes/ClientSpecDeserializer.java @@ -0,0 +1,44 @@ +/** + * 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.consumes; + +import java.io.IOException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Custom deserializer that discriminates between Import and regular HttpClientSpec + * based on presence of 'location' field. + * + * Design: + * - If 'location' field is present -> ImportedConsumesHttpSpec + * - Otherwise -> HttpClientSpec + */ +public class ClientSpecDeserializer extends JsonDeserializer { + + @Override + public ClientSpec deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode node = ctxt.readTree(p); + + // If 'location' field is present -> ImportedConsumesHttpSpec + if (node.has("location")) { + return ctxt.readTreeAsValue(node, ImportedConsumesHttpSpec.class); + } + + // Otherwise -> HttpClientSpec + return ctxt.readTreeAsValue(node, HttpClientSpec.class); + } +} diff --git a/src/main/java/io/naftiko/spec/consumes/HttpClientSpec.java b/src/main/java/io/naftiko/spec/consumes/HttpClientSpec.java index 14535ef..44a45d3 100644 --- a/src/main/java/io/naftiko/spec/consumes/HttpClientSpec.java +++ b/src/main/java/io/naftiko/spec/consumes/HttpClientSpec.java @@ -16,11 +16,14 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import io.naftiko.spec.InputParameterSpec; /** * Specification Element of consumed HTTP adapter endpoints */ +@JsonDeserialize(using = JsonDeserializer.None.class) public class HttpClientSpec extends ClientSpec { private volatile String baseUri; diff --git a/src/main/java/io/naftiko/spec/consumes/ImportedConsumesHttpSpec.java b/src/main/java/io/naftiko/spec/consumes/ImportedConsumesHttpSpec.java new file mode 100644 index 0000000..4b3e17a --- /dev/null +++ b/src/main/java/io/naftiko/spec/consumes/ImportedConsumesHttpSpec.java @@ -0,0 +1,88 @@ +/** + * 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.consumes; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * A globally imported consumed HTTP adapter reference. + * Discriminant: presence of 'location' field distinguishes from HttpClientSpec. + * + * When an import is resolved, this spec should be replaced by the resolved HttpClientSpec + * from the source consumes file. + */ +@JsonDeserialize(using = JsonDeserializer.None.class) +public class ImportedConsumesHttpSpec extends ClientSpec { + + @JsonProperty("location") + private volatile String location; + + @JsonProperty("import") + private volatile String importNamespace; + + @JsonProperty("as") + private volatile String alias; + + public ImportedConsumesHttpSpec() { + super(null, null); + } + + public ImportedConsumesHttpSpec(String location, String importNamespace, String alias) { + super("http", null); + this.location = location; + this.importNamespace = importNamespace; + this.alias = alias; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + public String getImportNamespace() { + return importNamespace; + } + + public void setImportNamespace(String importNamespace) { + this.importNamespace = importNamespace; + } + + public String getAlias() { + return alias; + } + + public void setAlias(String alias) { + this.alias = alias; + } + + /** + * Returns the effective namespace for this import. + * If 'as' is specified, uses that; otherwise uses the source namespace. + * This should only be called after import resolution. + */ + @Override + public String getNamespace() { + if (importNamespace == null) { + throw new IllegalStateException( + "Cannot get namespace before import is resolved. Location: " + location + ); + } + return (alias != null && !alias.isEmpty()) ? alias : importNamespace; + } +} diff --git a/src/main/resources/blueprints/consumes-adapter-reuse-proposal.md b/src/main/resources/blueprints/consumes-adapter-reuse-proposal.md new file mode 100644 index 0000000..d7fd391 --- /dev/null +++ b/src/main/resources/blueprints/consumes-adapter-reuse-proposal.md @@ -0,0 +1,524 @@ +### Global import of consumed HTTP adapters with unified JSON Schema + +**Status** : Draft + +**Version** : 0.6 + +**Date** : March 11, 2026 + +**Author** : @Thomas Eskenazi + +**Parent proposal** : [External Capability Reference Proposal](https://www.notion.so/External-Capability-Reference-Proposal-7575c829c70a4443ac1d5afc0cd58781?pvs=21) (Stalled — out of scope for alpha) + +--- + +## Table of Contents + +1. Executive Summary +2. Standalone Consumes File Format +3. Import Mechanism +4. Schema Architecture +5. Unified Schema — Full JSON Schema Source +6. DX & Tooling +7. Validation Rules (Spectral) +8. Design Decisions & Rationale + +--- + +## 1. Executive Summary + +### What This Proposes + +Two interconnected changes to the Naftiko specification: + +1. **`consumes` becomes a standalone object** — with its own file format (`.yml`), its own JSON Schema definition, and the ability to be validated independently. +2. **Inline import mechanism** — a consumed resource from an external consumes file can be imported directly at point of use within a `consumes` block, using `import` and `as` fields. Operations are explicitly declared (imported or local). + +### What This Does NOT Do + +- **No cherry-picking** (yet) — you import an entire namespace with all its resources and operations. Selective import may be added as a future iteration if use cases emerge. +- **No override** — imported adapters are used as-is (address, authentication, resource definitions). If a modified version is needed, define a local adapter instead. +- **No transitive import** — the source consumes file must be self-sufficient. + +### Schema Architecture Change + +The consumes types and the capability types are unified into a **single JSON Schema file** (`naftiko-schema.json`). The root is a **flat object** with a `oneOf` on `required` to discriminate between a capability document and a standalone consumes document — no wrapper types, no cross-file references, no compilation step. All `$defs` live in one namespace. + +```jsx +schemas/ +└── naftiko-schema.json ← single schema with oneOf at root +``` + +--- + +## 2. Standalone Consumes File Format + +A new top-level YAML file format for standalone consumes definitions: + +```yaml +# shared-notion-api.yml +naftiko: "0.5" + +info: + label: "Notion API Consumes block" + description: "Shared consumes block for Notion REST API v1" + +consumes: + - namespace: "notion" + type: "http" + address: "https://api.notion.com" + authentication: + type: "bearer" + token: "$secrets.notion_token" + resources: + - name: "databases" + path: "/v1/databases" + operations: + - method: "POST" + name: "query-database" + path: "/{databaseId}/query" + description: "Query a Notion database with filters and sorts" + requestBody: + type: "json" + outputParameters: + - name: "results" + type: "array" + - method: "GET" + name: "get-database" + path: "/{databaseId}" + - name: "pages" + path: "/v1/pages" + operations: + - method: "GET" + name: "get-page" + path: "/{pageId}" + - method: "POST" + name: "create-page" + requestBody: + type: "json" +``` + +### Key points + +- Same `naftiko` version header as capabilities +- `info` block for metadata (label, description) +- `consumes` array at the root — **identical structure** as inside a capability today +- No `capability` block, no `exposes` — purely a consumes definition + +### Detection and disambiguation + +Both capabilities and standalone consumes use `.yml` files. Disambiguation is **content-based**: + +- Has `capability` key → capability file +- Has `consumes` key at root without `capability` → standalone consumes file + +--- + +## 3. Import Mechanism + +### 3.1 Core concept + +A consumed adapter within a capability's `consumes` block can be **globally imported** from an external standalone consumes file. The discriminant is the presence of the `location` field on the `consumes[]` entry itself. + +```yaml +capability: + consumes: + # Local adapter (no location → defined here) + - namespace: "my-api" + type: "http" + address: "http://localhost:8080" + resources: + - name: "hello" + path: "/hello" + operations: + - method: "GET" + name: "fetch" + + # Imported adapter (location present → from external file) + - location: "./shared-notion-api.yml" + import: "notion" +``` + +### 3.2 Consumes-level import + +| **Field** | **Type** | **Required** | **Description** | +| --- | --- | --- | --- | +| `location` | string (URI) | **Yes** | Path to the source consumes file. Relative paths resolved from the importing file's directory. | +| `import` | string | **Yes** | Namespace identifier in the source consumes file | +| `as` | string | No | Local alias for the imported namespace. If omitted, the source namespace is used. | + +### 3.3 Runtime context: address and authentication + +When an adapter is imported globally, its runtime context (`address`, `authentication`) comes from the **source file** — not from the importing capability. The import brings the complete adapter definition including its runtime configuration. + +This is intentional for the alpha: + +- Global import is a convenience for **reusing an entire adapter** — the source file fully defines the contract and the runtime +- If different address or auth is needed, define a local adapter instead +- Address/auth override on global imports may be added as a future evolution if use cases emerge + +### 3.5 Examples + +#### Source files + +Two standalone consumes adapters with **identical namespace, resource names, and operation names** — one in English, one in French: + +```yaml +# hello-world-en-consumes.yml +naftiko: "0.5" +info: + label: "Hello World Adapter (EN)" +consumes: + - namespace: "hello-world" + type: "http" + address: "http://localhost:8080" + resources: + - name: "hello" + path: "/hello" + operations: + - method: "GET" + name: "say-hello" + outputParameters: + - type: "string" + const: "Hello, World!" + - method: "POST" + name: "say-hello-custom" + requestBody: + type: "json" +``` + +```yaml +# hello-world-fr-consumes.yml +naftiko: "0.5" +info: + label: "Hello World Adapter (FR)" +consumes: + - namespace: "hello-world" + type: "http" + address: "http://localhost:8081" + resources: + - name: "hello" + path: "/hello" + operations: + - method: "GET" + name: "say-hello" + outputParameters: + - type: "string" + const: "Bonjour, le monde !" +``` + +#### Example A — Collision management with `as` + +Two sources share the same namespace `"hello-world"` → `as` disambiguates: + +```yaml +# my-multilang-capability.yml +naftiko: "0.5" +capability: + consumes: + - location: "./hello-world-en-consumes.yml" + import: "hello-world" + as: "hello-world-en" + - location: "./hello-world-fr-consumes.yml" + import: "hello-world" + as: "hello-world-fr" + + exposes: + - type: "api" + port: 8082 + namespace: "proxy" + resources: + - path: "/greet/en" + operations: + - method: "GET" + call: "hello-world-en.say-hello" + - path: "/greet/fr" + operations: + - method: "GET" + call: "hello-world-fr.say-hello" +``` + +> **Why `as` is needed here**: Both source files define namespace `"hello-world"`. Importing both without aliasing would create a namespace collision. `as: "hello-world-en"` / `as: "hello-world-fr"` gives each import a unique local namespace so that `call` references resolve unambiguously. +> + +### 3.6 Constraints + +- **No override** — an imported adapter is used exactly as defined in the source (address, authentication, resources, operations). If you need a modified version, define a local adapter instead. +- **No cherry-picking** — you import an entire namespace, not individual resources or operations. Selective import may be considered as a future iteration if use cases emerge. +- **No transitive import** — the source consumes file must be self-sufficient (no imports within imports). + +> 📝 Cherry-picking and override are recognized as potentially valuable features. They are deliberately out of scope for the alpha to keep the import mechanism simple and predictable. If these needs emerge, they will be treated as **incremental evolutions** of the current global import model. +> + +--- + +## 4. Schema Architecture + +### 4.1 Unified schema with `oneOf` at root + +| **Before (1 file)** | **After (1 file, unified)** | +| --- | --- | +| `capability-schema.json` — capability types only | `naftiko-schema.json` — unified schema with `oneOf` at root, all `$defs` in one namespace | + +The schema root is a **flat object** — all properties are declared directly at root, with a `oneOf` on `required` to discriminate between document types. No wrapper types (`CapabilityDocument`, `ConsumesDocument`): + +```json +{ + "$id": "https://naftiko.io/schemas/v0.5/naftiko.json", + "type": "object", + "properties": { + "naftiko": { "type": "string", "const": "0.5" }, + "info": { "$ref": "#/$defs/Info" }, + "capability": { "$ref": "#/$defs/Capability" }, + "consumes": { + "type": "array", + "items": { + "oneOf": [ + { "$ref": "#/$defs/ConsumesHttp" }, + { "$ref": "#/$defs/ImportedConsumesHttp" } + ] + }, + "minItems": 1 + }, + "externalRefs": { "type": "array", "items": { "$ref": "#/$defs/ExternalRef" } } + }, + "oneOf": [ + { "required": ["naftiko", "capability"] }, + { "required": ["naftiko", "consumes"] } + ], + "additionalProperties": false +} +``` + +Discrimination via `required`: + +- `{ "required": ["naftiko", "capability"] }` → capability document +- `{ "required": ["naftiko", "consumes"] }` → standalone consumes document + +`info` is **optional in both branches** — may be omitted in any document type. + +### 4.2 New types + +- `ImportedConsumesHttp` — schema definition for the global import mechanism (discriminated by the `location` field) + +--- + +## 5. Schema Changes + +This section describes only the **changes** to the existing `capability-schema.json` (renamed to `naftiko-schema.json`). All existing `$defs` (`ConsumesHttp`, `ConsumedHttpResource`, `ConsumedHttpOperation`, `Authentication`, etc.) remain unchanged. + +### 5.1 Root — add `oneOf` discriminator + +The schema root becomes a **flat object** with all properties declared directly and a `oneOf` on `required` for discrimination — no wrapper types: + +```json +{ + "$id": "https://naftiko.io/schemas/v0.5/naftiko.json", + "type": "object", + "properties": { + "naftiko": { "type": "string", "const": "0.5" }, + "info": { "$ref": "#/$defs/Info" }, + "capability": { "$ref": "#/$defs/Capability" }, + "consumes": { + "type": "array", + "items": { + "oneOf": [ + { "$ref": "#/$defs/ConsumesHttp" }, + { "$ref": "#/$defs/ImportedConsumesHttp" } + ] + }, + "minItems": 1 + }, + "externalRefs": { "type": "array", "items": { "$ref": "#/$defs/ExternalRef" } } + }, + "oneOf": [ + { "required": ["naftiko", "capability"] }, + { "required": ["naftiko", "consumes"] } + ], + "additionalProperties": false +} +``` + +### 5.2 Removed — `ConsumesDocument` and `ConsumesInfo` + +These wrapper types are no longer needed. Discrimination between capability and standalone consumes documents is handled by `oneOf` on `required` at the root — no separate `$def` per document type. + +The existing `Info` `$def` is reused for the optional `info` property in both document types. + +### 5.4 New `$def` — `ImportedConsumesHttp` + +```json +"ImportedConsumesHttp": { + "type": "object", + "description": "A globally imported consumed HTTP adapter. Discriminant: 'location' field present.", + "properties": { + "location": { + "type": "string", + "description": "URI to the source consumes file" + }, + "import": { + "$ref": "#/$defs/IdentifierKebab", + "description": "Namespace in the source consumes file" + }, + "as": { + "$ref": "#/$defs/IdentifierKebab", + "description": "Optional local alias for the imported namespace" + } + }, + "required": ["location", "import"], + "additionalProperties": false +} +``` + +### 5.5 Modified `$def` — `Capability` + +The `consumes` items change from a direct `ConsumesHttp` reference to a `oneOf` accepting both local and imported adapters: + +```json +"Capability": { + "properties": { + "consumes": { + "type": "array", + "items": { + "oneOf": [ + { "$ref": "#/$defs/ConsumesHttp" }, + { "$ref": "#/$defs/ImportedConsumesHttp" } + ] + } + } + } +} +``` + +> 📝 All other existing `$defs` (`ConsumesHttp`, `ConsumedHttpResource`, `ConsumedHttpOperation`, `Authentication`, `RequestBody`, identifiers, etc.) are **unchanged** and not repeated here. +> + +--- + +## 6. DX & Tooling + +> 📝 This section describes **tooling and DX considerations** — IDE extensions and local development workflows. They are **not part of the specification itself**. +> + +### 6.1 VS Code configuration + +```json +// .vscode/settings.json +{ + "yaml.schemas": { + "schemas/naftiko-schema.json": ["**/*.capability.yml", "**/*.consumes.yml"] + } +} +``` + +> ⚠️ File naming convention (`*.capability.yml` / `*.consumes.yml`) is a **DX convenience** for schema association — not a requirement. Content-based detection remains the authoritative mechanism. +> + +### 6.2 Java validation + +The unified schema is validated at runtime by the framework using **networknt/json-schema-validator**. No compilation step, no build tool — the schema is a standard JSON Schema file loaded directly. + +--- + +## 7. Validation Rules (Spectral) + +JSON Schema validates the **structure** of a document — types, required fields, allowed values, patterns. But certain constraints are **semantic** and go beyond what a schema can express: + +- **Contextual requirements** — `name` must be required in standalone consumes files but stays optional in inline capabilities (backward compatibility). A schema has one `required` list — it cannot vary by context. +- **Cross-field consistency** — `import` references a namespace that must exist in the source file. These are **referential integrity** checks across files, not structural checks. +- **Uniqueness within scope** — operation names unique within a resource, resource names unique within a namespace. +- **Business rules** — `as` aliases should not collide within a capability's `consumes` block. + +Spectral fills this gap: it runs **custom linting rules** on the parsed YAML/JSON, with full access to the document tree and the ability to resolve cross-file references. + +### 7.1 Standalone consumes files + +| Rule | Severity | Description | +| --- | --- | --- | +| `consumes-resource-name-required` | error | Resources in standalone consumes files **must** have a `name` (needed for import references) | +| `consumes-operation-name-required` | error | Operations in standalone consumes files **must** have a `name` | +| `consumes-unique-resource-name` | error | Resource `name` must be unique within a namespace | +| `consumes-unique-operation-name` | error | Operation `name` must be unique within a resource | + +### 7.2 Import mechanism + +| Rule | Severity | Description | +| --- | --- | --- | +| `import-ref-exists` | error | The `import` reference (namespace) must resolve to an existing namespace in the source consumes file | +| `import-unique-alias` | warning | `as` aliases must be unique within the capability's `consumes[]` array | + +### 7.3 Capabilities (when using inline consumes) + +| Rule | Severity | Description | +| --- | --- | --- | +| `capability-resource-name-when-reused` | warning | Resources referenced by `call:` patterns should have a `name` | + +> 📝 `name` is enforced by Spectral rules rather than schema `required` because existing capabilities where resources don't need names should remain valid. The schema stays permissive; Spectral layers on stricter rules contextually. +> + +--- + +## 8. Design Decisions & Rationale + +### Decision 1 : Namespace = unit of import (no cherry-picking for alpha) + +**Context** : The import granularity could be at the operation, resource, or namespace level. + +**Choice** : Import at the **namespace** level. The entire consumed adapter (all resources and all operations) is imported as a unit. + +**Rationale** : + +- A namespace is a **cohesive unit** — address, authentication, resources, and operations form a consistent contract +- Finer-grained import (resource-level or operation-level cherry-picking) introduces resolution complexity that isn't needed for the alpha +- Simple mental model: "I import this adapter, I get everything it defines" + +> 📝 Cherry-picking (selecting individual resources or operations) may be added as a **future iteration** if use cases emerge. The current global import model is designed to support this evolution without breaking changes. +> + +### Decision 2 : No override — import as-is or define locally + +**Choice** : Imported adapters are used exactly as defined in the source file. No mechanism to override address, authentication, resources, or operations on import. + +**Rationale** : + +- **Simple and explicit** — import or define locally, no middle ground +- Override introduces complex questions (partial override? merge semantics? precedence?) for marginal gains +- If you need a modified version, define a local adapter instead — slightly more verbose but unambiguous +- Override on global imports may be added as a future evolution if use cases emerge + +### Decision 3 : Address/auth from source file + +**Context** : When a namespace is imported, the runtime context (address, authentication) could come from the source file or from the importing capability. + +**Choice** : Address and authentication come from the **source file**. + +**Rationale** : + +- Global import is a convenience for **reusing an entire adapter as-is** — the source file fully defines the contract and the runtime +- The source file is the single source of truth for how to reach the service +- If different address or auth is needed, define a local adapter instead +- This keeps the import mechanism simple — no merge of runtime configuration between files + +### Decision 4 : Unified JSON Schema — single file, no compilation + +**Context** : The Naftiko specification needs a schema to validate YAML capability and consumes files. + +**Choice** : A single `naftiko-schema.json` file authored and maintained directly as standard JSON Schema. `oneOf` at the root discriminates between capability and consumes documents. All `$defs` live in one namespace. + +**Rationale** : + +- **No build step** — the schema is ready to use as-is, no compilation required +- **Standard tooling** — any JSON Schema validator (networknt in Java, AJV in JS) works out-of-the-box +- **VS Code friendly** — YAML extension resolves everything from one local file +- **Simplicity** — one file, one namespace, zero cross-file references + +### Decision 5 : Content-based file disambiguation + +**Choice** : Capability vs standalone consumes is detected by content (`capability` key present or not), not by file extension. + +**Rationale** : + +- No new naming constraints on `.yml` files +- Simple to implement — check for the `capability` top-level key +- Consistent with how YAML/JSON tools typically handle polymorphic schemas +- File naming conventions (`*.capability.yml` / `*.consumes.yml`) are optional DX sugar for VS Code schema association \ No newline at end of file diff --git a/src/main/resources/schemas/naftiko-schema.json b/src/main/resources/schemas/naftiko-schema.json index 13f0517..9f7e68c 100644 --- a/src/main/resources/schemas/naftiko-schema.json +++ b/src/main/resources/schemas/naftiko-schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://naftiko.io/schemas/v0.5/capability.json", + "$id": "https://naftiko.io/schemas/v0.5/naftiko.json", "name": "Naftiko Specification", "description": "This Schema should be used to describe and validate Naftiko Capabilities", "type": "object", @@ -13,6 +13,20 @@ "info": { "$ref": "#/$defs/Info" }, + "consumes": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/ConsumesHttp" + }, + { + "$ref": "#/$defs/ImportedConsumesHttp" + } + ] + }, + "minItems": 1 + }, "externalRefs": { "type": "array", "items": { @@ -25,12 +39,45 @@ "$ref": "#/$defs/Capability" } }, - "required": [ - "naftiko", - "capability" + "oneOf": [ + { + "required": [ + "naftiko", + "capability" + ] + }, + { + "required": [ + "naftiko", + "consumes" + ] + } ], "additionalProperties": false, "$defs": { + "ImportedConsumesHttp": { + "type": "object", + "description": "A globally imported consumed HTTP adapter. Discriminant: 'location' field present.", + "properties": { + "location": { + "type": "string", + "description": "URI to the source consumes file" + }, + "import": { + "$ref": "#/$defs/IdentifierKebab", + "description": "Namespace in the source consumes file" + }, + "as": { + "$ref": "#/$defs/IdentifierKebab", + "description": "Optional local alias for the imported namespace" + } + }, + "required": [ + "location", + "import" + ], + "additionalProperties": false + }, "ExternalRef": { "oneOf": [ { @@ -683,7 +730,14 @@ "type": "array", "description": "Consumed client adapters", "items": { - "$ref": "#/$defs/ConsumesHttp" + "oneOf": [ + { + "$ref": "#/$defs/ConsumesHttp" + }, + { + "$ref": "#/$defs/ImportedConsumesHttp" + } + ] }, "minItems": 1 } @@ -1027,7 +1081,9 @@ ], "oneOf": [ { - "required": ["call"], + "required": [ + "call" + ], "type": "object", "properties": { "outputParameters": { @@ -1037,10 +1093,16 @@ } } }, - "not": { "required": ["location"] } + "not": { + "required": [ + "location" + ] + } }, { - "required": ["steps"], + "required": [ + "steps" + ], "type": "object", "properties": { "mappings": true, @@ -1051,14 +1113,28 @@ } } }, - "not": { "required": ["location"] } + "not": { + "required": [ + "location" + ] + } }, { - "required": ["location"], + "required": [ + "location" + ], "not": { "anyOf": [ - { "required": ["call"] }, - { "required": ["steps"] } + { + "required": [ + "call" + ] + }, + { + "required": [ + "steps" + ] + } ] } } @@ -1111,12 +1187,24 @@ ], "oneOf": [ { - "required": ["template"], - "not": { "required": ["location"] } + "required": [ + "template" + ], + "not": { + "required": [ + "location" + ] + } }, { - "required": ["location"], - "not": { "required": ["template"] } + "required": [ + "location" + ], + "not": { + "required": [ + "template" + ] + } } ], "additionalProperties": false @@ -1155,7 +1243,10 @@ "properties": { "role": { "type": "string", - "enum": ["user", "assistant"], + "enum": [ + "user", + "assistant" + ], "description": "The role of the message sender." }, "content": { @@ -1163,7 +1254,10 @@ "description": "The message content. Supports {{arg}} placeholders for argument substitution." } }, - "required": ["role", "content"], + "required": [ + "role", + "content" + ], "additionalProperties": false }, "ExposedResource": { @@ -2027,4 +2121,4 @@ "additionalProperties": false } } -} \ No newline at end of file +} diff --git a/src/main/resources/schemas/tutorial/step1-hello-world.yml b/src/main/resources/schemas/tutorial/step1-hello-world.yml index 5a1a9fa..08d5383 100644 --- a/src/main/resources/schemas/tutorial/step1-hello-world.yml +++ b/src/main/resources/schemas/tutorial/step1-hello-world.yml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=../capability-schema.json +# yaml-language-server: $schema=../naftiko-schema.json --- naftiko: "0.5" info: diff --git a/src/main/resources/schemas/tutorial/step2-forward.yml b/src/main/resources/schemas/tutorial/step2-forward.yml index 00a1780..e8467f8 100644 --- a/src/main/resources/schemas/tutorial/step2-forward.yml +++ b/src/main/resources/schemas/tutorial/step2-forward.yml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=../capability-schema.json +# yaml-language-server: $schema=../naftiko-schema.json --- naftiko: "0.5" info: diff --git a/src/main/resources/schemas/tutorial/step3a-encapsulate.yml b/src/main/resources/schemas/tutorial/step3a-encapsulate.yml index 2f91259..31c7e4b 100644 --- a/src/main/resources/schemas/tutorial/step3a-encapsulate.yml +++ b/src/main/resources/schemas/tutorial/step3a-encapsulate.yml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=../capability-schema.json +# yaml-language-server: $schema=../naftiko-schema.json --- naftiko: "0.5" info: diff --git a/src/main/resources/schemas/tutorial/step3b-encapsulate.yml b/src/main/resources/schemas/tutorial/step3b-encapsulate.yml index c732dcb..556458d 100644 --- a/src/main/resources/schemas/tutorial/step3b-encapsulate.yml +++ b/src/main/resources/schemas/tutorial/step3b-encapsulate.yml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=../capability-schema.json +# yaml-language-server: $schema=../naftiko-schema.json --- naftiko: "0.5" info: diff --git a/src/main/resources/schemas/tutorial/step3c-encapsulate.yml b/src/main/resources/schemas/tutorial/step3c-encapsulate.yml index 8214a3a..c8f0883 100644 --- a/src/main/resources/schemas/tutorial/step3c-encapsulate.yml +++ b/src/main/resources/schemas/tutorial/step3c-encapsulate.yml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=../capability-schema.json +# yaml-language-server: $schema=../naftiko-schema.json --- naftiko: "0.5" info: diff --git a/src/main/resources/schemas/tutorial/step4a-structure.yml b/src/main/resources/schemas/tutorial/step4a-structure.yml index ea740a1..a9cfbbc 100644 --- a/src/main/resources/schemas/tutorial/step4a-structure.yml +++ b/src/main/resources/schemas/tutorial/step4a-structure.yml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=../capability-schema.json +# yaml-language-server: $schema=../naftiko-schema.json --- naftiko: "0.5" info: diff --git a/src/main/resources/schemas/tutorial/step4b-structure.yml b/src/main/resources/schemas/tutorial/step4b-structure.yml index 19cf624..3700f68 100644 --- a/src/main/resources/schemas/tutorial/step4b-structure.yml +++ b/src/main/resources/schemas/tutorial/step4b-structure.yml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=../capability-schema.json +# yaml-language-server: $schema=../naftiko-schema.json --- naftiko: "0.5" diff --git a/src/main/resources/schemas/tutorial/step5a-auth-bearer.yml b/src/main/resources/schemas/tutorial/step5a-auth-bearer.yml index bb84130..20f97c2 100644 --- a/src/main/resources/schemas/tutorial/step5a-auth-bearer.yml +++ b/src/main/resources/schemas/tutorial/step5a-auth-bearer.yml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=../capability-schema.json +# yaml-language-server: $schema=../naftiko-schema.json --- naftiko: "0.5" info: diff --git a/src/main/resources/schemas/tutorial/step5b-auth-apikey.yml b/src/main/resources/schemas/tutorial/step5b-auth-apikey.yml index e9ae285..73328dc 100644 --- a/src/main/resources/schemas/tutorial/step5b-auth-apikey.yml +++ b/src/main/resources/schemas/tutorial/step5b-auth-apikey.yml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=../capability-schema.json +# yaml-language-server: $schema=../naftiko-schema.json --- naftiko: "0.5" info: diff --git a/src/test/java/io/naftiko/engine/CapabilityImportIntegrationTest.java b/src/test/java/io/naftiko/engine/CapabilityImportIntegrationTest.java new file mode 100644 index 0000000..572c6e4 --- /dev/null +++ b/src/test/java/io/naftiko/engine/CapabilityImportIntegrationTest.java @@ -0,0 +1,251 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import io.naftiko.Capability; +import io.naftiko.spec.NaftikoSpec; +import io.naftiko.engine.consumes.ClientAdapter; + +/** + * Integration tests for consumes imports in full capability loading. + * Tests end-to-end loading, validation, and operation calls with imported adapters. + */ +public class CapabilityImportIntegrationTest { + + private Path tempDir; + private Capability capability; + + @BeforeEach + public void setUp() throws IOException { + tempDir = Files.createTempDirectory("naftiko-cap-test-"); + } + + @AfterEach + public void tearDown() throws Exception { + if (capability != null) { + capability.stop(); + } + Files.walk(tempDir) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + // Ignore + } + }); + } + + @Test + public void testCapabilityLoadsWithImportedConsumes() throws Exception { + // Create source consumes file + String sourceConsumesYaml = """ + naftiko: "0.5" + consumes: + - type: "http" + namespace: "json-api" + baseUri: "https://api.example.com" + resources: [] + """; + + Files.writeString(tempDir.resolve("json-api.consumes.yml"), sourceConsumesYaml); + + // Create capability with import + String capabilityYaml = """ + naftiko: "0.5" + info: + label: "Test Capability" + description: "Tests imported adapters" + capability: + consumes: + - location: "./json-api.consumes.yml" + import: "json-api" + exposes: + - type: "api" + address: "localhost" + port: 9999 + namespace: "proxy" + resources: [] + """; + + Path capPath = tempDir.resolve("test-capability.yml"); + Files.writeString(capPath, capabilityYaml); + + // Load capability + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + NaftikoSpec spec = mapper.readValue(capPath.toFile(), NaftikoSpec.class); + + capability = new Capability(spec, tempDir.toString()); + + // Verify consumes are loaded + assertEquals(1, capability.getClientAdapters().size()); + ClientAdapter adapter = capability.getClientAdapters().get(0); + assertEquals("json-api", adapter.getSpec().getNamespace()); + } + + @Test + public void testCapabilityWithMixedConsumes() throws Exception { + // Create source consumes + String sourceConsumesYaml = """ + naftiko: "0.5" + consumes: + - type: "http" + namespace: "external-api" + baseUri: "https://external.example.com" + resources: [] + """; + + Files.writeString(tempDir.resolve("external.consumes.yml"), sourceConsumesYaml); + + // Create capability with both inline and imported consumes + String capabilityYaml = """ + naftiko: "0.5" + info: + label: "Mixed Capability" + description: "Tests both inline and imported consumes" + capability: + consumes: + - type: "http" + namespace: "local-api" + baseUri: "https://local.example.com" + resources: [] + - location: "./external.consumes.yml" + import: "external-api" + exposes: + - type: "api" + address: "localhost" + port: 9998 + namespace: "proxy" + resources: [] + """; + + Path capPath = tempDir.resolve("mixed-capability.yml"); + Files.writeString(capPath, capabilityYaml); + + // Load capability + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + NaftikoSpec spec = mapper.readValue(capPath.toFile(), NaftikoSpec.class); + + capability = new Capability(spec, tempDir.toString()); + + // Verify both consumes are loaded + assertEquals(2, capability.getClientAdapters().size()); + + ClientAdapter adapter1 = capability.getClientAdapters().get(0); + assertEquals("local-api", adapter1.getSpec().getNamespace()); + + ClientAdapter adapter2 = capability.getClientAdapters().get(1); + assertEquals("external-api", adapter2.getSpec().getNamespace()); + } + + @Test + public void testCapabilityImportWithAlias() throws Exception { + // Create source consumes + String sourceConsumesYaml = """ + naftiko: "0.5" + consumes: + - type: "http" + namespace: "api" + baseUri: "https://api.example.com" + resources: [] + """; + + Files.writeString(tempDir.resolve("api.consumes.yml"), sourceConsumesYaml); + + // Create capability with import using alias + String capabilityYaml = """ + naftiko: "0.5" + info: + label: "Aliased Capability" + description: "Tests import with alias" + capability: + consumes: + - location: "./api.consumes.yml" + import: "api" + as: "my-api" + exposes: + - type: "api" + address: "localhost" + port: 9997 + namespace: "proxy" + resources: [] + """; + + Path capPath = tempDir.resolve("aliased-capability.yml"); + Files.writeString(capPath, capabilityYaml); + + // Load capability + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + NaftikoSpec spec = mapper.readValue(capPath.toFile(), NaftikoSpec.class); + + capability = new Capability(spec, tempDir.toString()); + + // Verify alias is applied + assertEquals(1, capability.getClientAdapters().size()); + ClientAdapter adapter = capability.getClientAdapters().get(0); + assertEquals("my-api", adapter.getSpec().getNamespace()); + } + + @Test + public void testCapabilityImportFileNotFound() throws Exception { + // Create capability with non-existent import + String capabilityYaml = """ + naftiko: "0.5" + info: + label: "Bad Capability" + description: "Bad import path" + capability: + consumes: + - location: "./nonexistent.consumes.yml" + import: "api" + exposes: + - type: "api" + address: "localhost" + port: 9996 + namespace: "proxy" + resources: [] + """; + + Path capPath = tempDir.resolve("bad-capability.yml"); + Files.writeString(capPath, capabilityYaml); + + // Load capability + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + NaftikoSpec spec = mapper.readValue(capPath.toFile(), NaftikoSpec.class); + + // Should throw IOException due to missing file + assertThrows(IOException.class, () -> { + new Capability(spec, tempDir.toString()); + }); + } +} diff --git a/src/test/java/io/naftiko/engine/ConsumesImportResolverTest.java b/src/test/java/io/naftiko/engine/ConsumesImportResolverTest.java new file mode 100644 index 0000000..9e77a3b --- /dev/null +++ b/src/test/java/io/naftiko/engine/ConsumesImportResolverTest.java @@ -0,0 +1,244 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.naftiko.spec.consumes.ClientSpec; +import io.naftiko.spec.consumes.HttpClientSpec; +import io.naftiko.spec.consumes.ImportedConsumesHttpSpec; + +/** + * Unit tests for ConsumesImportResolver. + * Tests import loading, namespace resolution, and alias handling. + */ +public class ConsumesImportResolverTest { + + private ConsumesImportResolver resolver; + private Path tempDir; + + @BeforeEach + public void setUp() throws IOException { + resolver = new ConsumesImportResolver(); + tempDir = Files.createTempDirectory("naftiko-import-test-"); + } + + @AfterEach + public void tearDown() throws IOException { + // Clean up temp files + Files.walk(tempDir) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + // Ignore + } + }); + } + + @Test + public void testResolveSimpleImport() throws Exception { + // Create source consumes file + String sourceConsumesYaml = """ + naftiko: "0.5" + consumes: + - type: "http" + namespace: "notion" + baseUri: "https://api.notion.com" + resources: [] + """; + + Path sourcePath = tempDir.resolve("notion-consumes.yml"); + Files.writeString(sourcePath, sourceConsumesYaml); + + // Create capability consumes with import + List consumes = new ArrayList<>(); + consumes.add(new ImportedConsumesHttpSpec( + "./notion-consumes.yml", + "notion", + null // no alias + )); + + // Resolve + resolver.resolveImports(consumes, tempDir.toString()); + + // Verify + assertEquals(1, consumes.size()); + assertTrue(consumes.get(0) instanceof HttpClientSpec); + + HttpClientSpec resolved = (HttpClientSpec) consumes.get(0); + assertEquals("notion", resolved.getNamespace()); + assertEquals("https://api.notion.com", resolved.getBaseUri()); + } + + @Test + public void testResolveImportWithAlias() throws Exception { + // Create two source consumes with same namespace + String sourceEn = """ + naftiko: "0.5" + consumes: + - type: "http" + namespace: "hello-world" + baseUri: "http://localhost:8080" + resources: [] + """; + + String sourceFr = """ + naftiko: "0.5" + consumes: + - type: "http" + namespace: "hello-world" + baseUri: "http://localhost:8081" + resources: [] + """; + + Files.writeString(tempDir.resolve("hello-en.yml"), sourceEn); + Files.writeString(tempDir.resolve("hello-fr.yml"), sourceFr); + + // Create imports with aliases + List consumes = new ArrayList<>(); + consumes.add(new ImportedConsumesHttpSpec("./hello-en.yml", "hello-world", "hello-en")); + consumes.add(new ImportedConsumesHttpSpec("./hello-fr.yml", "hello-world", "hello-fr")); + + resolver.resolveImports(consumes, tempDir.toString()); + + // Verify + assertEquals(2, consumes.size()); + assertEquals("hello-en", ((HttpClientSpec) consumes.get(0)).getNamespace()); + assertEquals("hello-fr", ((HttpClientSpec) consumes.get(1)).getNamespace()); + } + + @Test + public void testResolveImportFileNotFound() { + List consumes = new ArrayList<>(); + consumes.add(new ImportedConsumesHttpSpec("./nonexistent.yml", "api", null)); + + assertThrows(IOException.class, () -> { + resolver.resolveImports(consumes, tempDir.toString()); + }); + } + + @Test + public void testResolveImportNamespaceNotFound() throws Exception { + String sourceYaml = """ + naftiko: "0.5" + consumes: + - type: "http" + namespace: "api-v1" + baseUri: "http://api.example.com" + resources: [] + """; + + Files.writeString(tempDir.resolve("api.yml"), sourceYaml); + + List consumes = new ArrayList<>(); + // Try to import "api-v2" which doesn't exist + consumes.add(new ImportedConsumesHttpSpec("./api.yml", "api-v2", null)); + + IOException ex = assertThrows(IOException.class, () -> { + resolver.resolveImports(consumes, tempDir.toString()); + }); + + assertTrue(ex.getMessage().contains("not found")); + } + + @Test + public void testMixedImportsAndInlineConsumes() throws Exception { + String sourceYaml = """ + naftiko: "0.5" + consumes: + - type: "http" + namespace: "external-api" + baseUri: "http://external.com" + resources: [] + """; + + Files.writeString(tempDir.resolve("external.yml"), sourceYaml); + + List consumes = new ArrayList<>(); + + // Inline consumes + HttpClientSpec inline = new HttpClientSpec(); + inline.setNamespace("local-api"); + inline.setBaseUri("http://localhost:9999"); + consumes.add(inline); + + // Imported consumes + consumes.add(new ImportedConsumesHttpSpec("./external.yml", "external-api", null)); + + resolver.resolveImports(consumes, tempDir.toString()); + + // Both should be HttpClientSpec, in same order + assertEquals(2, consumes.size()); + assertTrue(consumes.get(0) instanceof HttpClientSpec); + assertTrue(consumes.get(1) instanceof HttpClientSpec); + assertEquals("local-api", ((HttpClientSpec) consumes.get(0)).getNamespace()); + assertEquals("external-api", ((HttpClientSpec) consumes.get(1)).getNamespace()); + } + + @Test + public void testRelativePathResolution() throws Exception { + // Create nested directory structure + Path subdir = tempDir.resolve("adapters"); + Files.createDirectory(subdir); + + String sourceYaml = """ + naftiko: "0.5" + consumes: + - type: "http" + namespace: "nested" + baseUri: "http://example.com" + resources: [] + """; + + Files.writeString(subdir.resolve("nested.yml"), sourceYaml); + + // Create capability in parent with relative path + List consumes = new ArrayList<>(); + consumes.add(new ImportedConsumesHttpSpec("./adapters/nested.yml", "nested", null)); + + resolver.resolveImports(consumes, tempDir.toString()); + + assertEquals(1, consumes.size()); + assertEquals("nested", ((HttpClientSpec) consumes.get(0)).getNamespace()); + } + + @Test + public void testEmptyConsumesListIsNoop() throws Exception { + List consumes = new ArrayList<>(); + + // Should not throw + resolver.resolveImports(consumes, tempDir.toString()); + + assertEquals(0, consumes.size()); + } + + @Test + public void testNullConsumesListIsNoop() throws Exception { + // Should not throw + resolver.resolveImports(null, tempDir.toString()); + } +} diff --git a/src/test/java/io/naftiko/spec/consumes/ClientSpecDeserializerTest.java b/src/test/java/io/naftiko/spec/consumes/ClientSpecDeserializerTest.java new file mode 100644 index 0000000..b0db1ca --- /dev/null +++ b/src/test/java/io/naftiko/spec/consumes/ClientSpecDeserializerTest.java @@ -0,0 +1,104 @@ +/** + * 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.consumes; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +/** + * Unit tests for ClientSpec deserialization. + * Tests discrimination between HttpClientSpec and ImportedConsumesHttpSpec. + */ +public class ClientSpecDeserializerTest { + + private ObjectMapper mapper; + + @BeforeEach + public void setUp() { + mapper = new ObjectMapper(new YAMLFactory()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + @Test + public void testDeserializeImportedConsumes() throws Exception { + String yaml = """ + type: "http" + location: "./api.yml" + import: "myapi" + as: "myapi-v1" + """; + + ClientSpec result = mapper.readValue(yaml, ClientSpec.class); + + assertTrue(result instanceof ImportedConsumesHttpSpec); + ImportedConsumesHttpSpec imported = (ImportedConsumesHttpSpec) result; + assertEquals("./api.yml", imported.getLocation()); + assertEquals("myapi", imported.getImportNamespace()); + assertEquals("myapi-v1", imported.getAlias()); + } + + @Test + public void testDeserializeHttpClientSpec() throws Exception { + String yaml = """ + type: "http" + namespace: "api" + baseUri: "https://api.example.com" + resources: [] + """; + + ClientSpec result = mapper.readValue(yaml, ClientSpec.class); + + assertTrue(result instanceof HttpClientSpec); + HttpClientSpec http = (HttpClientSpec) result; + assertEquals("api", http.getNamespace()); + assertEquals("https://api.example.com", http.getBaseUri()); + } + + @Test + public void testDeserializeImportWithoutAlias() throws Exception { + String yaml = """ + type: "http" + location: "./shared.yml" + import: "shared-api" + """; + + ClientSpec result = mapper.readValue(yaml, ClientSpec.class); + + assertTrue(result instanceof ImportedConsumesHttpSpec); + ImportedConsumesHttpSpec imported = (ImportedConsumesHttpSpec) result; + assertNull(imported.getAlias()); + } + + @Test + public void testDeserializeImportWithEmptyAlias() throws Exception { + String yaml = """ + type: "http" + location: "./shared.yml" + import: "shared-api" + as: "" + """; + + ClientSpec result = mapper.readValue(yaml, ClientSpec.class); + + assertTrue(result instanceof ImportedConsumesHttpSpec); + ImportedConsumesHttpSpec imported = (ImportedConsumesHttpSpec) result; + assertEquals("", imported.getAlias()); + } +}