From 127d38aef90761784e7adbb08755fe5d6b83e3f3 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 7 Sep 2026 15:41:07 +0200 Subject: [PATCH 1/2] feat: upgrade dependencies --- .../cxdevtools/cxdevproxy/external-dependencies.xml | 6 +++--- core-customize/manifest.json | 9 +++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml index 1efe656..201b728 100644 --- a/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevproxy/external-dependencies.xml @@ -7,15 +7,15 @@ jar - 2.16.0 - 25.0.3-snapshot + 2.20.0 + 25.3.4.1 io.undertow undertow-core - 2.3.23.Final + 2.4.3.Final diff --git a/core-customize/manifest.json b/core-customize/manifest.json index e6abd28..68e7754 100644 --- a/core-customize/manifest.json +++ b/core-customize/manifest.json @@ -1,6 +1,11 @@ { - "commerceSuiteVersion": "2211-jdk21.7", - "extensionPacks": [], + "commerceSuiteVersion": "2211-jdk21.17", + "extensionPacks": [ + { + "name": "hybris-commerce-integrations", + "version": "2211-jdk21.13" + } + ], "solr": { "location": "solr", "solrVersion": "9.10" From 5b9e5eec494ad5bf08d96e6bd979b153a37591a7 Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Mon, 7 Sep 2026 20:25:30 +0200 Subject: [PATCH 2/2] feat(cxdevcpi): typed outbound interfaces --- .../bin/custom/cxdevtools/cxdevcpi/.gitignore | 46 ++++ .../bin/custom/cxdevtools/cxdevcpi/LICENSE.md | 201 +++++++++++++++ .../bin/custom/cxdevtools/cxdevcpi/README.md | 153 +++++++++++ .../cxdevtools/cxdevcpi/extensioninfo.xml | 9 + .../cxdevtools/cxdevcpi/project.properties | 6 + .../cxdevcpi/resources/cxdevcpi-beans.xml | 4 + .../cxdevcpi/resources/cxdevcpi-spring.xml | 7 + .../cxdevcpi/generic/scpi-outbound-spring.xml | 41 +++ .../cxdevcpi-locales_de.properties | 4 + .../cxdevcpi-locales_en.properties | 4 + .../cxdev/cpi/constants/CxCpiConstants.java | 11 + .../AbstractScpiOutboundStrategy.java | 25 ++ .../RestTemplateScpiRemoteClient.java | 240 ++++++++++++++++++ .../remoteclient/ScpiRemoteClient.java | 122 +++++++++ .../remoteclient/ScpiRemoteClientFactory.java | 45 ++++ .../generic/CxCpiSpringConfigurationTest.java | 88 +++++++ .../RestTemplateScpiRemoteClientTest.java | 138 ++++++++++ .../ScpiRemoteClientFactoryTest.java | 87 +++++++ .../hybris/config/localextensions.xml | 1 + 19 files changed, 1232 insertions(+) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/.gitignore create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/LICENSE.md create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/README.md create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/extensioninfo.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/project.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi-beans.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi/generic/scpi-outbound-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/localization/cxdevcpi-locales_de.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/localization/cxdevcpi-locales_en.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/constants/CxCpiConstants.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/outbound/AbstractScpiOutboundStrategy.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/RestTemplateScpiRemoteClient.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClient.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClientFactory.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/CxCpiSpringConfigurationTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/remoteclient/RestTemplateScpiRemoteClientTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClientFactoryTest.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/.gitignore b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/.gitignore new file mode 100644 index 0000000..ca19572 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/.gitignore @@ -0,0 +1,46 @@ +# Folders to ignore, whatever the place they are +.DS_Store +.metadata +.settings +classes +testclasses +eclipsebin +gensrc + +# .classpath files and lib folder in cxdevtools to ignore +.classpath +lib/ + +# Filename patterns to ignore +*.class +build.xml +Generated*.java +platformhome.properties +*testclasses.xml +extensioninfo.xsd +*hmc.jar +hmc.xsd +items.xsd +beans.xsd +ruleset.xml +*.log +.pmd +*build.number +base.properties + +# Addon specific copy folders +**/_ui/addons +**/views/addons +**/tld/addons +**/tags/addons +**/messages/addons +**/lib/addons +**/web/addonsrc +**/_ui-src/addons +**/web/addontestsrc +**/web/commonwebsrc/*/ +**/web/webroot/WEB-INF/lib/addon-* +wro_addons.xml + +# Backoffice artifacts +*_bof.jar diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/LICENSE.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/LICENSE.md new file mode 100644 index 0000000..8676c10 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2025] [CX DEV Tools] + + 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. \ No newline at end of file diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/README.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/README.md new file mode 100644 index 0000000..d37093f --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/README.md @@ -0,0 +1,153 @@ +# CX DEV CPI + +The `cxdevcpi` extension provides typed outbound calls from SAP Commerce to SAP +Cloud Integration (CPI). It uses the SAP `IntegrationRestTemplateFactory` and +`ConsumedDestination` configuration, while accepting Java request DTOs and +returning typed response DTOs through Spring's Jackson message converter. + +## Scope + +The reusable API is in `me.cxdev.cpi.generic`: + +| Class | Purpose | +|---|---| +| `outbound.AbstractScpiOutboundStrategy` | Base class with an injected remote client factory and destination ID. | +| `remoteclient.ScpiRemoteClientFactory` | Resolves a destination by ID or accepts a `ConsumedDestinationModel`, then creates a client using SAP outbound services. | +| `remoteclient.ScpiRemoteClient` | REST operations without a URL argument, with typed responses and URI variables. | +| `remoteclient.RestTemplateScpiRemoteClient` | Delegates requests to the SAP-created `RestOperations` instance. | + +The extension contains no business strategies, request/response DTOs, inbound +hooks, item types, credentials, endpoint data or automatic ImpEx imports. +It has no dependency on another CX DEV extension or on a project extension. +The two `postForLocation` overloads with URI variables forward both the request +body and the variables; this corrects a delegation error in the source implementation. + +## Installation in a consuming project + +1. Copy this directory to `hybris/bin/custom/cxdevtools/cxdevcpi` in the target project. +2. Add `` to that project's `localextensions.xml` and + ensure the extension is within a configured extension search path. +3. Add `` to the consuming extension's + `extensioninfo.xml`. +4. Run the target project's normal SAP Commerce build and update procedure. + +`cxdevcpi.application-context=cxdevcpi-spring.xml` registers the Spring context +automatically. The direct dependencies are `apiregistryservices` and +`outboundservices`; the platform resolves their transitive dependencies. +No additional Maven libraries are required. + +## Configure outbound interfaces + +Create these standard SAP items in the consuming project's configuration, using +Backoffice or that project's ImpEx setup: + +| Item | Required configuration | +|---|---| +| `DestinationTarget` | A project-specific target ID. | +| `ConsumedOAuthCredential` or `BasicCredential` | Authentication for the CPI interface; for OAuth configure `clientId`, `clientSecret` and `oAuthUrl`. | +| `Endpoint` | An ID, version and the interface's URL/metadata. | +| `ConsumedDestination` | An ID, URL, endpoint, destination target, credential and `active=true`. | + +The strategy's `destinationId` is the **ConsumedDestination ID**, not the +Endpoint ID. Lookup by ID requires a unique matching destination. If IDs are +reused across destination targets, resolve the intended model in the consuming +project and call `clientForDestination(ConsumedDestinationModel)` instead. + +The optional destination additional property `urlQuery` is appended to the URL +with `?`. Store it without a leading `?` and keep the destination URL free of an +existing query. Both the URL and the query may contain Spring URI template +variables such as `{documentId}`; pass their values as a map or positional +arguments to the client operation. Null, empty or blank `urlQuery` values are +ignored. + +Credentials, hosts and interface paths belong to the consuming project's +environment configuration. The factory retains the original lookup behavior; +it does not add an `active` check or an endpoint-specific validation layer. + +## Implement consuming strategies + +Extend `me.cxdev.cpi.generic.outbound.AbstractScpiOutboundStrategy` in the +consuming extension. Define the concrete bean in that extension's Spring context: + +```xml + + + +``` + +The parent injects `scpiRemoteClientFactory`. The concrete strategy must supply +`destinationId`. Within its implementation, request a typed response: + +```java +ScpiRemoteClient client = getRemoteClientFactory().clientForDestination(getDestinationId()); +ResponseEntity response = client.postForEntity(requestDto, ResponseDto.class); +``` + +`RequestDto` and `ResponseDto` belong to the consuming project. Define them in +its `*-beans.xml` or as Jackson-compatible Java classes. Jackson handles nested +objects, collections and JSON property annotations. For generic response types, +use the `exchange` overload with a `ParameterizedTypeReference`: + +```java +ResponseEntity> response = client.exchange( + HttpMethod.GET, null, new ParameterizedTypeReference>() {}); +``` + +The client delegates HTTP error handling to the configured RestTemplate. The +consuming strategy decides how to handle empty bodies, status codes and +`RestClientException`, and whether to apply business mapping or caching. + +## Spring configuration and mapping + +The public bean aliases follow the CX DEV naming convention: + +| Alias | Implementation bean | +|---|---| +| `scpiRemoteClientFactory` | `cxScpiRemoteClientFactory` | +| `abstractScpiOutboundStrategy` | `cxAbstractScpiOutboundStrategy` | +| `abstractRestTemplateCreator` | `cxAbstractRestTemplateCreator` | + +The extracted converter configuration replaces the shared SAP +`abstractRestTemplateCreator` alias. It therefore also applies to other SAP +RestTemplate creators that use this parent. It accepts JSON responses served as +`application/json` or `application/octet-stream`, while keeping SAP's +`outboundClientHttpRequestFactory`, `restTemplateHeaderInterceptors` and +`outboundRestTemplateCache`. This matches the source implementation and the +standard converter configuration in the referenced SAP release. Binary +responses still depend on the converters supported by the SAP-created template. + +Authentication and available credential types are determined by the installed +SAP RestTemplate creators. The extension calls the template directly: it does +not invoke the `OutboundServiceFacade` decorator pipeline, generate Integration +Object payloads, fetch CSRF tokens through facade decorators or create outbound +monitoring records. The SAP template's interceptors remain attached; monitoring +records require the corresponding SAP monitoring context supplied by the caller. + +When a target project already overrides the shared aliases, reconcile those +overrides in its Spring configuration. DTO conversion here uses Jackson HTTP +message conversion; it does not use the OCC `DataMapper`. + +`resources/localization/cxdevcpi-locales_en.properties` and +`cxdevcpi-locales_de.properties` reserve the standard localization locations. +They contain no keys because this extension adds no item types or UI labels. + +## Tests + +`testsrc` contains isolated unit tests for nested DTO conversion, generic lists, +URI variables, JSON served as an octet stream, request bodies, HTTP errors, +destination lookup and Spring aliases/parent bean configuration. They use mock +HTTP responses and do not require a CPI account. + +After registering and building the extension in a target project, run from +`hybris/bin/platform` with the platform Ant environment loaded: + +```sh +ant unittests -Dtestclasses.extensions=cxdevcpi +``` + +## License + +Licensed under the Apache License, Version 2.0. See [LICENSE.md](LICENSE.md). + +Copyright 2026, CX DEV Tools. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/extensioninfo.xml new file mode 100644 index 0000000..00c8bfd --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/extensioninfo.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/project.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/project.properties new file mode 100644 index 0000000..0311038 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/project.properties @@ -0,0 +1,6 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2026 CX DEV Tools +# ---------------------------------------------------------------------------- + +# Location of the file for the global platform spring application context +cxdevcpi.application-context=cxdevcpi-spring.xml diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi-beans.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi-beans.xml new file mode 100644 index 0000000..9cf707f --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi-beans.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi-spring.xml new file mode 100644 index 0000000..da8d91e --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi-spring.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi/generic/scpi-outbound-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi/generic/scpi-outbound-spring.xml new file mode 100644 index 0000000..b9c8a30 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/cxdevcpi/generic/scpi-outbound-spring.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + application/json + application/octet-stream + + + + + + + + + + + + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/localization/cxdevcpi-locales_de.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/localization/cxdevcpi-locales_de.properties new file mode 100644 index 0000000..0b17e3f --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/localization/cxdevcpi-locales_de.properties @@ -0,0 +1,4 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2026 CX DEV Tools +# ---------------------------------------------------------------------------- +# Keine extension-spezifischen Item-Typen oder Attribute zu lokalisieren. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/localization/cxdevcpi-locales_en.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/localization/cxdevcpi-locales_en.properties new file mode 100644 index 0000000..daff404 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/resources/localization/cxdevcpi-locales_en.properties @@ -0,0 +1,4 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2026 CX DEV Tools +# ---------------------------------------------------------------------------- +# No extension-specific item types or attributes require localization. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/constants/CxCpiConstants.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/constants/CxCpiConstants.java new file mode 100644 index 0000000..f10d275 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/constants/CxCpiConstants.java @@ -0,0 +1,11 @@ +package me.cxdev.cpi.constants; + +@SuppressWarnings({ "deprecation", "squid:CallToDeprecatedMethod" }) +public class CxCpiConstants extends GeneratedCxCpiConstants { + + public static final String EXTENSIONNAME = "cxdevcpi"; + + private CxCpiConstants() { + // empty + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/outbound/AbstractScpiOutboundStrategy.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/outbound/AbstractScpiOutboundStrategy.java new file mode 100644 index 0000000..52a2319 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/outbound/AbstractScpiOutboundStrategy.java @@ -0,0 +1,25 @@ +package me.cxdev.cpi.generic.outbound; + +import me.cxdev.cpi.generic.remoteclient.ScpiRemoteClientFactory; + +public abstract class AbstractScpiOutboundStrategy { + + private ScpiRemoteClientFactory remoteClientFactory; + private String destinationId; + + public void setRemoteClientFactory(ScpiRemoteClientFactory remoteClientFactory) { + this.remoteClientFactory = remoteClientFactory; + } + + public void setDestinationId(String destinationId) { + this.destinationId = destinationId; + } + + public ScpiRemoteClientFactory getRemoteClientFactory() { + return remoteClientFactory; + } + + public String getDestinationId() { + return destinationId; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/RestTemplateScpiRemoteClient.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/RestTemplateScpiRemoteClient.java new file mode 100644 index 0000000..a88648c --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/RestTemplateScpiRemoteClient.java @@ -0,0 +1,240 @@ +package me.cxdev.cpi.generic.remoteclient; + +import static org.apache.commons.collections4.MapUtils.emptyIfNull; +import static org.apache.commons.lang3.StringUtils.defaultIfBlank; + +import java.net.URI; +import java.util.Map; +import java.util.Set; + +import de.hybris.platform.apiregistryservices.model.ConsumedDestinationModel; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RequestCallback; +import org.springframework.web.client.ResponseExtractor; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestOperations; + +import jakarta.annotation.Nonnull; + +public class RestTemplateScpiRemoteClient implements ScpiRemoteClient { + private static final String URL_QUERY_KEY = "urlQuery"; + private ConsumedDestinationModel destination; + private RestOperations template; + + public ConsumedDestinationModel getDestination() { + return this.destination; + } + + RestTemplateScpiRemoteClient(@Nonnull ConsumedDestinationModel destination, @Nonnull RestOperations template) { + this.destination = destination; + this.template = template; + } + + @Override + public T getForObject(Class responseType, Object... uriVariables) throws RestClientException { + return template.getForObject(getUrl(), responseType, uriVariables); + } + + @Override + public T getForObject(Class responseType, Map uriVariables) throws RestClientException { + return template.getForObject(getUrl(), responseType, uriVariables); + } + + @Override + public T getForObject(Class responseType) throws RestClientException { + return template.getForObject(getUrl(), responseType); + } + + @Override + public ResponseEntity getForEntity(Class responseType, Object... uriVariables) throws RestClientException { + return template.getForEntity(getUrl(), responseType, uriVariables); + } + + @Override + public ResponseEntity getForEntity(Class responseType, Map uriVariables) throws RestClientException { + return template.getForEntity(getUrl(), responseType, uriVariables); + } + + @Override + public ResponseEntity getForEntity(Class responseType) throws RestClientException { + return template.getForEntity(getUrl(), responseType); + } + + @Override + public HttpHeaders headForHeaders(Object... uriVariables) throws RestClientException { + return template.headForHeaders(getUrl(), uriVariables); + } + + @Override + public HttpHeaders headForHeaders(Map uriVariables) throws RestClientException { + return template.headForHeaders(getUrl(), uriVariables); + } + + @Override + public HttpHeaders headForHeaders() throws RestClientException { + return template.headForHeaders(getUrl()); + } + + @Override + public URI postForLocation(Object request, Object... uriVariables) throws RestClientException { + return template.postForLocation(getUrl(), request, uriVariables); + } + + @Override + public URI postForLocation(Object request, Map uriVariables) throws RestClientException { + return template.postForLocation(getUrl(), request, uriVariables); + } + + @Override + public URI postForLocation(Object request) throws RestClientException { + return template.postForLocation(getUrl(), request); + } + + @Override + public T postForObject(Object request, Class responseType, Object... uriVariables) throws RestClientException { + return template.postForObject(getUrl(), request, responseType, uriVariables); + } + + @Override + public T postForObject(Object request, Class responseType, Map uriVariables) throws RestClientException { + return template.postForObject(getUrl(), request, responseType, uriVariables); + } + + @Override + public T postForObject(Object request, Class responseType) throws RestClientException { + return template.postForObject(getUrl(), request, responseType); + } + + @Override + public ResponseEntity postForEntity(Object request, Class responseType, Object... uriVariables) throws RestClientException { + return template.postForEntity(getUrl(), request, responseType, uriVariables); + } + + @Override + public ResponseEntity postForEntity(Object request, Class responseType, Map uriVariables) throws RestClientException { + return template.postForEntity(getUrl(), request, responseType, uriVariables); + } + + @Override + public ResponseEntity postForEntity(Object request, Class responseType) throws RestClientException { + return template.postForEntity(getUrl(), request, responseType); + } + + @Override + public void put(Object request, Object... uriVariables) throws RestClientException { + template.put(getUrl(), request, uriVariables); + } + + @Override + public void put(Object request, Map uriVariables) throws RestClientException { + template.put(getUrl(), request, uriVariables); + } + + @Override + public void put(Object request) throws RestClientException { + template.put(getUrl(), request); + } + + @Override + public T patchForObject(Object request, Class responseType, Object... uriVariables) throws RestClientException { + return template.patchForObject(getUrl(), request, responseType, uriVariables); + } + + @Override + public T patchForObject(Object request, Class responseType, Map uriVariables) throws RestClientException { + return template.patchForObject(getUrl(), request, responseType, uriVariables); + } + + @Override + public T patchForObject(Object request, Class responseType) throws RestClientException { + return template.patchForObject(getUrl(), request, responseType); + } + + @Override + public void delete(Object... uriVariables) throws RestClientException { + template.delete(getUrl(), uriVariables); + } + + @Override + public void delete(Map uriVariables) throws RestClientException { + template.delete(getUrl(), uriVariables); + } + + @Override + public void delete() throws RestClientException { + template.delete(getUrl()); + } + + @Override + public Set optionsForAllow(Object... uriVariables) throws RestClientException { + return template.optionsForAllow(getUrl(), uriVariables); + } + + @Override + public Set optionsForAllow(Map uriVariables) throws RestClientException { + return template.optionsForAllow(getUrl(), uriVariables); + } + + @Override + public Set optionsForAllow() throws RestClientException { + return template.optionsForAllow(getUrl()); + } + + @Override + public ResponseEntity exchange(HttpMethod method, HttpEntity requestEntity, Class responseType, Object... uriVariables) throws RestClientException { + return template.exchange(getUrl(), method, requestEntity, responseType, uriVariables); + } + + @Override + public ResponseEntity exchange(HttpMethod method, HttpEntity requestEntity, Class responseType, Map uriVariables) throws RestClientException { + return template.exchange(getUrl(), method, requestEntity, responseType, uriVariables); + } + + @Override + public ResponseEntity exchange(HttpMethod method, HttpEntity requestEntity, Class responseType) throws RestClientException { + return template.exchange(getUrl(), method, requestEntity, responseType); + } + + @Override + public ResponseEntity exchange(HttpMethod method, HttpEntity requestEntity, ParameterizedTypeReference responseType, Object... uriVariables) + throws RestClientException { + return template.exchange(getUrl(), method, requestEntity, responseType, uriVariables); + } + + @Override + public ResponseEntity exchange(HttpMethod method, HttpEntity requestEntity, ParameterizedTypeReference responseType, Map uriVariables) + throws RestClientException { + return template.exchange(getUrl(), method, requestEntity, responseType, uriVariables); + } + + @Override + public ResponseEntity exchange(HttpMethod method, HttpEntity requestEntity, ParameterizedTypeReference responseType) throws RestClientException { + return template.exchange(getUrl(), method, requestEntity, responseType); + } + + @Override + public T execute(HttpMethod method, RequestCallback requestCallback, ResponseExtractor responseExtractor, Object... uriVariables) throws RestClientException { + return template.execute(getUrl(), method, requestCallback, responseExtractor, uriVariables); + } + + @Override + public T execute(HttpMethod method, RequestCallback requestCallback, ResponseExtractor responseExtractor, Map uriVariables) throws RestClientException { + return template.execute(getUrl(), method, requestCallback, responseExtractor, uriVariables); + } + + @Override + public T execute(HttpMethod method, RequestCallback requestCallback, ResponseExtractor responseExtractor) throws RestClientException { + return template.execute(getUrl(), method, requestCallback, responseExtractor); + } + + private String getUrl() { + String url = destination.getUrl(); + String query = defaultIfBlank(emptyIfNull(destination.getAdditionalProperties()).get(URL_QUERY_KEY), null); + return (query == null) ? url : String.join("?", url, query); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClient.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClient.java new file mode 100644 index 0000000..73bcbb1 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClient.java @@ -0,0 +1,122 @@ +package me.cxdev.cpi.generic.remoteclient; + +import java.net.URI; +import java.util.Map; +import java.util.Set; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +import org.springframework.web.client.RequestCallback; +import org.springframework.web.client.ResponseExtractor; +import org.springframework.web.client.RestClientException; + +/** + * Abstraction of the {@link org.springframework.web.client.RestOperations} interface, + * specifying a basic set of RESTful operations. Implemented by RestTemplate. + * + * Uses the destination URL and the SAP outbound RestTemplate configuration for + * authentication, message conversion and request interceptors. Outbound facade + * decorators (including creation of monitoring records) are not invoked here. + */ +public interface ScpiRemoteClient { + @Nullable + T getForObject(Class responseType, Object... uriVariables) throws RestClientException; + + @Nullable + T getForObject(Class responseType, Map uriVariables) throws RestClientException; + + @Nullable + T getForObject(Class responseType) throws RestClientException; + + ResponseEntity getForEntity(Class responseType, Object... uriVariables) throws RestClientException; + + ResponseEntity getForEntity(Class responseType, Map uriVariables) throws RestClientException; + + ResponseEntity getForEntity(Class responseType) throws RestClientException; + + HttpHeaders headForHeaders(Object... uriVariables) throws RestClientException; + + HttpHeaders headForHeaders(Map uriVariables) throws RestClientException; + + HttpHeaders headForHeaders() throws RestClientException; + + @Nullable + URI postForLocation(@Nullable Object request, Object... uriVariables) throws RestClientException; + + @Nullable + URI postForLocation(@Nullable Object request, Map uriVariables) throws RestClientException; + + @Nullable + URI postForLocation(@Nullable Object request) throws RestClientException; + + @Nullable + T postForObject(@Nullable Object request, Class responseType, Object... uriVariables) throws RestClientException; + + @Nullable + T postForObject(@Nullable Object request, Class responseType, Map uriVariables) throws RestClientException; + + @Nullable + T postForObject(@Nullable Object request, Class responseType) throws RestClientException; + + ResponseEntity postForEntity(@Nullable Object request, Class responseType, Object... uriVariables) throws RestClientException; + + ResponseEntity postForEntity(@Nullable Object request, Class responseType, Map uriVariables) throws RestClientException; + + ResponseEntity postForEntity(@Nullable Object request, Class responseType) throws RestClientException; + + void put(@Nullable Object request, Object... uriVariables) throws RestClientException; + + void put(@Nullable Object request, Map uriVariables) throws RestClientException; + + void put(@Nullable Object request) throws RestClientException; + + @Nullable + T patchForObject(@Nullable Object request, Class responseType, Object... uriVariables) throws RestClientException; + + @Nullable + T patchForObject(@Nullable Object request, Class responseType, Map uriVariables) throws RestClientException; + + @Nullable + T patchForObject(@Nullable Object request, Class responseType) throws RestClientException; + + void delete(Object... uriVariables) throws RestClientException; + + void delete(Map uriVariables) throws RestClientException; + + void delete() throws RestClientException; + + Set optionsForAllow(Object... uriVariables) throws RestClientException; + + Set optionsForAllow(Map uriVariables) throws RestClientException; + + Set optionsForAllow() throws RestClientException; + + ResponseEntity exchange(HttpMethod method, @Nullable HttpEntity requestEntity, Class responseType, Object... uriVariables) throws RestClientException; + + ResponseEntity exchange(HttpMethod method, @Nullable HttpEntity requestEntity, Class responseType, Map uriVariables) throws RestClientException; + + ResponseEntity exchange(HttpMethod method, @Nullable HttpEntity requestEntity, Class responseType) throws RestClientException; + + ResponseEntity exchange(HttpMethod method, @Nullable HttpEntity requestEntity, ParameterizedTypeReference responseType, Object... uriVariables) + throws RestClientException; + + ResponseEntity exchange(HttpMethod method, @Nullable HttpEntity requestEntity, ParameterizedTypeReference responseType, Map uriVariables) + throws RestClientException; + + ResponseEntity exchange(HttpMethod method, @Nullable HttpEntity requestEntity, ParameterizedTypeReference responseType) throws RestClientException; + + @Nullable + T execute(HttpMethod method, @Nullable RequestCallback requestCallback, @Nullable ResponseExtractor responseExtractor, Object... uriVariables) + throws RestClientException; + + @Nullable + T execute(HttpMethod method, @Nullable RequestCallback requestCallback, @Nullable ResponseExtractor responseExtractor, Map uriVariables) + throws RestClientException; + + @Nullable + T execute(HttpMethod method, @Nullable RequestCallback requestCallback, @Nullable ResponseExtractor responseExtractor) throws RestClientException; +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClientFactory.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClientFactory.java new file mode 100644 index 0000000..e621536 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/src/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClientFactory.java @@ -0,0 +1,45 @@ +package me.cxdev.cpi.generic.remoteclient; + +import de.hybris.platform.apiregistryservices.model.ConsumedDestinationModel; +import de.hybris.platform.outboundservices.client.IntegrationRestTemplateFactory; +import de.hybris.platform.servicelayer.exceptions.ModelNotFoundException; +import de.hybris.platform.servicelayer.search.FlexibleSearchService; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.client.RestOperations; + +import jakarta.validation.constraints.NotNull; + +public class ScpiRemoteClientFactory { + private static final Logger LOG = LoggerFactory.getLogger(ScpiRemoteClientFactory.class); + + private FlexibleSearchService flexibleSearchService; + private IntegrationRestTemplateFactory restTemplateFactory; + + public ScpiRemoteClientFactory(FlexibleSearchService flexibleSearchService, IntegrationRestTemplateFactory restTemplateFactory) { + this.flexibleSearchService = flexibleSearchService; + this.restTemplateFactory = restTemplateFactory; + } + + public RestTemplateScpiRemoteClient clientForDestination(@NotNull String destinationId) { + ConsumedDestinationModel destination = getConsumedDestination(destinationId); + return clientForDestination(destination); + } + + public RestTemplateScpiRemoteClient clientForDestination(@NotNull ConsumedDestinationModel destination) { + RestOperations template = restTemplateFactory.create(destination); + return new RestTemplateScpiRemoteClient(destination, template); + } + + private ConsumedDestinationModel getConsumedDestination(String destinationId) { + try { + ConsumedDestinationModel example = new ConsumedDestinationModel(); + example.setId(destinationId); + return flexibleSearchService.getModelByExample(example); + } catch (RuntimeException e) { + LOG.warn("Failed to find ConsumedDestination with id '{}'", destinationId, e); + throw new ModelNotFoundException("Provided destination was not found."); + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/CxCpiSpringConfigurationTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/CxCpiSpringConfigurationTest.java new file mode 100644 index 0000000..6494f03 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/CxCpiSpringConfigurationTest.java @@ -0,0 +1,88 @@ +package me.cxdev.cpi.generic; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.List; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.outboundservices.cache.RestTemplateCache; +import de.hybris.platform.outboundservices.client.IntegrationRestTemplateFactory; +import de.hybris.platform.outboundservices.client.impl.DefaultIntegrationNoCredentialRestTemplateCreator; +import de.hybris.platform.servicelayer.search.FlexibleSearchService; + +import org.junit.Test; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.xml.XmlBeanDefinitionReader; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.test.util.ReflectionTestUtils; + +import me.cxdev.cpi.generic.outbound.AbstractScpiOutboundStrategy; +import me.cxdev.cpi.generic.remoteclient.ScpiRemoteClientFactory; + +@UnitTest +public class CxCpiSpringConfigurationTest { + + @Test + public void wiresFactoryAndConsumingStrategyThroughPublicAliases() { + try (GenericApplicationContext context = createContext()) { + context.registerBeanDefinition("testStrategy", BeanDefinitionBuilder.genericBeanDefinition(TestStrategy.class) + .setParentName("abstractScpiOutboundStrategy") + .addPropertyValue("destinationId", "testDestination").getBeanDefinition()); + context.refresh(); + + TestStrategy strategy = context.getBean("testStrategy", TestStrategy.class); + ScpiRemoteClientFactory factory = context.getBean("scpiRemoteClientFactory", ScpiRemoteClientFactory.class); + assertSame(factory, context.getBean("cxScpiRemoteClientFactory")); + assertSame(factory, strategy.getRemoteClientFactory()); + assertEquals("testDestination", strategy.getDestinationId()); + assertTrue(context.getBeanFactory().getMergedBeanDefinition("abstractScpiOutboundStrategy").isAbstract()); + assertSame(context.getBean("integrationRestTemplateFactory"), ReflectionTestUtils.getField(factory, "restTemplateFactory")); + } + } + + @Test + public void configuresSapCreatorsWithTypedConversionAndPreservesInfrastructure() { + try (GenericApplicationContext context = createContext()) { + context.registerBeanDefinition("testCreator", BeanDefinitionBuilder + .genericBeanDefinition(DefaultIntegrationNoCredentialRestTemplateCreator.class) + .setParentName("abstractRestTemplateCreator").getBeanDefinition()); + context.refresh(); + + Object creator = context.getBean("testCreator"); + List converters = (List) ReflectionTestUtils.getField(creator, "messageConverters"); + HttpMessageConverter converter = (HttpMessageConverter) converters.get(0); + assertTrue(converter.canRead(TestResponse.class, MediaType.APPLICATION_JSON)); + assertTrue(converter.canRead(TestResponse.class, MediaType.APPLICATION_OCTET_STREAM)); + assertTrue(converter.canWrite(TestResponse.class, MediaType.APPLICATION_JSON)); + assertSame(context.getBean("outboundClientHttpRequestFactory"), ReflectionTestUtils.getField(creator, "clientHttpRequestFactory")); + assertSame(context.getBean("outboundRestTemplateCache"), ReflectionTestUtils.getField(creator, "cache")); + assertEquals(context.getBean("restTemplateHeaderInterceptors"), ReflectionTestUtils.getField(creator, "requestInterceptors")); + } + } + + private GenericApplicationContext createContext() { + GenericApplicationContext context = new GenericApplicationContext(); + context.getBeanFactory().registerSingleton("flexibleSearchService", mock(FlexibleSearchService.class)); + context.getBeanFactory().registerSingleton("integrationRestTemplateFactory", mock(IntegrationRestTemplateFactory.class)); + context.getBeanFactory().registerSingleton("outboundClientHttpRequestFactory", mock(ClientHttpRequestFactory.class)); + context.getBeanFactory().registerSingleton("outboundRestTemplateCache", mock(RestTemplateCache.class)); + context.getBeanFactory().registerSingleton("restTemplateHeaderInterceptors", List.of(mock(ClientHttpRequestInterceptor.class))); + new XmlBeanDefinitionReader(context).loadBeanDefinitions(new ClassPathResource("cxdevcpi-spring.xml")); + return context; + } + + public static class TestStrategy extends AbstractScpiOutboundStrategy { + } + + public static class TestResponse { + public String id; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/remoteclient/RestTemplateScpiRemoteClientTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/remoteclient/RestTemplateScpiRemoteClientTest.java new file mode 100644 index 0000000..29f65ec --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/remoteclient/RestTemplateScpiRemoteClientTest.java @@ -0,0 +1,138 @@ +package me.cxdev.cpi.generic.remoteclient; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.apiregistryservices.model.ConsumedDestinationModel; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +@UnitTest +public class RestTemplateScpiRemoteClientTest { + + private static final String URL = "https://cpi.example.invalid/http/documents"; + + private ConsumedDestinationModel destination; + private ScpiRemoteClient client; + private MockRestServiceServer server; + + @Before + public void setUp() { + destination = mock(ConsumedDestinationModel.class); + when(destination.getUrl()).thenReturn(URL); + MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + converter.setSupportedMediaTypes(List.of(MediaType.APPLICATION_JSON, MediaType.APPLICATION_OCTET_STREAM)); + RestTemplate template = new RestTemplate(List.of(converter)); + server = MockRestServiceServer.bindTo(template).build(); + client = new RestTemplateScpiRemoteClient(destination, template); + } + + @Test + public void mapsNestedResponseAndSerializesRequestWithUriVariables() { + when(destination.getUrl()).thenReturn(URL + "/{id}"); + when(destination.getAdditionalProperties()).thenReturn(Map.of("urlQuery", "language={language}")); + server.expect(requestTo(URL + "/42?language=en")) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().string("{\"filter\":\"open\"}")) + .andRespond(withSuccess("{\"items\":[{\"id\":\"42\"}]}", MediaType.APPLICATION_JSON)); + + Envelope result = client.postForEntity(Map.of("filter", "open"), Envelope.class, + Map.of("id", "42", "language", "en")).getBody(); + + assertEquals("42", result.items.get(0).id); + server.verify(); + } + + @Test + public void mapsGenericListFromJsonServedAsOctetStream() { + server.expect(requestTo(URL)).andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("[{\"id\":\"42\"}]", MediaType.APPLICATION_OCTET_STREAM)); + + List result = client.exchange(HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }).getBody(); + + assertEquals("42", result.get(0).id); + server.verify(); + } + + @Test + public void omitsQuerySeparatorWhenQueryIsAbsentOrBlank() { + for (Map properties : List.of(Map.of(), Map.of("urlQuery", ""), Map.of("urlQuery", " "))) { + when(destination.getAdditionalProperties()).thenReturn(properties); + server.expect(requestTo(URL)).andRespond(withSuccess("{\"id\":\"42\"}", MediaType.APPLICATION_JSON)); + assertEquals("42", client.getForObject(Document.class).id); + server.verify(); + server.reset(); + } + } + + @Test + public void forwardsRequestBodyAndPositionalVariablesForLocation() { + when(destination.getUrl()).thenReturn(URL + "/{id}"); + expectCreation(URL + "/42"); + + assertEquals(URI.create(URL + "/created"), client.postForLocation(Map.of("name", "test"), "42")); + server.verify(); + } + + @Test + public void forwardsRequestBodyAndNamedVariablesForLocation() { + when(destination.getUrl()).thenReturn(URL + "/{id}"); + expectCreation(URL + "/42"); + + assertEquals(URI.create(URL + "/created"), client.postForLocation(Map.of("name", "test"), Map.of("id", "42"))); + server.verify(); + } + + @Test + public void forwardsRequestBodyForLocationWithoutVariables() { + expectCreation(URL); + + assertEquals(URI.create(URL + "/created"), client.postForLocation(Map.of("name", "test"))); + server.verify(); + } + + @Test + public void propagatesHttpErrorsToTheConsumingStrategy() { + server.expect(requestTo(URL)).andRespond(withStatus(HttpStatus.BAD_REQUEST)); + + assertThrows(HttpClientErrorException.BadRequest.class, () -> client.getForObject(Document.class)); + server.verify(); + } + + private void expectCreation(String url) { + server.expect(requestTo(url)).andExpect(method(HttpMethod.POST)) + .andExpect(content().string("{\"name\":\"test\"}")) + .andRespond(withStatus(HttpStatus.CREATED).location(URI.create(URL + "/created"))); + } + + public static class Envelope { + public List items; + } + + public static class Document { + public String id; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClientFactoryTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClientFactoryTest.java new file mode 100644 index 0000000..0b1e0eb --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevcpi/testsrc/me/cxdev/cpi/generic/remoteclient/ScpiRemoteClientFactoryTest.java @@ -0,0 +1,87 @@ +package me.cxdev.cpi.generic.remoteclient; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.apiregistryservices.model.ConsumedDestinationModel; +import de.hybris.platform.outboundservices.client.IntegrationRestTemplateFactory; +import de.hybris.platform.servicelayer.exceptions.ModelNotFoundException; +import de.hybris.platform.servicelayer.search.FlexibleSearchService; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; +import org.springframework.web.client.RestOperations; + +@UnitTest +public class ScpiRemoteClientFactoryTest { + + private FlexibleSearchService searchService; + private IntegrationRestTemplateFactory templateFactory; + private RestOperations template; + private ScpiRemoteClientFactory factory; + private ConsumedDestinationModel destination; + + @Before + public void setUp() { + searchService = mock(FlexibleSearchService.class); + templateFactory = mock(IntegrationRestTemplateFactory.class); + template = mock(RestOperations.class); + destination = mock(ConsumedDestinationModel.class); + when(destination.getUrl()).thenReturn("https://cpi.example.invalid/http/documents"); + when(templateFactory.create(destination)).thenReturn(template); + factory = new ScpiRemoteClientFactory(searchService, templateFactory); + } + + @Test + public void resolvesDestinationByIdAndUsesSapTemplateForTypedCalls() { + when(searchService.getModelByExample(any(ConsumedDestinationModel.class))).thenReturn(destination); + // Keep creation of the lookup example independent of the platform tenant. + try (MockedConstruction models = mockConstruction(ConsumedDestinationModel.class)) { + RestTemplateScpiRemoteClient client = factory.clientForDestination("myDestination"); + client.getForObject(String.class); + + ArgumentCaptor example = ArgumentCaptor.forClass(ConsumedDestinationModel.class); + verify(searchService).getModelByExample(example.capture()); + assertSame(models.constructed().get(0), example.getValue()); + verify(example.getValue()).setId("myDestination"); + assertSame(destination, client.getDestination()); + verify(templateFactory).create(destination); + verify(template).getForObject(destination.getUrl(), String.class); + } + } + + @Test + public void acceptsResolvedDestinationWithoutAnotherLookup() { + assertSame(destination, factory.clientForDestination(destination).getDestination()); + verifyNoInteractions(searchService); + verify(templateFactory).create(destination); + } + + @Test + public void reportsLookupFailureWithoutCreatingTemplate() { + when(searchService.getModelByExample(any(ConsumedDestinationModel.class))) + .thenThrow(new ModelNotFoundException("Missing destination")); + + try (MockedConstruction models = mockConstruction(ConsumedDestinationModel.class)) { + assertThrows(ModelNotFoundException.class, () -> factory.clientForDestination("missing")); + verifyNoInteractions(templateFactory); + } + } + + @Test + public void propagatesTemplateCreationFailure() { + IllegalArgumentException failure = new IllegalArgumentException("Unsupported credential"); + when(templateFactory.create(destination)).thenThrow(failure); + + assertSame(failure, assertThrows(IllegalArgumentException.class, () -> factory.clientForDestination(destination))); + } +} diff --git a/core-customize/hybris/config/localextensions.xml b/core-customize/hybris/config/localextensions.xml index 7f15afb..05fd570 100644 --- a/core-customize/hybris/config/localextensions.xml +++ b/core-customize/hybris/config/localextensions.xml @@ -9,6 +9,7 @@ +