conneqt / m2-module-sap-base
N/A
Package info
git.dev.epartment.nl/conneqt/m2/module-sap-base
Type:magento2-module
pkg:composer/conneqt/m2-module-sap-base
Requires
- php: >=8.1
- conneqt/m2-base: 1.*
- magento/framework: *
README
Magento 2 foundation module for SAP Business One Service Layer integrations.
conneqt/m2-module-sap-base provides the shared connection layer — an authenticated, session-caching
Guzzle client — plus the customer/address EAV fields and order extension attributes that the
SAP-facing feature modules build on. It contains no end-user feature of its own.
- Package:
conneqt/m2-module-sap-base - Module:
Conneqt_SapBase - Requires: PHP >= 8.1,
magento/framework,conneqt/m2-base(1.*) - License: proprietary
Table of contents
- What it provides
- Installation
- Configuration
- Using the API helper
- Session and cookie caching
- Retry, cooldown and failure behaviour
- EAV attributes
- Order extension attributes
- Admin: Test Connection
- Logging
- Troubleshooting
- Known gaps and gotchas
- File map
What it provides
| Capability | Entry point |
|---|---|
| Store-scoped SAP configuration | Helper/ScopeConfigHelper.php |
| Authenticated Guzzle client + cached SAP session | Helper/SapClient.php |
GET / POST / PATCH wrapper with retry + re-login | Helper/Api.php |
| SAP fields on customers and customer addresses | Setup/Patch/Data/*.php |
| SAP fields on orders and order addresses (API output) | Plugin/Order/*.php, etc/extension_attributes.xml |
| Admin connection test button | Block/Adminhtml/Config/TestConnectionButton.php, Controller/Adminhtml/Config/TestConnection.php |
Consumed by
Feature modules that depend on this one:
conneqt/module-sap-my-account—Helper/Api/ApiBase.php,Helper/Api/ApiEquipment.phpconneqt/module-sap-service-layer-special-prices—Helper/PriceApi.php- project-level modules that inject
Conneqt\SapBase\Helper\Apidirectly (e.g. cart/order preview calls)
Installation
composer require conneqt/m2-module-sap-base
bin/magento module:enable Conneqt_SapBase && bin/magento setup:upgrade
setup:upgrade runs the three data patches that create the customer and address EAV attributes.
Run all bin/magento / composer commands inside the project's PHP container if the project uses one.
Configuration
Stores → Configuration → Conneqt → SAP (section id="sap").
Every field is available at default, website and store-view scope, so different websites can talk to
different SAP instances.
ScopeConfigHelper resolves against the current store view by default. Outside a storefront
request there is no meaningful current store view and Magento falls back to the default store view, so
admin controllers, console commands and cron jobs must state the scope they mean:
$this->scopeConfigHelper->setScope(ScopeInterface::SCOPE_WEBSITES, $websiteId);
// ... SAP calls resolve against that website ...
$this->scopeConfigHelper->resetScope();
setScope() accepts any scope type ScopeConfigInterface::getValue() understands
(default, websites, stores). The helper is a shared DI instance, so pinning it also affects the
SapClient and Api instances built from it — reset it once the scoped work is done.
sap/api — SAP API
| Path | Label | Type | Default | Notes |
|---|---|---|---|---|
sap/api/base_url | Base URL | text | — | Service Layer root, e.g. https://sap.example.com:50000/b1s/v1/. Used as Guzzle base_uri, so a trailing slash matters. |
sap/api/username | Username | text | — | |
sap/api/password | Password | password | — | See Known gaps — not encrypted at rest. |
sap/api/database | Database | text | — | SAP CompanyDB. |
sap/api/subscription_header_key | Aiden API Manager Header Key | text | — | Optional. Header is only sent when both key and value are filled. |
sap/api/subscription_header_value | Aiden API Manager Header Value | text | — | Optional. |
sap/api/enable_proxy | Enable Proxy | yes/no | — | |
sap/api/proxy | Proxy | text | — | Only shown when the proxy is enabled; passed straight to Guzzle's proxy option. |
sap/api/test_connection | Test Connection | button | — | See Admin: Test Connection. |
sap/default — Default API
| Path | Label | Default (etc/config.xml) | Notes |
|---|---|---|---|
sap/default/timeout | Response Timeout | 15 | Guzzle timeout, seconds. |
sap/default/connect_timeout | Connection Timeout | 15 | Guzzle connect_timeout, seconds. |
sap/login — Login API
| Path | Label | Default (etc/config.xml) | Notes |
|---|---|---|---|
sap/login/timeout | Response Timeout | 2 | Applied to the Login call only. |
sap/login/connect_timeout | Connection Timeout | 2 | Applied to the Login call only. |
sap/login/retry_limit | Max Connection Attempts | 5 | Retries on HTTP 429 and on connection failures. |
sap/login/cooldown | Cooldown | 60 | Seconds that all SAP traffic is blocked after the retry limit is exhausted. |
sap/logging — Logging
| Path | Label | Default (etc/config.xml) | Notes |
|---|---|---|---|
sap/logging/enable | Log request/response | 0 | Writes full request and response bodies to var/log/system.log. |
The timeout/retry defaults come from etc/config.xml, not from the PHP getters — see
Known gaps.
Using the API helper
Inject Conneqt\SapBase\Helper\Api and call the verb you need. The helper resolves the shared
client, logs in when required, persists cookies and retries on 429 / 401.
use Conneqt\SapBase\Helper\Api;
class MyService
{
public function __construct(
private Api $api
) {
}
public function getItem(string $sku): array
{
return $this->api->get(sprintf("Items('%s')", $sku));
}
}
Method contract
| Method | Second argument | Returns |
|---|---|---|
get(string $uri, array $data = [], bool $raw = false) | Guzzle request options, e.g. ['query' => '$filter=...'] | decoded array, or the raw body string when $raw === true |
post(string $uri, array $data = []) | Request payload, wrapped internally as ['json' => $data] | raw body string |
patch(string $uri, array $data = []) | Request payload, wrapped internally as ['json' => $data] | raw body string |
The asymmetry is deliberate but easy to trip over:
get()forwards$datato Guzzle untouched, so query parameters must be nested under aquerykey.post()andpatch()take the body directly.
// GET with an OData query
$this->api->get('SpecialPrices', ['query' => "\$filter=ItemCode eq 'ABC' & \$top=20"]);
// POST with a JSON body
$this->api->post('OrdersService_Preview', ['CardCode' => 'C10001', 'DocumentLines' => []]);
Api::getClient() exposes the underlying GuzzleHttp\Client for calls the wrapper does not cover
(other verbs, streaming, custom options). Doing so bypasses the logging, cookie-persistence and
retry logic, so prefer the wrapper methods.
Exceptions
| Condition | Thrown |
|---|---|
| Cooldown flag active (a previous login sequence failed) | \Exception('Login failed in a previous request', 401) |
| Login retry limit exhausted | \Exception('Login retry limit exceeded', 401) |
| Any other 4xx from SAP | GuzzleHttp\Exception\ClientException |
| Transport failure | GuzzleHttp\Exception\GuzzleException |
Callers are expected to catch these; nothing in this module swallows them.
Session and cookie caching
SAP Service Layer authenticates with a session cookie, and login is expensive and rate-limited. This module therefore reuses one SAP session across Magento requests:
SapClient::getClient()builds the Guzzle client once per Magento request (the class is a DI singleton) and hydrates itsCookieJarfrom Magento cache.- If the jar comes back empty,
login()is called immediately with the configured credentials. - After a successful login, and after every successful
get()/post()/patch(), the jar is serialized back into cache bysaveCookiesInCache(). - If SAP later answers
401(session expired server-side),Apire-logs in once and replays the request.
Cache keys, all tagged with Magento\Framework\App\Cache\Type\Config::TYPE_IDENTIFIER:
| Constant | Key | Holds |
|---|---|---|
SapClient::COOKIE_IDENTIFIER | conneqt-sap-cookies | serialized Guzzle cookie jar |
SapClient::COOKIE_LIFE_TIME_IDENTIFIER | conneqt-sap-cookies-lifetime | TTL used for the cookie entry |
SapClient::LOGIN_FAILED_IDENTIFIER | conneqt-sap-login-failed | cooldown flag after a failed login sequence |
The TTL is derived from SessionTimeout (minutes) in the SAP login response the first time it is
stored, and reused for subsequent saves until the lifetime entry itself expires.
The cache keys are global, not per scope. Cookies are matched by domain inside the jar, so scopes
pointing at different SAP hosts do not interfere. Scopes that share a host but use a different
CompanyDB would share one session cookie — avoid that layout.
Because everything is tagged as config cache, flushing the config cache drops the SAP session —
bin/magento cache:clean config, a config save in the admin, or a deploy all force the next SAP call
to log in again. That is safe, just slower, and it is the intended way to clear a stuck cooldown.
Retry, cooldown and failure behaviour
Login (SapClient::login() / retryLogin()):
- HTTP
429or aConnectException→sleep(1), retry, up tosap/login/retry_limitattempts. - Any other
ClientException→ logged and rethrown immediately. - Retry limit exhausted → the cooldown flag is cached for
sap/login/cooldownseconds and\Exception('Login retry limit exceeded', 401)is thrown.
Requests (Api::get() / post() / patch()):
- Cooldown flag set → throws before any HTTP traffic. This is what prevents a dead SAP host from adding seconds of latency to every storefront page.
- HTTP
429→sleep(1)and retry the same call (recursive, no attempt cap). - HTTP
401→login()and replay once. - Anything else → logged and rethrown.
Note that both retry paths use a blocking sleep(1), which occupies the PHP-FPM worker. Keep
sap/login/timeout low (the 2 second default exists for exactly this reason) so a slow SAP does
not stall storefront rendering.
EAV attributes
Created by data patches on setup:upgrade.
Customer (Magento\Customer\Model\Customer::ENTITY)
| Code | Label | Type | Admin form | Grid |
|---|---|---|---|---|
card_code | SAP CardCode | varchar | adminhtml_customer | used / visible / filterable |
sap_interncode | Sap Internal Code | varchar | adminhtml_customer | used / visible / filterable |
Customer address (Magento\Customer\Model\Indexer\Address\AttributeProvider::ENTITY)
| Code | Label | Type | Admin form |
|---|---|---|---|
sap_address_name | SAP Address Name | varchar | adminhtml_customer_address |
sap_address_type | SAP Address Type | varchar | adminhtml_customer_address |
All four are user_defined = 0 and are meant to be written by the SAP sync modules or maintained by
hand in the admin — this module never populates them.
Because the customer attributes are grid-enabled, run
bin/magento indexer:reindex customer_grid after a bulk import if the values do not show up in the
customer grid.
Order extension attributes
etc/extension_attributes.xml declares:
| Entity | Attribute | Source |
|---|---|---|
Magento\Sales\Api\Data\OrderInterface | sap_interncode | customer attribute sap_interncode |
Magento\Sales\Api\Data\OrderInterface | card_code | customer attribute card_code |
Magento\Sales\Api\Data\OrderAddressInterface | sap_address_name | customer address attribute sap_address_name |
Magento\Sales\Api\Data\OrderAddressInterface | sap_address_type | customer address attribute sap_address_type |
Three plugins on Magento\Sales\Api\OrderRepositoryInterface (etc/di.xml) fill them in afterGet
and afterGetList:
Plugin/Order/SapInternCodePlugin.phpPlugin/Order/CardCodePlugin.phpPlugin/Order/SapAddressPlugin.php
Behaviour worth knowing:
- Values are resolved live from the customer, not stored on the order. Changing a customer's
card_coderetroactively changes whatGET /V1/orders/:idreports for their historical orders. - Guest orders are skipped — all three plugins return early when
getCustomerId()is empty. SapAddressPluginmatches an order address to a customer address byOrderAddressInterface::getCustomerAddressId(). Addresses typed in at checkout, or customer addresses deleted after the order was placed, get no SAP values.- It covers the billing address, every shipping assignment in the order extension attributes, and
getShippingAddress()— virtual orders and orders with a null shipping assignment are handled. - The plugins only run on the repository. Orders loaded through
Magento\Sales\Model\OrderFactory, order collections, or the admin sales grid do not get the extension attributes. - Each plugin loads the customer independently, so a repository
getList()over N orders performs customer lookups per order.CustomerRepositorycaches by ID within the request, which keeps this to one DB round trip per distinct customer rather than three — but a largegetList()is still N customer loads.
Admin: Test Connection
Stores → Configuration → Conneqt → SAP → SAP API → Test Connection.
Flow:
Block/Adminhtml/Config/TestConnectionButton.phprendersview/adminhtml/templates/system/config/test-connection-button.phtmlin place of the field, and forwards thewebsite/storeparameters of the configuration page into the AJAX URL.- The template boots
view/adminhtml/web/js/test-connection.jsviax-magento-init, passing the admin URL. - The JS AJAXes to
sap-base/config/testConnection(Controller/Adminhtml/Config/TestConnection.php, route declared inetc/adminhtml/routes.xml). - The controller pins
ScopeConfigHelperto the scope those parameters describe —stores/<id>,websites/<id>ordefault— so the credentials under test are the ones shown on the page. - It then cleans the whole config cache and calls
SapClient::getClient(), which forces a fresh login because the cookie cache was just dropped. - The response is
{"success": true}or{"success": false, "message": "..."}; the message is the SAP error body when there is one, otherwise the exception message. The JS shows it in a modal alert.
Two things to keep in mind:
- The button reflects the saved configuration, not what is currently typed into the form — save first, then test.
- It tests the scope you are currently editing. A website-scope test resolves website-scope values and ignores store-view overrides beneath it, which is what the page itself shows; switch to the store view to test an override.
Logging
With sap/logging/enable on, SapClient::log() writes every request and response through
Psr\Log\LoggerInterface (so var/log/system.log by default), prefixed Conneqt\SapBase::
Conneqt\SapBase: GET request Items('ABC') - array (...)
Conneqt\SapBase: GET response Items('ABC') - {"odata.metadata":...}
Two consequences: it is verbose enough to hurt on pages that make many SAP calls, and request
bodies are logged verbatim, including the Login payload with the SAP username and password.
Treat it as a temporary debugging switch, not a production setting.
Troubleshooting
| Symptom | Likely cause | What to do |
|---|---|---|
Every SAP call throws Login failed in a previous request (401) | Cooldown flag is cached after an exhausted retry sequence | Fix the underlying cause, then bin/magento cache:clean config to clear the flag, or wait out sap/login/cooldown |
Login retry limit exceeded | SAP kept returning 429 or refusing connections | Check SAP availability / licence seats; raise sap/login/retry_limit only as a last resort |
TypeError: ...getBaseUrl(): Return value must be of type string, null returned | sap/api/base_url is empty in the resolved scope | Fill in the SAP API group for that store view |
| Timeouts everywhere after a config change | Timeout config resolved to 0, which Guzzle reads as "no timeout" | Make sure sap/default/* and sap/login/* have values; etc/config.xml supplies them unless they were overridden with blanks |
| Extension attributes missing on an order | Order not loaded through OrderRepositoryInterface, guest order, or no matching customer_address_id | See Order extension attributes |
| SAP section not visible for a restricted admin role | The section's ACL resource does not exist | See Known gaps |
| SAP config resolves to the wrong values in cron, a console command or an admin controller | There is no meaningful current store view outside a storefront request, so the store manager falls back to the default store view | Call ScopeConfigHelper::setScope() with the scope you mean before reading any getter, and resetScope() afterwards |
Known gaps and gotchas
Documented rather than silently fixed — several are load-bearing for existing installs.
- The password is stored in plain text.
sap/api/passwordistype="password"but has no<backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>, so the value lands unencrypted incore_config_data. Adding the backend model later requires re-entering the password on every scope where it is set. - The ACL resource is undefined.
etc/adminhtml/system.xmlguards the section withConneqt_SapBase::config, but noetc/acl.xmlin this module (or inConneqt_Base) declares it. Magento's ACL policy falls back to the role's global permission for unknown resources, so full-access admins see the section, but the permission cannot be granted to a restricted role. TestConnectionhas noADMIN_RESOURCE, so it inheritsMagento_Backend::admin— any logged-in admin can trigger a config-cache flush and a SAP login. It also does not implementHttpGetActionInterface.TestConnectionflushes the entire config cache, not just the three SAP keys, so a connection test evicts the whole shop's cached configuration.??defaults inScopeConfigHelperare dead code.(int)$value ?? 15can never be null, so a missing config value yields0, not the literal in the getter. The real defaults are the ones inetc/config.xml.TestConnectionButton::getButtonHtml()is unused. The template renders its own<button>; the method'ssetLocation()behaviour would navigate away from the config page instead of AJAXing.- TLS verification is disabled (
'verify' => falseinSapClient::getClient()), presumably for self-signed Service Layer certificates. Client::getConfig()(used bysaveCookiesInCache()) is deprecated in Guzzle 7 and removed in Guzzle 8 — this will need replacing with a cookie jar the module holds itself before a Guzzle major upgrade.- JS strings are not translatable.
Connection successful/Connection failedintest-connection.jsare hardcoded, and the button label is literallyClick Me. The module ships noi18n/directory.
File map
CHANGELOG.md release history per version
composer.json package metadata, conneqt/m2-base dependency
registration.php module registration
etc/module.xml declaration + sequence after Conneqt_Base
etc/config.xml default timeouts, retry limit, cooldown, logging
etc/di.xml order repository plugins
etc/extension_attributes.xml order + order address attribute declarations
etc/adminhtml/system.xml SAP configuration section
etc/adminhtml/routes.xml admin route sapbase / sap-base
Helper/ScopeConfigHelper.php store-scoped config lookups
Helper/SapClient.php Guzzle client, login, cookie + cooldown cache
Helper/Api.php GET/POST/PATCH wrapper, logging, retry, re-login
Plugin/Order/SapInternCodePlugin.php order.sap_interncode
Plugin/Order/CardCodePlugin.php order.card_code
Plugin/Order/SapAddressPlugin.php order address sap_address_name / sap_address_type
Setup/Patch/Data/AddCustomerAttributesPatch.php customer.card_code
Setup/Patch/Data/AddCustomerInternCodePatch.php customer.sap_interncode
Setup/Patch/Data/AddAddressAttributesPatch.php customer address SAP fields
Block/Adminhtml/Config/TestConnectionButton.php renders the config button
Controller/Adminhtml/Config/TestConnection.php connection test endpoint (JSON)
view/adminhtml/templates/system/config/test-connection-button.phtml
view/adminhtml/web/js/test-connection.js