diff --git a/ai/README.md b/ai/README.md new file mode 100644 index 000000000..2734ad945 --- /dev/null +++ b/ai/README.md @@ -0,0 +1,136 @@ +# AI Plugin for Apache OFBiz + +LangChain4j integration that exposes AI/LLM capabilities as standard OFBiz services. + +Apache JIRA: https://issues.apache.org/jira/browse/OFBIZ-13408 + +## What this plugin does + +The AI plugin connects OFBiz to any OpenAI-compatible chat model via LangChain4j 1.8.0. +It provides two callable OFBiz services — `aiGenerate` for free-text responses and +`aiGenerateStructured` for JSON-schema-constrained structured output — that any other +service or Groovy script can call without depending on LangChain4j directly. + +## Architecture + +| Layer | Class | Role | +|---|---|---| +| Container | `AiContainer` | Reads `ai.properties` at startup, builds the `ChatModel`, calls `AiFactory.setChatModel()` | +| Singleton | `AiFactory` | Holds the live `ChatModel` instance; throws `IllegalStateException` if not initialized | +| Utility | `AiWorker` | Static `generate()` and `generateStructured()` methods; handles message conversion and JSON schema mapping | +| Services | `AiServices` | Thin OFBiz service wrappers that delegate to `AiWorker` and return standard service result maps | + +## Prerequisites + +- Java 17 or later +- OFBiz trunk +- An API key from OpenAI or a compatible provider (Ollama, Groq, Together, Azure OpenAI) + +## Installation + +1. The plugin is already placed at `plugins/ai/` inside the OFBiz source tree. +2. Copy the properties template and fill in your values: + ``` + cp plugins/ai/config/ai.properties.template plugins/ai/config/ai.properties + ``` + If no template exists, create `plugins/ai/config/ai.properties` from the table below. +3. Start OFBiz normally. The container will log: + ``` + AI plugin initialized: provider=openai model=gpt-4o-mini + ``` + If the API key is missing or placeholder, startup continues but the plugin logs an error and skips initialization. + +## Configuration + +Edit `plugins/ai/config/ai.properties` (this file is gitignored — never commit API keys). + +| Property | Description | Default | +|---|---|---| +| `ai.provider` | Provider name. Currently used for logging; the `openai` engine handles all OpenAI-compatible endpoints. | `openai` | +| `ai.model` | Model name passed to the provider. | `gpt-4o-mini` | +| `ai.baseUrl` | Base URL override. Leave empty for the OpenAI default. Set for local or third-party endpoints. | _(empty)_ | +| `ai.apiKey` | Your API key. **Required.** | _(none)_ | +| `ai.timeout` | Request timeout in seconds. | `60` | + +## Multiple providers + +Setting `ai.baseUrl` makes the plugin work with any OpenAI-compatible endpoint: + +| Provider | `ai.baseUrl` | +|---|---| +| OpenAI (default) | _(leave empty)_ | +| Ollama | `http://localhost:11434` | +| Groq | `https://api.groq.com/openai/v1` | +| Together AI | `https://api.together.xyz/v1` | +| Azure OpenAI | your Azure endpoint URL | + +## Usage + +### Generate free-text (from a Groovy service) + +```groovy +import org.apache.ofbiz.ai.AiWorker + +def messages = [ + [role: "system", content: "You are a helpful assistant."], + [role: "user", content: "Summarize this order in one sentence."] +] + +String response = AiWorker.generate(dctx, messages) +``` + +### Generate structured output + +Schema values can be a plain type string (`"string"`, `"number"`, `"integer"`, `"boolean"`, +`"array"`, `"object"`) or a map with `type` and optional `properties`/`items` for nested shapes. + +```groovy +import org.apache.ofbiz.ai.AiWorker + +def messages = [ + [role: "user", content: "Extract the product name and price from: 'Widget Pro costs \$49.99'"] +] + +def schema = [ + productName: "string", + price: "number" +] + +Map result = AiWorker.generateStructured(dctx, messages, schema) +// result == [productName: "Widget Pro", price: 49.99] +``` + +Both methods throw `GeneralException` on failure; callers should catch it and return +`ServiceUtil.returnError()` as appropriate. + +## Available services + +| Service | IN | OUT | Description | +|---|---|---|---| +| `aiGenerate` | `messages` (List, required)
`configName` (String, optional) | `response` (String) | Free-text generation | +| `aiGenerateStructured` | `messages` (List, required)
`schema` (Map, required)
`configName` (String, optional) | `result` (Map) | Structured JSON output | + +Each message in the `messages` list is a `Map` with keys `role` (`system`, `user`, or `assistant`) and `content` (String). + +## Smoke test + +With OFBiz running and a valid API key configured, invoke `aiSmokeTest` from the +webtools service runner: + +``` +https://localhost:8443/webtools/control/main +→ Service Engine → Run Service → aiSmokeTest +``` + +A successful run logs: +``` +AI smoke test response: Hello +``` + +## Adding new providers + +To support a provider that is not OpenAI-compatible (e.g., Anthropic native, Amazon Bedrock), +add a new `case` to the `switch (provider)` block in `AiContainer.java`. Instantiate the +provider's LangChain4j `ChatModel` builder, call `AiFactory.setChatModel(chatModel)`, and +add the corresponding `dev.langchain4j:langchain4j-` dependency to `build.gradle`. +No changes to `AiFactory`, `AiWorker`, or `AiServices` are needed. diff --git a/ai/build.gradle b/ai/build.gradle new file mode 100644 index 000000000..91ae11591 --- /dev/null +++ b/ai/build.gradle @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +dependencies { + pluginLibsCompile 'dev.langchain4j:langchain4j:1.8.0' + pluginLibsCompile 'dev.langchain4j:langchain4j-anthropic:1.8.0' + pluginLibsCompile 'dev.langchain4j:langchain4j-ollama:1.8.0' + pluginLibsCompile 'dev.langchain4j:langchain4j-open-ai:1.8.0' +} diff --git a/ai/config/ai.properties b/ai/config/ai.properties new file mode 100644 index 000000000..bcdfb2598 --- /dev/null +++ b/ai/config/ai.properties @@ -0,0 +1,38 @@ +# AI Plugin configuration +# This file is gitignored — never commit API keys. +# +# Supported providers: openai | anthropic | ollama +# To switch provider: uncomment the desired block, comment out the others. + +# ------------------------------------------------- +# Provider: openai +# Models: gpt-4o, gpt-4o-mini, gpt-4-turbo, o1-mini +# Docs: https://platform.openai.com/docs/models +# ------------------------------------------------- +ai.provider=openai +ai.model=gpt-4o-mini +ai.apiKey=REPLACE_WITH_YOUR_API_KEY +ai.baseUrl= +ai.timeout=60 + +# ------------------------------------------------- +# Provider: anthropic +# Models: claude-opus-4-5, claude-sonnet-4-5, claude-3-5-haiku-20241022 +# Docs: https://docs.anthropic.com/en/docs/about-claude/models +# ------------------------------------------------- +#ai.provider=anthropic +#ai.model=claude-3-5-haiku-20241022 +#ai.apiKey=REPLACE_WITH_YOUR_API_KEY +#ai.baseUrl= +#ai.timeout=60 + +# ------------------------------------------------- +# Provider: ollama (local, no API key required) +# Models: llama3.2, llama3.1, mistral, gemma3 +# Docs: https://ollama.com/library +# ------------------------------------------------- +#ai.provider=ollama +#ai.model=llama3.2 +#ai.apiKey= +#ai.baseUrl=http://localhost:11434 +#ai.timeout=120 diff --git a/ai/groovyScripts/AiStructuredTest.groovy b/ai/groovyScripts/AiStructuredTest.groovy new file mode 100644 index 000000000..2c4cc6d53 --- /dev/null +++ b/ai/groovyScripts/AiStructuredTest.groovy @@ -0,0 +1,25 @@ +import org.apache.ofbiz.base.util.Debug +import org.apache.ofbiz.ai.AiWorker + +final String MODULE = 'AiStructuredTest.groovy' + +def messages = [ + [role: "user", content: "Return a greeting with a single word."] +] + +def schema = [ + word: "string" +] + +try { + Map result = AiWorker.generateStructured(dctx, messages, schema) + if (!result || !result.containsKey("word")) { + Debug.logError("AI structured smoke test failed: response missing 'word' key. Got: " + result, MODULE) + return error("AI structured smoke test failed: missing 'word' key in response") + } + Debug.logInfo("AI structured smoke test response: " + result, MODULE) + return success("AI structured smoke test passed: " + result) +} catch (Exception e) { + Debug.logError(e, "AI structured smoke test failed", MODULE) + return error("AI structured smoke test failed: " + e.getMessage()) +} diff --git a/ai/groovyScripts/AiTest.groovy b/ai/groovyScripts/AiTest.groovy new file mode 100644 index 000000000..9a8597b40 --- /dev/null +++ b/ai/groovyScripts/AiTest.groovy @@ -0,0 +1,17 @@ +import org.apache.ofbiz.base.util.Debug +import org.apache.ofbiz.ai.AiWorker + +final String MODULE = 'AiTest.groovy' + +def messages = [ + [role: "user", content: "Say hello in one word."] +] + +try { + String response = AiWorker.generate(dctx, messages) + Debug.logInfo("AI smoke test response: " + response, MODULE) + return success("AI smoke test passed: " + response) +} catch (Exception e) { + Debug.logError(e, "AI smoke test failed", MODULE) + return error("AI smoke test failed: " + e.getMessage()) +} diff --git a/ai/ofbiz-component.xml b/ai/ofbiz-component.xml new file mode 100644 index 000000000..6f4a3539d --- /dev/null +++ b/ai/ofbiz-component.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/ai/servicedef/services.xml b/ai/servicedef/services.xml new file mode 100644 index 000000000..f98c7e374 --- /dev/null +++ b/ai/servicedef/services.xml @@ -0,0 +1,51 @@ + + + + + + Generate a text response from an AI model + + + + + + + Generate a structured Map response from an AI model + + + + + + + + Smoke test for the AI plugin — calls aiGenerate with a test message + + + + Smoke test for generateStructured — expects Map with word key + + + diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiServices.java b/ai/src/main/java/org/apache/ofbiz/ai/AiServices.java new file mode 100644 index 000000000..47fb8207d --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiServices.java @@ -0,0 +1,60 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.ofbiz.ai; + +import java.util.List; +import java.util.Map; + +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilGenerics; +import org.apache.ofbiz.service.DispatchContext; +import org.apache.ofbiz.service.ServiceUtil; + +public class AiServices { + + private static final String MODULE = AiServices.class.getName(); + + public static Map generate(DispatchContext dctx, Map context) { + List> messages = UtilGenerics.cast(context.get("messages")); + try { + String response = AiWorker.generate(dctx, messages); + Map result = ServiceUtil.returnSuccess(); + result.put("response", response); + return result; + } catch (GeneralException e) { + Debug.logError(e, e.getMessage(), MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } + + public static Map generateStructured(DispatchContext dctx, Map context) { + List> messages = UtilGenerics.cast(context.get("messages")); + Map schema = UtilGenerics.cast(context.get("schema")); + try { + Map aiResult = AiWorker.generateStructured(dctx, messages, schema); + Map result = ServiceUtil.returnSuccess(); + result.put("result", aiResult); + return result; + } catch (GeneralException e) { + Debug.logError(e, e.getMessage(), MODULE); + return ServiceUtil.returnError(e.getMessage()); + } + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java b/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java new file mode 100644 index 000000000..09c863272 --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/AiWorker.java @@ -0,0 +1,156 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.ofbiz.ai; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.chat.request.ResponseFormatType; +import dev.langchain4j.model.chat.request.json.JsonArraySchema; +import dev.langchain4j.model.chat.request.json.JsonBooleanSchema; +import dev.langchain4j.model.chat.request.json.JsonIntegerSchema; +import dev.langchain4j.model.chat.request.json.JsonNumberSchema; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.model.chat.request.json.JsonSchemaElement; +import dev.langchain4j.model.chat.request.json.JsonStringSchema; + +import org.apache.ofbiz.ai.container.AiContainer; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.GeneralException; +import org.apache.ofbiz.base.util.UtilGenerics; +import org.apache.ofbiz.service.DispatchContext; + +public final class AiWorker { + + private static final String MODULE = AiWorker.class.getName(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Map> TYPE_BUILDERS = new HashMap<>(); + static { + TYPE_BUILDERS.put("string", JsonStringSchema::new); + TYPE_BUILDERS.put("number", JsonNumberSchema::new); + TYPE_BUILDERS.put("integer", JsonIntegerSchema::new); + TYPE_BUILDERS.put("boolean", JsonBooleanSchema::new); + } + + private AiWorker() { } + + public static String generate(DispatchContext dctx, + List> messages) throws GeneralException { + var chatModel = AiContainer.getChatModel(); + if (chatModel == null) { + return "AI service is not available. Check ai.properties configuration."; + } + try { + List chatMessages = toChatMessages(messages); + var request = ChatRequest.builder().messages(chatMessages).build(); + var response = chatModel.chat(request); + return response.aiMessage().text(); + } catch (Exception e) { + Debug.logError(e, "AI generate failed", MODULE); + throw new GeneralException("AI generate failed: " + e.getMessage(), e); + } + } + + public static Map generateStructured(DispatchContext dctx, + List> messages, + Map schema) throws GeneralException { + var chatModel = AiContainer.getChatModel(); + if (chatModel == null) { + throw new GeneralException( + "AI service is not available. Check ai.properties configuration."); + } + try { + List chatMessages = toChatMessages(messages); + JsonObjectSchema jsonObjectSchema = buildJsonObjectSchema(schema); + JsonSchema jsonSchema = JsonSchema.builder() + .name("response").rootElement(jsonObjectSchema).build(); + ResponseFormat responseFormat = ResponseFormat.builder() + .type(ResponseFormatType.JSON).jsonSchema(jsonSchema).build(); + var request = ChatRequest.builder() + .messages(chatMessages).responseFormat(responseFormat).build(); + var response = chatModel.chat(request); + return OBJECT_MAPPER.readValue(response.aiMessage().text(), + new TypeReference>() { }); + } catch (Exception e) { + Debug.logError(e, "AI generateStructured failed", MODULE); + throw new GeneralException("AI generateStructured failed: " + e.getMessage(), e); + } + } + + private static List toChatMessages(List> messages) { + List chatMessages = new ArrayList<>(); + for (Map msg : messages) { + String role = (String) msg.get("role"); + String content = (String) msg.get("content"); + if ("system".equals(role)) { + chatMessages.add(SystemMessage.from(content)); + } else if ("assistant".equals(role)) { + chatMessages.add(AiMessage.from(content)); + } else { + chatMessages.add(UserMessage.from(content)); + } + } + return chatMessages; + } + + private static JsonObjectSchema buildJsonObjectSchema(Map schemaMap) { + JsonObjectSchema.Builder builder = JsonObjectSchema.builder(); + for (Map.Entry entry : schemaMap.entrySet()) { + builder.addProperty(entry.getKey(), buildSchemaElement(entry.getValue())); + } + return builder.build(); + } + + private static JsonSchemaElement buildSchemaElement(Object descriptor) { + if (descriptor instanceof String type) { + if ("array".equals(type)) return JsonArraySchema.builder().build(); + if ("object".equals(type)) return JsonObjectSchema.builder().build(); + return TYPE_BUILDERS.getOrDefault(type, JsonStringSchema::new).get(); + } + if (descriptor instanceof Map) { + Map descMap = UtilGenerics.cast(descriptor); + String type = (String) descMap.get("type"); + if ("array".equals(type)) { + JsonArraySchema.Builder ab = JsonArraySchema.builder(); + if (descMap.containsKey("items")) ab.items(buildSchemaElement(descMap.get("items"))); + return ab.build(); + } + if ("object".equals(type)) { + Object props = descMap.get("properties"); + if (props instanceof Map) return buildJsonObjectSchema(UtilGenerics.cast(props)); + return JsonObjectSchema.builder().build(); + } + if (type != null) return TYPE_BUILDERS.getOrDefault(type, JsonStringSchema::new).get(); + } + return new JsonStringSchema(); + } +} diff --git a/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java b/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java new file mode 100644 index 000000000..c2155d48e --- /dev/null +++ b/ai/src/main/java/org/apache/ofbiz/ai/container/AiContainer.java @@ -0,0 +1,129 @@ +/******************************************************************************* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.ofbiz.ai.container; + +import java.time.Duration; +import java.util.List; + +import dev.langchain4j.model.anthropic.AnthropicChatModel; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.ollama.OllamaChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; + +import org.apache.ofbiz.base.container.Container; +import org.apache.ofbiz.base.container.ContainerException; +import org.apache.ofbiz.base.start.StartupCommand; +import org.apache.ofbiz.base.util.Debug; +import org.apache.ofbiz.base.util.UtilProperties; +import org.apache.ofbiz.base.util.UtilValidate; + +public class AiContainer implements Container { + + private static final String MODULE = AiContainer.class.getName(); + + private static ChatModel chatModel; + + private String name; + private String configFile; + + @Override + public void init(List ofbizCommands, String name, String configFile) throws ContainerException { + this.name = name; + this.configFile = configFile; + } + + @Override + public boolean start() throws ContainerException { + String provider = UtilProperties.getPropertyValue("ai", "ai.provider", "openai"); + String model = UtilProperties.getPropertyValue("ai", "ai.model", "gpt-4o-mini"); + String apiKey = UtilProperties.getPropertyValue("ai", "ai.apiKey"); + String baseUrl = UtilProperties.getPropertyValue("ai", "ai.baseUrl", ""); + int timeoutSecs; + try { + timeoutSecs = Integer.parseInt( + UtilProperties.getPropertyValue("ai", "ai.timeout", "60")); + } catch (NumberFormatException e) { + timeoutSecs = 60; + } + + ChatModel chatModel = buildChatModel(provider, model, apiKey, baseUrl, timeoutSecs); + if (chatModel == null) { + Debug.logWarning("AI plugin disabled - check ai.properties", MODULE); + return true; + } + AiContainer.chatModel = chatModel; + Debug.logInfo("AI plugin initialized: provider=" + provider + " model=" + model, MODULE); + return true; + } + + private static ChatModel buildChatModel(String provider, String model, String apiKey, + String baseUrl, int timeoutSecs) throws ContainerException { + if ("anthropic".equals(provider)) { + if (UtilValidate.isEmpty(apiKey) || "REPLACE_WITH_YOUR_API_KEY".equals(apiKey)) { + Debug.logWarning("AI plugin: ai.apiKey is required for provider 'anthropic'", MODULE); + return null; + } + var builder = AnthropicChatModel.builder() + .apiKey(apiKey) + .modelName(model) + .timeout(Duration.ofSeconds(timeoutSecs)); + if (UtilValidate.isNotEmpty(baseUrl)) { + builder.baseUrl(baseUrl); + } + return builder.build(); + } else if ("ollama".equals(provider)) { + return OllamaChatModel.builder() + .baseUrl(UtilValidate.isNotEmpty(baseUrl) ? baseUrl : "http://localhost:11434") + .modelName(model) + .timeout(Duration.ofSeconds(timeoutSecs)) + .build(); + } else if ("openai".equals(provider)) { + if (UtilValidate.isEmpty(apiKey) || "REPLACE_WITH_YOUR_API_KEY".equals(apiKey)) { + Debug.logWarning("AI plugin: ai.apiKey is required for provider 'openai'", MODULE); + return null; + } + var builder = OpenAiChatModel.builder() + .apiKey(apiKey) + .modelName(model) + .timeout(Duration.ofSeconds(timeoutSecs)); + if (UtilValidate.isNotEmpty(baseUrl)) { + builder.baseUrl(baseUrl); + } + return builder.build(); + } else { + Debug.logWarning("AI plugin: unsupported provider '" + provider + + "'. Supported providers: openai, anthropic, ollama", MODULE); + return null; + } + } + + @Override + public void stop() throws ContainerException { + chatModel = null; + } + + @Override + public String getName() { + return name; + } + + public static ChatModel getChatModel() { + return chatModel; + } +} diff --git a/example/servicedef/services.xml b/example/servicedef/services.xml index fcd99a1ff..66227d0c8 100644 --- a/example/servicedef/services.xml +++ b/example/servicedef/services.xml @@ -65,7 +65,7 @@ under the License. - Create a ExampleItem + Create an ExampleItem @@ -73,52 +73,52 @@ under the License. - Update a ExampleItem + Update an ExampleItem - Delete a ExampleItem + Delete an ExampleItem - Create a ExampleFeature + Create an ExampleFeature - Update a ExampleFeature + Update an ExampleFeature - Delete a ExampleFeature + Delete an ExampleFeature - Create a ExampleFeatureAppl + Create an ExampleFeatureAppl - Update a ExampleFeatureAppl + Update an ExampleFeatureAppl - Delete a ExampleFeatureAppl + Delete an ExampleFeatureAppl @@ -156,17 +156,17 @@ under the License. - Create a ExampleFeatureApplType + Create an ExampleFeatureApplType - Update a ExampleFeatureApplType + Update an ExampleFeatureApplType - Delete a ExampleFeatureApplType + Delete an ExampleFeatureApplType