diff --git a/doc/commands/businessservice.md b/doc/commands/businessservice.md
index 5533d08b..3ded632c 100644
--- a/doc/commands/businessservice.md
+++ b/doc/commands/businessservice.md
@@ -1,40 +1,36 @@
-# rap
+# rap (deprecated)
-1. [definition activate](#activate)
-2. [biding publish](#publish)
+The `rap` command group is **deprecated**. Both subcommands listed here
+emit a stderr deprecation warning when invoked and delegate to the new
+top-level `srvd` / `srvb` groups. They will be removed in the next
+minor release.
-## definition
+| Old (deprecated) | New equivalent |
+| ------------------------------- | --------------------------- |
+| `sapcli rap definition activate` | `sapcli srvd activate` |
+| `sapcli rap binding publish` | `sapcli srvb publish` |
-### activate
+For full CRUD on Service Definitions and Service Bindings, see:
-Activates the give Business Service Definition
+- [`doc/commands/srvd.md`](srvd.md)
+- [`doc/commands/srvb.md`](srvb.md)
+
+## definition activate (deprecated)
+
+Activates the given Business Service Definition. Same semantics as
+`sapcli srvd activate`; this alias only writes a deprecation warning
+to stderr and forwards.
```bash
sapcli rap definition activate NAME [NAME [NAME ...]]
```
-**Parameters**:
-- `NAME`: A business service definition name to activate
-
-## binding
-
-### publish
+## binding publish (deprecated)
-Publishes a desired oData service name or oData service version in the corresponding service binding
+Publishes a desired oData service name or oData service version in the
+corresponding service binding. Same semantics as `sapcli srvb publish`;
+this alias only writes a deprecation warning to stderr and forwards.
```bash
sapcli rap binding publish BINDING_NAME [--service SERVICE_NAME] [--version SERVICE_VERSION]
```
-
-**Parameters**:
-- `BINDING_NAME`: A business service binding whose service definition will be published
-- `--service SERVICE_NAME`: Name of the service to publish
-- `--version SERVICE_VERSION`: Version of the service to publish
-
-If no SERVICE\_NAME nor SERVICE\_VERSION is supplied and the binding contains only
-one service, that service will be published by default. Otherwise, the command
-will exit with non-0 code and action will be performed.
-
-If SERVICE\_NAME or SERVICE\_VERSION or both values are supplied, a first service
-matching the give parameters will be published. If there is no such a service,
-the operation will be aborted and sapcli will exit with non-0 code.
diff --git a/doc/commands/srvb.md b/doc/commands/srvb.md
new file mode 100644
index 00000000..a8da13cb
--- /dev/null
+++ b/doc/commands/srvb.md
@@ -0,0 +1,125 @@
+# Service Binding (SRVB)
+
+CRUD commands for the ABAP RAP **Service Binding** object (`SRVB/SVB`),
+plus `publish` and a `preview` sub-group for inspecting the bound OData
+service at runtime.
+
+The Service Binding has no `text/plain` source body, so a top-level
+`write` command is not provided - the binding's configuration lives in
+XML attributes/nodes which require a JSON round-trip (planned for a
+future version).
+
+1. [create](#create)
+2. [read](#read)
+3. [activate](#activate)
+4. [publish](#publish)
+5. [delete](#delete)
+6. [whereused](#whereused)
+7. [preview](#preview)
+ - [preview metadata](#preview-metadata)
+ - [preview fetch](#preview-fetch)
+
+## create
+
+Creates a new (inactive) Service Binding wired to an existing Service
+Definition. The server rejects an empty binding, so `--service-definition`
+is required.
+
+```bash
+sapcli srvb create NAME DESCRIPTION PACKAGE \
+ --binding-type {ODATAV2_UI,ODATAV2_API,ODATAV4_UI,ODATAV4_API} \
+ --service-definition SERVICE_DEFINITION_NAME \
+ [--service-version SERVICE_VERSION] \
+ [--corrnr TRANSPORT]
+```
+
+- **--binding-type** - Service Binding type. Selects both contract
+ (OData V2 / V4) and category (`UI` or `API`).
+- **--service-definition** - name of the Service Definition (SRVD) that
+ this binding exposes.
+- **--service-version** - version of the wired Service Definition
+ (default: `0001`).
+- **--corrnr** - transport request number.
+
+## read
+
+Print a structural summary of the binding (name, description, package,
+type, version, published flag, list of bound services).
+
+```bash
+sapcli srvb read NAME
+```
+
+## activate
+
+Activates the given Service Bindings.
+
+```bash
+sapcli srvb activate NAME [NAME ...] [--ignore-errors] [--warning-errors]
+```
+
+- **--ignore-errors** - do not stop activation in case of errors.
+- **--warning-errors** - treat activation warnings as errors.
+
+## publish
+
+Publish the OData / INA / SQL service exposed by the binding to its
+local service endpoint.
+
+```bash
+sapcli srvb publish BINDING_NAME [--service SERVICE_NAME] [--version SERVICE_VERSION]
+```
+
+If the binding contains exactly one service, omitting `--service` and
+`--version` publishes that one. Otherwise, the two filters narrow which
+`` entry is selected.
+
+## delete
+
+Deletes the given Service Bindings.
+
+```bash
+sapcli srvb delete NAME [NAME ...] [--corrnr TRANSPORT]
+```
+
+- **--corrnr** - transport request number.
+
+## whereused
+
+Find objects that reference the given Service Binding.
+
+```bash
+sapcli srvb whereused NAME
+```
+
+## preview
+
+Sub-group of utilities for previewing the OData service exposed by a
+Service Binding without leaving the command line. Each command takes
+the binding name and, when the binding contains more than one Service
+Definition, requires `--service` to disambiguate.
+
+### preview metadata
+
+Download and print the OData `$metadata` document of the bound service.
+
+```bash
+sapcli srvb preview metadata BINDING_NAME [--service SERVICE_NAME]
+```
+
+- **--service** - name of the binding's service to preview. Required
+ only when the binding contains more than one Service Definition.
+
+### preview fetch
+
+Fetch entries of an entity set from the bound OData service and print
+the JSON response.
+
+```bash
+sapcli srvb preview fetch BINDING_NAME ENTITY_SET [--service SERVICE_NAME]
+```
+
+- **ENTITY_SET** - name of the OData entity set to read (e.g.
+ `BookSet`).
+- **--service** - name of the binding's service to preview. Required
+ only when the binding contains more than one Service Definition.
diff --git a/doc/commands/srvd.md b/doc/commands/srvd.md
new file mode 100644
index 00000000..faebd9c5
--- /dev/null
+++ b/doc/commands/srvd.md
@@ -0,0 +1,77 @@
+# Service Definition (SRVD)
+
+CRUD commands for the ABAP RAP **Service Definition** object (`SRVD/SRV`).
+The source body is plain CDS service definition syntax
+(`define service ... { expose ... }`).
+
+1. [create](#create)
+2. [read](#read)
+3. [write](#write)
+4. [activate](#activate)
+5. [delete](#delete)
+6. [whereused](#whereused)
+
+## create
+
+Creates a new (inactive) Service Definition in the given package.
+
+```bash
+sapcli srvd create NAME DESCRIPTION PACKAGE [--corrnr TRANSPORT]
+```
+
+The empty body is created on the server with `srvd:srvdSourceType="S"`.
+Use `sapcli srvd write` to upload the actual `define service { ... }` source.
+
+## read
+
+Download the source of the given Service Definition.
+
+```bash
+sapcli srvd read NAME
+```
+
+## write
+
+Upload source code into an existing Service Definition. The name argument
+can be either an explicit object name, or `-` to deduce the name from the
+file name (in which case multiple file paths are allowed).
+
+```bash
+sapcli srvd write NAME FILEPATH [-a] [--ignore-errors] [--warning-errors] [--check|--no-check] [--corrnr TRANSPORT]
+sapcli srvd write - FILEPATH [FILEPATH ...] [-a] [--ignore-errors] [--warning-errors] [--check|--no-check] [--corrnr TRANSPORT]
+```
+
+Pass `-` as the file name to read source from standard input.
+
+- **-a, --activate** - activate after write.
+- **--ignore-errors** - do not stop activation on errors.
+- **--warning-errors** - treat activation warnings as errors.
+- **--check / --no-check** - run abapCheckRun before write
+ (overrides `SAPCLI_CHECK_BEFORE_SAVE`).
+
+## activate
+
+Activates the given Service Definitions in the listed order.
+
+```bash
+sapcli srvd activate NAME [NAME ...] [--ignore-errors] [--warning-errors]
+```
+
+- **--ignore-errors** - do not stop activation on errors.
+- **--warning-errors** - treat activation warnings as errors.
+
+## delete
+
+Deletes the given Service Definitions.
+
+```bash
+sapcli srvd delete NAME [NAME ...] [--corrnr TRANSPORT]
+```
+
+## whereused
+
+Find objects that reference the given Service Definition.
+
+```bash
+sapcli srvd whereused NAME
+```
diff --git a/sap/adt/businessservice.py b/sap/adt/businessservice.py
index e5b4e62f..841818ea 100644
--- a/sap/adt/businessservice.py
+++ b/sap/adt/businessservice.py
@@ -1,5 +1,7 @@
"""Odataservice ADT wrappers"""
+from typing import Union
+from sap import get_logger
from sap.errors import SAPCliError
from sap.platform.abap import (
from_xml,
@@ -9,19 +11,29 @@
xmlns_adtcore_ancestor,
ADTObject,
ADTObjectType,
+ ADTObjectSourceEditor,
+ ADTRootObject,
OrderedClassMembers,
ADTObjectReferences
)
from sap.adt.annotations import (
XmlNodeAttributeProperty,
XmlNodeProperty,
- XmlContainer
+ XmlListNodeProperty,
)
from sap.adt.marshalling import Marshal
+def mod_log():
+ """ADT Module logger"""
+
+ return get_logger()
+
+
XMLNS_SRVB = xmlns_adtcore_ancestor('srvb', 'http://www.sap.com/adt/ddic/ServiceBindings')
XMLNS_SRVD = xmlns_adtcore_ancestor('srvd', 'http://www.sap.com/adt/ddic/srvdsources')
+XMLNS_ODATAV2 = xmlns_adtcore_ancestor('odatav2', 'http://www.sap.com/categories/odatav2')
+XMLNS_ODATAV4 = xmlns_adtcore_ancestor('odatav4', 'http://www.sap.com/categories/odatav4')
# pylint: disable=too-few-public-methods
@@ -51,7 +63,41 @@ class DefinitionLink(metaclass=OrderedClassMembers):
definition = XmlNodeProperty('srvb:serviceDefinition', factory=Definition)
-ServicesContainer = XmlContainer.define('srvb:content', DefinitionLink)
+class ServicesContainer(metaclass=OrderedClassMembers):
+ """Service container that also carries the srvb:name attribute."""
+
+ name = XmlNodeAttributeProperty('srvb:name')
+ link = XmlNodeProperty('srvb:content', factory=DefinitionLink)
+
+ @property
+ def definition(self):
+ """Backward compatibility property to mirror the original structure of the XML, which had
+ the service definition directly under the services container. This allows
+ existing code that accessed `service.services.link.definition` to continue
+ working without modification.
+ """
+
+ return self.link.definition
+
+ @property
+ def version(self):
+ """Backward compatibility property to mirror the original structure of the XML, which had
+ the service version directly under the services container. This allows
+ existing code that accessed `service.services.version` to continue
+ working without modification.
+ """
+
+ return self.link.version
+
+ @property
+ def release_state(self):
+ """Backward compatibility property to mirror the original structure of the XML, which had
+ the service release state directly under the services container. This allows
+ existing code that accessed `service.services.release_state` to continue
+ working without modification.
+ """
+
+ return self.link.release_state
# pylint: disable=too-few-public-methods
@@ -82,6 +128,138 @@ def term(self):
return f'{self.typ.lower()}{self.version.lower()}'
+# Published Service Info
+
+class ServiceInformationCollection(metaclass=OrderedClassMembers):
+ """ADT service information collection"""
+
+ name = XmlNodeAttributeProperty('serviceInfo:name')
+ is_leading = XmlNodeAttributeProperty('serviceInfo:isLeading')
+ is_root = XmlNodeAttributeProperty('serviceInfo:isRoot')
+
+
+class ServiceInformation(metaclass=OrderedClassMembers):
+ """ADT service information"""
+
+ service_name = XmlNodeAttributeProperty('serviceInfo:name')
+ service_version = XmlNodeAttributeProperty('serviceInfo:version')
+ collection = XmlNodeProperty('serviceInfo:collection', factory=ServiceInformationCollection)
+
+
+# OData V2
+
+class ODataV2ApplicationDetails(metaclass=OrderedClassMembers):
+ """ADT OData V2 Application Details"""
+
+ application_state = XmlNodeAttributeProperty('odatav2:applicationState')
+ application_description = XmlNodeAttributeProperty('odatav2:applicationDescription')
+ application_id = XmlNodeAttributeProperty('odatav2:applicationId')
+
+
+class ODataV2Service(metaclass=OrderedClassMembers):
+ """ADT OData V2 service"""
+
+ repository_id = XmlNodeAttributeProperty('odatav2:repositoryId')
+ service_id = XmlNodeAttributeProperty('odatav2:serviceId')
+ service_version = XmlNodeAttributeProperty('odatav2:serviceVersion')
+ service_url = XmlNodeAttributeProperty('odatav2:serviceUrl')
+ annotation_url = XmlNodeAttributeProperty('odatav2:annotationUrl')
+ created = XmlNodeAttributeProperty('odatav2:created')
+ published = XmlNodeAttributeProperty('odatav2:published')
+ allowed_action = XmlNodeProperty('odatav2:allowedAction')
+
+
+class ODataV2ServiceList(ADTRootObject):
+ """ADT ODataV2 Service Group"""
+
+ OBJTYPE = ADTObjectType(
+ None, # Object code - not used for ServiceGroup because it is not an ABAP object type
+ 'businessservices/odatav2',
+ XMLNS_ODATAV2,
+ ['application/vnd.sap.adt.businessservices.odatav2.v3+xml'],
+ {}, # Content types - not used for ServiceGroup as it is only the XML
+ 'serviceList')
+
+ services = XmlNodeProperty('odatav2:services', factory=ODataV2Service)
+
+ @classmethod
+ def get(cls, connection, name: str, version: str, srvdname: str) -> "ODataV2ServiceList":
+ """Fetches the OData V2 Service Group with the given name and version from the back-end"""
+
+ response = connection.execute(
+ 'GET',
+ cls.OBJTYPE.basepath + f'/{name}',
+ params={
+ 'servicename': name,
+ 'serviceversion': version,
+ 'srvdname': srvdname
+ },
+ accept=cls.OBJTYPE.all_mimetypes,
+ )
+
+ return Marshal().deserialize(response.text, cls())
+
+
+# OData V4
+
+class ODataV4ApplicationDetails(metaclass=OrderedClassMembers):
+ """ADT OData V4 Application Details"""
+
+ application_state = XmlNodeAttributeProperty('odatav4:applicationState')
+ application_description = XmlNodeAttributeProperty('odatav4:applicationDescription')
+ application_id = XmlNodeAttributeProperty('odatav4:applicationId')
+
+
+class ODataV4Service(metaclass=OrderedClassMembers):
+ """ADT OData V4 service"""
+
+ repository_id = XmlNodeAttributeProperty('odatav4:repositoryId')
+ service_id = XmlNodeAttributeProperty('odatav4:serviceId')
+ service_version = XmlNodeAttributeProperty('odatav4:serviceVersion')
+ service_url = XmlNodeAttributeProperty('odatav4:serviceUrl')
+ annotation_url = XmlNodeAttributeProperty('odatav4:annotationUrl')
+ created = XmlNodeAttributeProperty('odatav4:created')
+ service_information = XmlNodeProperty('serviceInfo:serviceInformation', factory=ServiceInformation)
+ application_details = XmlNodeProperty('odatav4:applicationDetails', factory=ODataV4ApplicationDetails)
+
+
+class ODataV4ServiceGroup(ADTRootObject):
+ """ADT ODataV4 Service Group"""
+
+ OBJTYPE = ADTObjectType(
+ None, # Object code - not used for ServiceGroup because it is not an ABAP object type
+ 'businessservices/odatav4',
+ XMLNS_ODATAV4,
+ ['application/vnd.sap.adt.businessservices.odatav4.v2+xml'],
+ {}, # Content types - not used for ServiceGroup as it is only the XML
+ 'serviceGroup')
+
+ published = XmlNodeAttributeProperty('odatav4:published')
+ service_url_prefix = XmlNodeAttributeProperty('odatav4:serviceUrlPrefix')
+ name = XmlNodeAttributeProperty('adtcore:name')
+ services = XmlNodeProperty('odatav4:services', factory=ODataV4Service)
+
+ @classmethod
+ def get(cls, connection, name: str, version: str, srvdname: str) -> "ODataV4ServiceGroup":
+ """Fetches the OData V4 Service Group with the given name and version from the back-end"""
+
+ response = connection.execute(
+ 'GET',
+ cls.OBJTYPE.basepath + f'/{name}',
+ params={
+ 'servicename': name,
+ 'serviceversion': version,
+ 'srvdname': srvdname
+ },
+ accept=cls.OBJTYPE.all_mimetypes,
+ )
+
+ return Marshal().deserialize(response.text, cls())
+
+
+# Service Binding
+
+
class ServiceBinding(ADTObject):
"""Business Service binding abstraction"""
@@ -98,12 +276,37 @@ class ServiceBinding(ADTObject):
release_supported = XmlNodeAttributeProperty('srvb:releaseSupported')
published = XmlNodeAttributeProperty('srvb:published')
bindingCreated = XmlNodeAttributeProperty('srvb:bindingCreated')
- services = XmlNodeProperty('srvb:services', factory=ServicesContainer)
+ services = XmlListNodeProperty('srvb:services', value=[], factory=ServicesContainer)
binding = XmlNodeProperty('srvb:binding', factory=Binding)
- def __init__(self, connection, name, metadata=None):
+ def __init__(self, connection, name, package=None, typ=None, version=None, category=None, metadata=None):
super().__init__(connection, name, metadata)
+ self._metadata.package_reference.name = package
+
+ inner_binding = Binding()
+ inner_binding.typ = typ
+ inner_binding.version = version
+ inner_binding.category = category
+ inner_binding.implementation = Implementation()
+ inner_binding.implementation.name = ''
+
+ self.binding = inner_binding
+
+ def add_service(self, service_name: str, service_definition: str, service_version: str):
+ """Add Service Definition as a new Service"""
+
+ service = ServicesContainer()
+ service.name = service_name
+ service.link = DefinitionLink()
+ service.link.version = service_version
+ service.link.release_state = 'NOT_RELEASED'
+ service.link.definition = Definition()
+ service.link.definition.name = service_definition
+ service.link.definition.typ = 'SRVD/SRV'
+
+ self.services.append(service)
+
def find_service(self, service_name=None, service_version=None):
"""Returns a first service matching the given parameters.
@@ -111,6 +314,10 @@ def find_service(self, service_name=None, service_version=None):
comparison.
"""
+ # `self.services` is a dynamically-built XmlContainer; pylint's static
+ # inference flags iteration over it as not-iterable. Suppress the
+ # false positive locally.
+ # pylint: disable=not-an-iterable
if service_name and service_version:
return next(
(item for item in self.services
@@ -134,6 +341,28 @@ def find_service(self, service_name=None, service_version=None):
raise SAPCliError("You must specify either Service Name or Service Version or both")
+ def get_service_group(self, service: ServicesContainer) -> Union[None | ODataV4ServiceGroup | ODataV2ServiceList]:
+ """Returns the Service Group or None"""
+
+ if self.binding.typ != 'ODATA':
+ mod_log().warning(
+ "Service Binding '%s' is of type '%s', expected 'ODATA'. Cannot fetch Service Group.",
+ self.name,
+ self.binding.typ)
+ return None
+
+ match self.binding.version:
+ case 'V2':
+ return ODataV2ServiceList.get(self.connection, service.name, service.version, service.definition.name)
+ case 'V4':
+ return ODataV4ServiceGroup.get(self.connection, service.name, service.version, service.definition.name)
+
+ mod_log().warning(
+ "Service Binding '%s' has unsupported OData version '%s'. Cannot fetch Service Group.",
+ self.name,
+ self.binding.version)
+ return None
+
def publish(self, service):
"""Publish service definition"""
@@ -166,10 +395,17 @@ class ServiceDefinition(ADTObject):
'ddic/srvd/sources',
XMLNS_SRVD,
['application/vnd.sap.adt.ddic.srvd.v1+xml'],
- {},
- 'srvdSource'
+ {'text/plain': 'source/main'},
+ 'srvdSource',
+ editor_factory=ADTObjectSourceEditor.plain_text
)
+ # Required by the back-end on POST: without it the server rejects with
+ # `ExceptionResourceCreationFailure: Service Definition type '' does not
+ # exist`. Fixed value 'S' (= "Definition") is the only one observed in
+ # captures - 'E' for "Extension" may exist but is out of scope for v1.
+ source_type = XmlNodeAttributeProperty('srvd:srvdSourceType', value='S')
+
def __init__(self, connection, name, package=None, metadata=None):
super().__init__(connection, name, metadata)
diff --git a/sap/adt/core.py b/sap/adt/core.py
index 1d87d289..49e64284 100644
--- a/sap/adt/core.py
+++ b/sap/adt/core.py
@@ -200,14 +200,17 @@ def _get_session(self):
return self._session
- def execute(self, method, adt_uri, params=None, headers=None, body=None, accept=None, content_type=None):
+ def execute(self, method, adt_uri, params=None, headers=None, body=None, accept=None, content_type=None, complete_url=False):
"""Executes the given ADT URI as an HTTP request and returns
the requests response object
"""
session = self._get_session()
- url = self._build_adt_url(adt_uri)
+ if complete_url:
+ url = adt_uri
+ else:
+ url = self._build_adt_url(adt_uri)
if headers is None:
headers = {}
diff --git a/sap/cli/__init__.py b/sap/cli/__init__.py
index ada7e1d6..43164cf2 100644
--- a/sap/cli/__init__.py
+++ b/sap/cli/__init__.py
@@ -51,6 +51,8 @@ def commands():
import sap.cli.featuretoggle
import sap.cli.flp
import sap.cli.rap
+ import sap.cli.srvd
+ import sap.cli.srvb
import sap.cli.table
import sap.cli.badi
import sap.cli.structure
@@ -83,6 +85,8 @@ def commands():
(adt_connection_from_args, sap.cli.adt.CommandGroup()),
(adt_connection_from_args, sap.cli.abapgit.CommandGroup()),
(adt_connection_from_args, sap.cli.rap.CommandGroup()),
+ (adt_connection_from_args, sap.cli.srvd.CommandGroup()),
+ (adt_connection_from_args, sap.cli.srvb.CommandGroup()),
(adt_connection_from_args, sap.cli.table.CommandGroup()),
(adt_connection_from_args, sap.cli.structure.CommandGroup()),
(adt_connection_from_args, sap.cli.dataelement.CommandGroup()),
diff --git a/sap/cli/rap.py b/sap/cli/rap.py
index 7495851d..e1030c06 100644
--- a/sap/cli/rap.py
+++ b/sap/cli/rap.py
@@ -1,5 +1,10 @@
"""
-ADT proxy for service binding commands
+ADT proxy for service binding commands.
+
+Both `rap binding publish` and `rap definition activate` are deprecated
+aliases kept for backwards compatibility. They emit a single-line stderr
+deprecation warning and then delegate to the new `srvb publish` / `srvd
+activate` code paths.
"""
import sap.adt
@@ -7,6 +12,17 @@
import sap.cli.helpers
import sap.cli.wb
import sap.cli.object
+import sap.cli.srvb
+import sap.cli.srvd
+
+
+_DEPRECATION_REMOVAL = 'This alias will be removed in the next minor release.'
+
+
+def _deprecation_warning(old, new):
+ """Format the standard deprecation warning text used by both shims."""
+
+ return f'WARNING: `sapcli rap {old}` is deprecated; use `sapcli {new}`. {_DEPRECATION_REMOVAL}'
class DefinitionGroup(sap.cli.core.CommandGroup):
@@ -49,57 +65,27 @@ def install_parser(self, arg_parser):
@BindingGroup.argument('binding_name')
@BindingGroup.command()
def publish(connection, args):
- """ publish odata service that belongs to a service binding identified by a version
- """
-
- console = args.console_factory()
-
- binding = sap.adt.businessservice.ServiceBinding(connection, args.binding_name)
- binding.fetch()
+ """Deprecated alias for `sapcli srvb publish`."""
- if not binding.services:
- console.printerr(
- f'Business Service Biding {args.binding_name} does not contain any services')
- return 1
+ args.console_factory().printerr(_deprecation_warning('binding publish', 'srvb publish'))
- if args.service is None and args.version is None:
- if len(binding.services) > 1:
- console.printerr(
- f'''Cannot publish Business Service Biding {args.binding_name} without
-Service Definition filters because the business binding contains more than one
-Service Definition''')
- return 1
-
- service = binding.services[0]
- else:
- service = binding.find_service(args.service, args.version)
- if service is None:
- console.printerr(
- f'''Business Service Binding {args.binding_name} has no Service Definition
-with supplied name "{args.service or ''}" and version "{args.version or ''}"''')
- return 1
-
- status = binding.publish(service)
-
- console.printout(status.SHORT_TEXT)
- if status.LONG_TEXT:
- console.printout(status.LONG_TEXT)
-
- if status.SEVERITY != "OK":
- console.printerr(f'Failed to publish Service {service.definition.name} in Binding {args.binding_name}')
- return 1
-
- console.printout(
- f'Service {service.definition.name} in Binding {args.binding_name} published successfully.')
- return 0
+ return sap.cli.srvb.publish_binding(connection, args)
@DefinitionGroup.argument('name', nargs='+')
@DefinitionGroup.command('activate')
def definition_activate(connection, args):
- """Activate Business Service Definition"""
+ """Deprecated alias for `sapcli srvd activate`."""
+
+ args.console_factory().printerr(_deprecation_warning('definition activate', 'srvd activate'))
+
+ # `srvd activate` (CommandGroupObjectMaster.activate_objects) reads
+ # ignore_errors / warning_errors off args; the `rap` subparser does not
+ # declare those flags, so default them to False to keep the legacy
+ # behaviour (continue=False, warnings_as_errors=False).
+ if not hasattr(args, 'ignore_errors'):
+ args.ignore_errors = False
+ if not hasattr(args, 'warning_errors'):
+ args.warning_errors = False
- console = args.console_factory()
- activator = sap.cli.wb.ObjectActivationWorker()
- activated_items = ((name, sap.adt.ServiceDefinition(connection, name)) for name in args.name)
- return sap.cli.object.activate_object_list(activator, activated_items, len(args.name), console)
+ return sap.cli.srvd.CommandGroup().activate_objects(connection, args)
diff --git a/sap/cli/srvb.py b/sap/cli/srvb.py
new file mode 100644
index 00000000..0128ee12
--- /dev/null
+++ b/sap/cli/srvb.py
@@ -0,0 +1,267 @@
+"""ADT proxy for Service Binding (SRVB)"""
+
+import json
+import sap.errors
+import sap.adt
+import sap.cli.object
+import sap.rest.connection
+
+
+class CommandGroupPreview(sap.cli.core.CommandGroup):
+ """Adapter converting command line parameters to sap.adt.ServiceBinding
+ methods calls.
+ """
+
+ def __init__(self):
+ super().__init__('preview', description='Preview utilities for Service Binding (SRVB)')
+
+
+class CommandGroup(sap.cli.object.CommandGroupObjectMaster):
+ """Adapter converting command line parameters to sap.adt.ServiceBinding
+ methods calls.
+ """
+
+ def __init__(self):
+ super().__init__('srvb', description='Service Binding (SRVB)')
+
+ self.command_group_preview = CommandGroupPreview()
+
+ self.define()
+
+ def install_parser(self, arg_parser):
+ super_parser = super().install_parser(arg_parser)
+
+ child_parser = super_parser.add_parser(self.command_group_preview.name)
+ self.command_group_preview.install_parser(child_parser)
+
+ def instance(self, connection, name, args, metadata=None):
+ package = None
+ if hasattr(args, 'package'):
+ package = args.package
+
+ return sap.adt.ServiceBinding(connection, name.upper(), package=package, metadata=metadata)
+
+ def build_new_object(self, connection, args, metadata):
+ # `srvb create` requires --binding-type and --service-definition
+
+ # We can validate by:
+ # GET /sap/bc/adt/businessservices/bindings/bindingtypes HTTP/1.1
+ # Accept: application/vnd.sap.adt.nameditems.v1+xml, application/xml
+ typ, version, category = {
+ 'ODATAV2_UI': ('ODATA', 'V2', '0'),
+ 'ODATAV2_API': ('ODATA', 'V2', '1'),
+ 'ODATAV4_UI': ('ODATA', 'V4', '0'),
+ 'ODATAV4_API': ('ODATA', 'V4', '1'),
+ }.get(args.binding_type, (None, None, None))
+
+ binding = sap.adt.ServiceBinding(
+ connection, args.name.upper(),
+ package=args.package,
+ typ=typ,
+ version=version,
+ category=category,
+ metadata=metadata,
+ )
+
+ # The SRVB API requires at least one service to be present on creation
+ # Args: new service name, service definition, and new service version
+ binding.add_service(args.name.upper(), args.service_definition.upper(), args.service_version)
+
+ return binding
+
+ def define_create(self, commands):
+ create_cmd = super().define_create(commands)
+ create_cmd.append_argument('--binding-type', dest='binding_type',
+ choices=[
+ 'ODATAV2_UI',
+ 'ODATAV2_API',
+ 'ODATAV4_UI',
+ 'ODATAV4_API',
+ ],
+ required=True,
+ help='Service Binding type')
+ create_cmd.append_argument('--service-definition', dest='service_definition', required=True,
+ help='Name of the Service Definition (SRVD) to wire into the binding')
+ create_cmd.append_argument('--service-version', dest='service_version', default='0001',
+ help='Version of the wired Service Definition (default: 0001)')
+ return create_cmd
+
+ def define_write(self, commands):
+ # No `srvb write` in v1: SRVB has no text/plain source body, so the
+ # default plain-text editor would not work. v2 will replace this with
+ # a JSON round-trip implementation; explicit None disables the
+ # write subcommand for now.
+ return None
+
+ def read_object_text(self, connection, args):
+ # Override the default `obj.text` printer because Service Bindings
+ # have no source body. Print a structural summary instead.
+ console = args.console_factory()
+
+ binding = self.instance(connection, args.name, args)
+ binding.fetch()
+
+ # `binding.reference` always exists (it's the embedded packageRef
+ # object); only `.name` is populated by fetch(). The inner
+ # `binding.binding` and `binding.services` may be unset on edge cases
+ # so guard them with `if`.
+ package = binding.reference.name or ''
+ typ = binding.binding.typ if binding.binding else ''
+ version = binding.binding.version if binding.binding else ''
+
+ console.printout(f'Name : {binding.name}')
+ console.printout(f'Description : {binding.description or ""}')
+ console.printout(f'Package : {package}')
+ console.printout(f'Type : {typ}')
+ console.printout(f'Version : {version}')
+ console.printout(f'Published : {binding.published or "false"}')
+
+ if binding.services:
+ console.printout('Services:')
+ # pylint: disable=not-an-iterable
+
+ for link in binding.services:
+ name = link.name
+ ver = link.version or ''
+ state = link.release_state or ''
+ console.printout(f' {name} (version {ver}, {state})')
+ service_group = binding.get_service_group(link)
+ if service_group:
+ console.printout(f' URL: {service_group.services.service_url}')
+
+
+def publish_binding(connection, args):
+ """Publish a Service Binding's service to its local endpoint.
+
+ Logic mirrors the legacy `sap.cli.rap.publish` handler verbatim — when
+ the binding contains exactly one service, omitting `--service`/`--version`
+ publishes that one; otherwise the two filters narrow which content entry
+ is selected.
+ """
+
+ console = args.console_factory()
+
+ binding = sap.adt.ServiceBinding(connection, args.binding_name)
+ binding.fetch()
+
+ if not binding.services:
+ console.printerr(
+ f'Business Service Biding {args.binding_name} does not contain any services')
+ return 1
+
+ if args.service is None and args.version is None:
+ if len(binding.services) > 1:
+ console.printerr(
+ f'''Cannot publish Business Service Biding {args.binding_name} without
+Service Definition filters because the business binding contains more than one
+Service Definition''')
+ return 1
+
+ # pylint: disable=unsubscriptable-object
+ service = binding.services[0]
+ else:
+ service = binding.find_service(args.service, args.version)
+ if service is None:
+ console.printerr(
+ f'''Business Service Binding {args.binding_name} has no Service Definition
+with supplied name "{args.service or ''}" and version "{args.version or ''}"''')
+ return 1
+
+ status = binding.publish(service)
+
+ console.printout(status.SHORT_TEXT)
+ if status.LONG_TEXT:
+ console.printout(status.LONG_TEXT)
+
+ if status.SEVERITY != 'OK':
+ console.printerr(
+ f'Failed to publish Service {service.definition.name} in Binding {args.binding_name}')
+ return 1
+
+ console.printout(
+ f'Service {service.definition.name} in Binding {args.binding_name} published successfully.')
+ return 0
+
+
+# Hook `publish` into the `srvb` command group via the class-level decorators.
+# The decorator runs at module load time and registers the command into
+# CommandGroup.commands; the order with respect to define() in __init__
+# is irrelevant because both feed into the same class-level CommandsList.
+@CommandGroup.argument('--version', nargs='?', default=None,
+ help="Version of the binding's services to publish")
+@CommandGroup.argument('--service', nargs='?', default=None,
+ help="Service name of the binding's services to publish")
+@CommandGroup.argument('binding_name')
+@CommandGroup.command('publish')
+def publish(connection, args):
+ """Publish odata/ina/sql service that belongs to a service binding."""
+
+ return publish_binding(connection, args)
+
+
+def _get_service_with_binding_group(connection, args):
+
+ binding = sap.adt.ServiceBinding(connection, args.binding_name.upper())
+ binding.fetch()
+
+ if not binding.services:
+ raise sap.errors.SAPCliError(f'Business Service Biding {args.binding_name} does not contain any services')
+
+ if len(binding.services) > 1 and args.service is None:
+ raise sap.errors.SAPCliError(
+ f'Business Service Biding {args.binding_name} contains more than one Service Definition; '
+ 'use --service to specify which one to preview')
+
+ if args.service is not None:
+ for link in binding.services:
+ name = link.name
+ if name == args.service:
+ break
+ else:
+ raise sap.errors.SAPCliError(
+ f'Business Service Biding {args.binding_name} has no Service Definition '
+ f'with supplied name "{args.service}"')
+ else:
+ # pylint: disable=unsubscriptable-object
+ link = binding.services[0]
+
+ service_group = binding.get_service_group(link)
+ if service_group is None:
+ raise sap.errors.SAPCliError(
+ f'Failed to retrieve service group for Service Definition {link.name} in '
+ f'Binding {args.binding_name}')
+
+ return binding, service_group
+
+
+@CommandGroupPreview.argument('--service', nargs='?', default=None,
+ help="Service name of the binding's services to preview")
+@CommandGroupPreview.argument('binding_name')
+@CommandGroupPreview.command('metadata')
+def preview_metadata(connection, args):
+ """Download and print the OData metadata document for one of the services in a Service Binding."""
+
+ console = args.console_factory()
+ _, service_group = _get_service_with_binding_group(connection, args)
+ metadata = connection.execute('GET', service_group.services.service_url + '/$metadata', complete_url=True).text
+ console.printout(metadata)
+
+
+@CommandGroupPreview.argument('--service', nargs='?', default=None,
+ help="Service name of the binding's services to preview")
+@CommandGroupPreview.argument('entity_set')
+@CommandGroupPreview.argument('binding_name')
+@CommandGroupPreview.command('fetch')
+def preview_fetch(connection, args):
+ """Fetch and print entries from the specified entity set of one of the services in a Service Binding."""
+
+ console = args.console_factory()
+ _, service_group = _get_service_with_binding_group(connection, args)
+ data = connection.execute(
+ 'GET',
+ service_group.services.service_url + '/' + args.entity_set,
+ accept='application/json',
+ complete_url=True
+ ).json()
+
+ console.printout(json.dumps(data, indent=2))
diff --git a/sap/cli/srvd.py b/sap/cli/srvd.py
new file mode 100644
index 00000000..f30a3557
--- /dev/null
+++ b/sap/cli/srvd.py
@@ -0,0 +1,22 @@
+"""ADT proxy for Service Definition (SRVD)"""
+
+import sap.adt
+import sap.cli.object
+
+
+class CommandGroup(sap.cli.object.CommandGroupObjectMaster):
+ """Adapter converting command line parameters to sap.adt.ServiceDefinition
+ methods calls.
+ """
+
+ def __init__(self):
+ super().__init__('srvd', description='Service Definition (SRVD)')
+
+ self.define()
+
+ def instance(self, connection, name, args, metadata=None):
+ package = None
+ if hasattr(args, 'package'):
+ package = args.package
+
+ return sap.adt.ServiceDefinition(connection, name.upper(), package=package, metadata=metadata)
diff --git a/test/system/test_cases/60-publish_service_binding_odata_v2_ui.sh b/test/system/test_cases/60-publish_service_binding_odata_v2_ui.sh
new file mode 100755
index 00000000..ce98aa0d
--- /dev/null
+++ b/test/system/test_cases/60-publish_service_binding_odata_v2_ui.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/bash
+
+set -o nounset
+set -o errexit
+set -o pipefail
+
+_tcn="61"
+_round="0"
+
+DDLS_NAME="ZAPCLI_ST${_tcn}_DDLS_O2UI_${_round}"
+SRVD_NAME="ZAPCLI_ST${_tcn}_SRVD_O2UI_${_round}"
+SRVB_NAME="ZAPCLI_ST${_tcn}_SRVB_O2UI_${_round}"
+
+sapcli ddl create ${DDLS_NAME} "sapcli system test ${_tcn}" '$tmp'
+
+sapcli ddl write -a ${DDLS_NAME} - <<_EOF
+@AccessControl.authorizationCheck: #NOT_REQUIRED
+@EndUserText.label: 'CDS View for T000'
+define view entity ${DDLS_NAME}
+ as select from t000
+{
+ key mandt,
+ logsys,
+ ort01,
+ mtext
+}
+_EOF
+
+sapcli srvd create ${SRVD_NAME} "sapcli system test ${_tcn}" '$TMP'
+
+sapcli srvd write -a ${SRVD_NAME} - <<_EOF
+@EndUserText.label: 'sapcli system test - service definition'
+define service ${SRVD_NAME} {
+ expose ${DDLS_NAME};
+}
+_EOF
+
+sapcli srvb create ${SRVB_NAME} "sapcli system test ${_tcn}" '$TMP' --binding-type ODATAV2_UI --service-definition ${SRVD_NAME}
+
+sapcli srvb activate ${SRVB_NAME}
+
+sapcli srvb publish ${SRVB_NAME}
+
+sapcli srvb read ${SRVB_NAME}
+
+sapcli srvb preview metadata ${SRVB_NAME}
+
+sapcli srvb preview fetch ${SRVB_NAME} ${DDLS_NAME}
diff --git a/test/system/test_cases/61-publish_service_binding_odata_v2_api.sh b/test/system/test_cases/61-publish_service_binding_odata_v2_api.sh
new file mode 100755
index 00000000..f8cd2fd8
--- /dev/null
+++ b/test/system/test_cases/61-publish_service_binding_odata_v2_api.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/bash
+
+set -o nounset
+set -o errexit
+set -o pipefail
+
+_tcn="61"
+_round="0"
+
+DDLS_NAME="ZAPCLI_ST${_tcn}_DDLS_O2UI_${_round}"
+SRVD_NAME="ZAPCLI_ST${_tcn}_SRVD_O2UI_${_round}"
+SRVB_NAME="ZAPCLI_ST${_tcn}_SRVB_O2UI_${_round}"
+
+sapcli ddl create ${DDLS_NAME} "sapcli system test ${_tcn}" '$tmp'
+
+sapcli ddl write -a ${DDLS_NAME} - <<_EOF
+@AccessControl.authorizationCheck: #NOT_REQUIRED
+@EndUserText.label: 'CDS View for T000'
+define view entity ${DDLS_NAME}
+ as select from t000
+{
+ key mandt,
+ logsys,
+ ort01,
+ mtext
+}
+_EOF
+
+sapcli srvd create ${SRVD_NAME} "sapcli system test ${_tcn}" '$TMP'
+
+sapcli srvd write -a ${SRVD_NAME} - <<_EOF
+@EndUserText.label: 'sapcli system test - service definition'
+define service ${SRVD_NAME} {
+ expose ${DDLS_NAME};
+}
+_EOF
+
+sapcli srvb create ${SRVB_NAME} "sapcli system test ${_tcn}" '$TMP' --binding-type ODATAV2_API --service-definition ${SRVD_NAME}
+
+sapcli srvb activate ${SRVB_NAME}
+
+sapcli srvb publish ${SRVB_NAME}
+
+sapcli srvb read ${SRVB_NAME}
+
+sapcli srvb preview metadata ${SRVB_NAME}
+
+sapcli srvb preview fetch ${SRVB_NAME} ${DDLS_NAME}
diff --git a/test/system/test_cases/62-publish_service_binding_odata_v4_ui.sh b/test/system/test_cases/62-publish_service_binding_odata_v4_ui.sh
new file mode 100755
index 00000000..def32564
--- /dev/null
+++ b/test/system/test_cases/62-publish_service_binding_odata_v4_ui.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/bash
+
+set -o nounset
+set -o errexit
+set -o pipefail
+
+_tcn="62"
+_round="0"
+
+DDLS_NAME="ZAPCLI_ST${_tcn}_DDLS_O2UI_${_round}"
+SRVD_NAME="ZAPCLI_ST${_tcn}_SRVD_O2UI_${_round}"
+SRVB_NAME="ZAPCLI_ST${_tcn}_SRVB_O2UI_${_round}"
+
+sapcli ddl create ${DDLS_NAME} "sapcli system test ${_tcn}" '$tmp'
+
+sapcli ddl write -a ${DDLS_NAME} - <<_EOF
+@AccessControl.authorizationCheck: #NOT_REQUIRED
+@EndUserText.label: 'CDS View for T000'
+define view entity ${DDLS_NAME}
+ as select from t000
+{
+ key mandt,
+ logsys,
+ ort01,
+ mtext
+}
+_EOF
+
+sapcli srvd create ${SRVD_NAME} "sapcli system test ${_tcn}" '$TMP'
+
+sapcli srvd write -a ${SRVD_NAME} - <<_EOF
+@EndUserText.label: 'sapcli system test - service definition'
+define service ${SRVD_NAME} {
+ expose ${DDLS_NAME};
+}
+_EOF
+
+sapcli srvb create ${SRVB_NAME} "sapcli system test ${_tcn}" '$TMP' --binding-type ODATAV4_UI --service-definition ${SRVD_NAME}
+
+sapcli srvb activate ${SRVB_NAME}
+
+sapcli srvb publish ${SRVB_NAME}
+
+sapcli srvb read ${SRVB_NAME}
+
+sapcli srvb preview metadata ${SRVB_NAME}
+
+sapcli srvb preview fetch ${SRVB_NAME} ${DDLS_NAME}
diff --git a/test/system/test_cases/63-publish_service_binding_odata_v4_api.sh b/test/system/test_cases/63-publish_service_binding_odata_v4_api.sh
new file mode 100755
index 00000000..9f685502
--- /dev/null
+++ b/test/system/test_cases/63-publish_service_binding_odata_v4_api.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/bash
+
+set -o nounset
+set -o errexit
+set -o pipefail
+
+_tcn="63"
+_round="0"
+
+DDLS_NAME="ZAPCLI_ST${_tcn}_DDLS_O2UI_${_round}"
+SRVD_NAME="ZAPCLI_ST${_tcn}_SRVD_O2UI_${_round}"
+SRVB_NAME="ZAPCLI_ST${_tcn}_SRVB_O2UI_${_round}"
+
+sapcli ddl create ${DDLS_NAME} "sapcli system test ${_tcn}" '$tmp'
+
+sapcli ddl write -a ${DDLS_NAME} - <<_EOF
+@AccessControl.authorizationCheck: #NOT_REQUIRED
+@EndUserText.label: 'CDS View for T000'
+define view entity ${DDLS_NAME}
+ as select from t000
+{
+ key mandt,
+ logsys,
+ ort01,
+ mtext
+}
+_EOF
+
+sapcli srvd create ${SRVD_NAME} "sapcli system test ${_tcn}" '$TMP'
+
+sapcli srvd write -a ${SRVD_NAME} - <<_EOF
+@EndUserText.label: 'sapcli system test - service definition'
+define service ${SRVD_NAME} {
+ expose ${DDLS_NAME};
+}
+_EOF
+
+sapcli srvb create ${SRVB_NAME} "sapcli system test ${_tcn}" '$TMP' --binding-type ODATAV4_API --service-definition ${SRVD_NAME}
+
+sapcli srvb activate ${SRVB_NAME}
+
+sapcli srvb publish ${SRVB_NAME}
+
+sapcli srvb read ${SRVB_NAME}
+
+sapcli srvb preview metadata ${SRVB_NAME}
+
+sapcli srvb preview fetch ${SRVB_NAME} ${DDLS_NAME}
diff --git a/test/unit/fixtures_adt_businessservice.py b/test/unit/fixtures_adt_businessservice.py
index 4b2f7c40..ca2e2534 100644
--- a/test/unit/fixtures_adt_businessservice.py
+++ b/test/unit/fixtures_adt_businessservice.py
@@ -1,3 +1,12 @@
+SERVICE_DEFINITION_NAME = 'ZSAPCLI_TEST_SRV'
+SERVICE_DEFINITION_PACKAGE = '$TMP'
+
+SERVICE_DEFINITION_SOURCE_TEXT = '''@EndUserText.label: 'Test service definition'
+define service ZSAPCLI_TEST_SRV {
+ expose ZSAPCLI_TEST_VIEW as Travel;
+}
+'''
+
SERVICE_DEFINITION_ADT_XML = '''
'''
+
+
+SERVICE_DEFINITION_ADT_POST_REQUEST_XML = '''
+
+
+'''
+
+
+SERVICE_BINDING_NAME = 'ZSAPCLI_TEST_BND'
+SERVICE_BINDING_PACKAGE = '$TMP'
+
+SERVICE_BINDING_ADT_POST_ODATA_V4_REQUEST_XML_UI = '''
+
+
+
+
+
+
+
+
+
+
+'''
+
+
+SERVICE_BINDING_ADT_POST_ODATA_V4_REQUEST_XML_API = '''
+
+
+
+
+
+
+
+
+
+
+'''
+
+
+SERVICE_BINDING_ADT_GET_V4_XML = '''
+
+
+
+
+
+
+
+
+
+
+
+
+
+'''
+
+
+SERVICE_BINDING_PUBLISH_OK_XML = '''
+
+
+
+ OK
+ Service published successfully
+
+
+
+
+'''
+
+
+SERVICE_BINDING_PUBLISH_FAIL_XML = '''
+
+
+
+ ERROR
+ Local Publish of ZSAPCLI_TEST_SRV failed
+ Service Binding ZSAPCLI_TEST_BND does not exist.
+
+
+
+'''
+
+SERVICE_GROUP_ODATAV4_GET_XML = '''
+
+
+
+
+
+
+
+
+
+
+
+
+'''
+
+SERVICE_GROUP_ODATAV2_GET_XML = '''
+
+
+
+
+
+
+
+
+'''
diff --git a/test/unit/test_sap_adt_businessservice.py b/test/unit/test_sap_adt_businessservice.py
index 1639a711..8efc36d6 100644
--- a/test/unit/test_sap_adt_businessservice.py
+++ b/test/unit/test_sap_adt_businessservice.py
@@ -11,8 +11,15 @@
from mock import Connection, Response, Request
+from fixtures_adt import LOCK_RESPONSE_OK, EMPTY_RESPONSE_OK
from fixtures_adt_businessservice import (
SERVICE_DEFINITION_ADT_XML,
+ SERVICE_DEFINITION_SOURCE_TEXT,
+ SERVICE_DEFINITION_ADT_POST_REQUEST_XML,
+ SERVICE_BINDING_ADT_POST_ODATA_V4_REQUEST_XML_UI,
+ SERVICE_BINDING_ADT_POST_ODATA_V4_REQUEST_XML_API,
+ SERVICE_GROUP_ODATAV4_GET_XML,
+ SERVICE_GROUP_ODATAV2_GET_XML,
)
SAMPLE_ODATA_BINDING_V2 = '''
@@ -31,6 +38,8 @@
+
+
@@ -149,7 +158,7 @@ def test_service_published(self):
binding = sap.adt.businessservice.ServiceBinding(connection, binding_name)
binding.fetch()
- status = binding.publish(binding.services[1])
+ status = binding.publish(binding.services[0])
connection.execs[1].assertEqual(
Request.post(
@@ -160,7 +169,7 @@ def test_service_published(self):
},
params={
'servicename': 'TEST_BINDING',
- 'serviceversion': '0002'
+ 'serviceversion': '0001'
},
body=SAMPLE_BINDING_OBJECT_REFERENCE
),
@@ -241,3 +250,346 @@ def test_binding_fetch(self):
self.assertEqual(definition.name, definition_name)
self.assertEqual(definition.description, 'Example Configuration')
self.assertEqual(definition.reference.name, 'EXAMPLE_CONFIG')
+ # `srvd:srvdSourceType` is REQUIRED on POST and round-trips through GET.
+ # The captured fixture carries srvd:srvdSourceType="S" - assert it
+ # so the field stays wired even if someone refactors the class.
+ self.assertEqual(definition.source_type, 'S')
+
+ def test_servicedefinition_default_source_type_is_S(self):
+ # New, in-memory ServiceDefinition must default source_type='S' so
+ # the create POST always carries srvd:srvdSourceType (the back-end
+ # rejects POST bodies without it - see live e2e captures).
+ srvd = sap.adt.businessservice.ServiceDefinition(Connection(), 'ZNEW_SRV')
+ self.assertEqual(srvd.source_type, 'S')
+
+ def test_servicedefinition_text_property_round_trip(self):
+ conn = Connection([Response(text=SERVICE_DEFINITION_SOURCE_TEXT,
+ status_code=200,
+ headers={'Content-Type': 'text/plain; charset=utf-8'})])
+
+ srvd = sap.adt.businessservice.ServiceDefinition(conn, name='ZSAPCLI_TEST_SRV')
+ code = srvd.text
+
+ self.assertEqual(conn.mock_methods(), [('GET', '/sap/bc/adt/ddic/srvd/sources/zsapcli_test_srv/source/main')])
+
+ get_request = conn.execs[0]
+ self.assertEqual(get_request.headers['Accept'], 'text/plain')
+ self.assertIsNone(get_request.params)
+ self.assertIsNone(get_request.body)
+
+ self.assertEqual(code, SERVICE_DEFINITION_SOURCE_TEXT)
+
+ def test_servicedefinition_open_editor_writes_source(self):
+ conn = Connection([LOCK_RESPONSE_OK, EMPTY_RESPONSE_OK, None])
+
+ srvd = sap.adt.businessservice.ServiceDefinition(conn, name='ZSAPCLI_TEST_SRV')
+ with srvd.open_editor() as editor:
+ editor.write(SERVICE_DEFINITION_SOURCE_TEXT)
+
+ self.assertEqual(len(conn.execs), 3)
+
+ put_request = conn.execs[1]
+ self.assertEqual(put_request.method, 'PUT')
+ self.assertEqual(put_request.adt_uri, '/sap/bc/adt/ddic/srvd/sources/zsapcli_test_srv/source/main')
+ self.assertEqual(put_request.headers, {'Content-Type': 'text/plain; charset=utf-8'})
+ self.assertEqual(put_request.params, {'lockHandle': 'win'})
+ self.assertEqual(put_request.body, bytes(SERVICE_DEFINITION_SOURCE_TEXT[:-1], 'utf-8'))
+
+ def test_servicedefinition_create_serializes_post_body(self):
+ conn = Connection([EMPTY_RESPONSE_OK])
+
+ metadata = sap.adt.ADTCoreData(language='EN', master_language='EN', responsible='DEVELOPER')
+ srvd = sap.adt.businessservice.ServiceDefinition(
+ conn, 'ZSAPCLI_TEST_SRV', package='$TMP', metadata=metadata)
+ srvd.description = 'Test service definition'
+ srvd.create()
+
+ self.assertEqual(len(conn.execs), 1)
+ post = conn.execs[0]
+ self.assertEqual(post.method, 'POST')
+ self.assertEqual(post.adt_uri, '/sap/bc/adt/ddic/srvd/sources')
+
+ self.maxDiff = None
+ self.assertEqual(post.body.decode('utf-8'), SERVICE_DEFINITION_ADT_POST_REQUEST_XML)
+
+
+class TestServiceBindingInit(unittest.TestCase):
+ '''Constructor extensions: package, typ, version arguments'''
+
+ def test_init_without_typ_version_keeps_binding_unset(self):
+ # backward compatibility: existing rap binding publish callers pass
+ # only (connection, name) and expect self.binding to remain None until
+ # fetch() populates it.
+ binding = sap.adt.businessservice.ServiceBinding(Connection(), 'TEST_BINDING')
+
+ self.assertIsNone(binding.binding.typ)
+ self.assertIsNone(binding.binding.version)
+ self.assertIsNone(binding.binding.category)
+ self.assertIsNotNone(binding.binding.implementation)
+ self.assertEqual(binding.binding.implementation.name, '')
+ self.assertEqual(binding.services, [])
+
+ def test_init_with_typ_version_sets_binding_node_ui(self):
+ binding = sap.adt.businessservice.ServiceBinding(
+ Connection(), 'ZSAPCLI_TEST_BND',
+ package='$TMP',
+ typ='ODATA',
+ version='V4',
+ category='0',
+ )
+
+ self.assertIsNotNone(binding.binding)
+ self.assertEqual(binding.binding.typ, 'ODATA')
+ self.assertEqual(binding.binding.version, 'V4')
+ self.assertEqual(binding.binding.category, '0')
+ self.assertIsNotNone(binding.binding.implementation)
+ self.assertEqual(binding.binding.implementation.name, '')
+
+ def test_init_with_typ_version_sets_binding_node_api(self):
+ binding = sap.adt.businessservice.ServiceBinding(
+ Connection(), 'ZSAPCLI_TEST_BND',
+ package='$TMP',
+ typ='ODATA',
+ version='V4',
+ category='1'
+ )
+
+ self.assertIsNotNone(binding.binding)
+ self.assertEqual(binding.binding.typ, 'ODATA')
+ self.assertEqual(binding.binding.version, 'V4')
+ self.assertEqual(binding.binding.category, '1')
+ self.assertIsNotNone(binding.binding.implementation)
+ self.assertEqual(binding.binding.implementation.name, '')
+
+ def test_init_with_service_creates_services_link(self):
+ binding = sap.adt.businessservice.ServiceBinding(
+ Connection(), 'ZSAPCLI_TEST_BND',
+ package='$TMP',
+ typ='ODATA',
+ version='V4',
+ category='1',
+ )
+
+ # Live captures show always equals the
+ # parent binding's name. The constructor must mirror that.
+ binding.add_service('SERVICE_NAME', 'BINDING_NAME', '0001')
+ self.assertEqual(len(binding.services), 1)
+
+ link = binding.services[0]
+
+ self.assertEqual(link.name, 'SERVICE_NAME')
+ self.assertEqual(link.version, '0001')
+ self.assertEqual(link.release_state, 'NOT_RELEASED')
+ self.assertEqual(link.definition.name, 'BINDING_NAME')
+ self.assertEqual(link.definition.typ, 'SRVD/SRV')
+
+ def test_init_creates_full_post_body_ui(self):
+ conn = Connection([EMPTY_RESPONSE_OK])
+
+ metadata = sap.adt.ADTCoreData(language='EN', master_language='EN', responsible='DEVELOPER')
+ binding = sap.adt.businessservice.ServiceBinding(
+ conn, 'ZSAPCLI_TEST_BND',
+ package='$TMP',
+ typ='ODATA', version='V4', category='0',
+ metadata=metadata,
+ )
+ binding.description = 'Test service binding'
+ binding.add_service('ZSAPCLI_TEST_BND', 'ZSAPCLI_TEST_SRV', '0001')
+ binding.create()
+
+ self.assertEqual(len(conn.execs), 1)
+ post = conn.execs[0]
+ self.assertEqual(post.method, 'POST')
+ self.assertEqual(post.adt_uri, '/sap/bc/adt/businessservices/bindings')
+
+ self.maxDiff = None
+ self.assertEqual(post.body.decode('utf-8'), SERVICE_BINDING_ADT_POST_ODATA_V4_REQUEST_XML_UI)
+
+ def test_init_creates_full_post_body_api(self):
+ conn = Connection([EMPTY_RESPONSE_OK])
+
+ metadata = sap.adt.ADTCoreData(language='EN', master_language='EN', responsible='DEVELOPER')
+ binding = sap.adt.businessservice.ServiceBinding(
+ conn, 'ZSAPCLI_TEST_BND',
+ package='$TMP',
+ typ='ODATA', version='V4', category='1',
+ metadata=metadata,
+ )
+ binding.description = 'Test service binding'
+ binding.add_service('ZSAPCLI_TEST_BND', 'ZSAPCLI_TEST_SRV', '0001')
+ binding.create()
+
+ self.assertEqual(len(conn.execs), 1)
+ post = conn.execs[0]
+ self.assertEqual(post.method, 'POST')
+ self.assertEqual(post.adt_uri, '/sap/bc/adt/businessservices/bindings')
+
+ self.maxDiff = None
+ self.assertEqual(post.body.decode('utf-8'), SERVICE_BINDING_ADT_POST_ODATA_V4_REQUEST_XML_API)
+
+
+class TestServiceBindingGetServiceGroup(unittest.TestCase):
+ '''ServiceBinding.get_service_group() path coverage tests.
+
+ The method has four execution paths:
+ 1. binding.typ != 'ODATA' -> warn + None
+ 2. binding.typ == 'ODATA' and version == 'V2' -> delegate to ODataV2ServiceList.get
+ 3. binding.typ == 'ODATA' and version == 'V4' -> delegate to ODataV4ServiceGroup.get
+ 4. binding.typ == 'ODATA' and unknown version -> warn + None
+ '''
+
+ def _make_binding(self, typ, version):
+ binding = sap.adt.businessservice.ServiceBinding(Connection(), 'TEST_BINDING')
+ binding.binding.typ = typ
+ binding.binding.version = version
+ return binding
+
+ def _make_service(self, name='SRV_NAME', version='0001', definition_name='SRV_DEF'):
+ service = sap.adt.businessservice.ServicesContainer()
+ service.name = name
+ service.link = sap.adt.businessservice.DefinitionLink()
+ service.link.version = version
+ service.link.definition = sap.adt.businessservice.Definition()
+ service.link.definition.name = definition_name
+ return service
+
+ def test_get_service_group_returns_none_when_not_odata(self):
+ binding = self._make_binding(typ='REST', version='V2')
+ service = self._make_service()
+
+ with self.assertLogs(level='WARNING') as captured:
+ result = binding.get_service_group(service)
+
+ self.assertIsNone(result)
+ self.assertEqual(len(captured.records), 1)
+ self.assertIn("TEST_BINDING", captured.records[0].getMessage())
+ self.assertIn("REST", captured.records[0].getMessage())
+ self.assertIn("expected 'ODATA'", captured.records[0].getMessage())
+
+ def test_get_service_group_v2_delegates_to_odatav2_service_list(self):
+ binding = self._make_binding(typ='ODATA', version='V2')
+ service = self._make_service(name='SRV_NAME', version='0001', definition_name='SRV_DEF')
+
+ sentinel = object()
+ with mock.patch.object(
+ sap.adt.businessservice.ODataV2ServiceList, 'get', return_value=sentinel) as v2_get, \
+ mock.patch.object(
+ sap.adt.businessservice.ODataV4ServiceGroup, 'get') as v4_get:
+ result = binding.get_service_group(service)
+
+ self.assertIs(result, sentinel)
+ v2_get.assert_called_once_with(binding.connection, 'SRV_NAME', '0001', 'SRV_DEF')
+ v4_get.assert_not_called()
+
+ def test_get_service_group_v4_delegates_to_odatav4_service_group(self):
+ binding = self._make_binding(typ='ODATA', version='V4')
+ service = self._make_service(name='SRV_NAME', version='0002', definition_name='SRV_DEF')
+
+ sentinel = object()
+ with mock.patch.object(
+ sap.adt.businessservice.ODataV4ServiceGroup, 'get', return_value=sentinel) as v4_get, \
+ mock.patch.object(
+ sap.adt.businessservice.ODataV2ServiceList, 'get') as v2_get:
+ result = binding.get_service_group(service)
+
+ self.assertIs(result, sentinel)
+ v4_get.assert_called_once_with(binding.connection, 'SRV_NAME', '0002', 'SRV_DEF')
+ v2_get.assert_not_called()
+
+ def test_get_service_group_returns_none_for_unsupported_odata_version(self):
+ binding = self._make_binding(typ='ODATA', version='V9')
+ service = self._make_service()
+
+ with mock.patch.object(sap.adt.businessservice.ODataV2ServiceList, 'get') as v2_get, \
+ mock.patch.object(sap.adt.businessservice.ODataV4ServiceGroup, 'get') as v4_get, \
+ self.assertLogs(level='WARNING') as captured:
+ result = binding.get_service_group(service)
+
+ self.assertIsNone(result)
+ v2_get.assert_not_called()
+ v4_get.assert_not_called()
+ self.assertEqual(len(captured.records), 1)
+ self.assertIn("TEST_BINDING", captured.records[0].getMessage())
+ self.assertIn("V9", captured.records[0].getMessage())
+ self.assertIn('unsupported OData version', captured.records[0].getMessage())
+
+
+class TestODataV4ServiceGroupGet(unittest.TestCase):
+ '''ODataV4ServiceGroup.get() HTTP request tests'''
+
+ def test_service_group_get_sends_expected_request(self):
+ connection = Connection([Response(
+ text=SERVICE_GROUP_ODATAV4_GET_XML,
+ status_code=200,
+ content_type='application/vnd.sap.adt.businessservices.odatav4.v2+xml; charset=utf-8'
+ )])
+
+ sap.adt.businessservice.ODataV4ServiceGroup.get(
+ connection,
+ 'ZSCLI_SVCDEMO_C',
+ '0001',
+ 'ZSCLI_SVCDEMO_S',
+ )
+
+ self.assertEqual(len(connection.execs), 1)
+
+ get_request = connection.execs[0]
+ self.assertEqual(get_request.method, 'GET')
+ self.assertEqual(
+ get_request.adt_uri,
+ '/sap/bc/adt/businessservices/odatav4/ZSCLI_SVCDEMO_C',
+ )
+ self.assertEqual(get_request.params, {
+ 'servicename': 'ZSCLI_SVCDEMO_C',
+ 'serviceversion': '0001',
+ 'srvdname': 'ZSCLI_SVCDEMO_S',
+ })
+ self.assertEqual(
+ get_request.headers['Accept'],
+ 'application/vnd.sap.adt.businessservices.odatav4.v2+xml',
+ )
+
+
+class TestODataV2ServiceListGet(unittest.TestCase):
+ '''ODataV2ServiceList.get() HTTP request tests'''
+
+ def test_service_list_get_sends_expected_request(self):
+ connection = Connection([Response(
+ text=SERVICE_GROUP_ODATAV2_GET_XML,
+ status_code=200,
+ content_type='application/vnd.sap.adt.businessservices.odatav2.v3+xml; charset=utf-8'
+ )])
+
+ service_list = sap.adt.businessservice.ODataV2ServiceList.get(
+ connection,
+ 'ZSCLI_DM_B_V2',
+ '0001',
+ 'ZSCLI_DM_B_V2',
+ )
+
+ self.assertEqual(len(connection.execs), 1)
+
+ get_request = connection.execs[0]
+ self.assertEqual(get_request.method, 'GET')
+ self.assertEqual(
+ get_request.adt_uri,
+ '/sap/bc/adt/businessservices/odatav2/ZSCLI_DM_B_V2',
+ )
+ self.assertEqual(get_request.params, {
+ 'servicename': 'ZSCLI_DM_B_V2',
+ 'serviceversion': '0001',
+ 'srvdname': 'ZSCLI_DM_B_V2',
+ })
+ self.assertEqual(
+ get_request.headers['Accept'],
+ 'application/vnd.sap.adt.businessservices.odatav2.v3+xml',
+ )
+
+ self.assertIsNotNone(service_list.services)
+ self.assertEqual(service_list.services.repository_id, '')
+ self.assertEqual(service_list.services.service_id, 'ZSCLI_DM_B_V2')
+ self.assertEqual(service_list.services.service_version, '0001')
+ self.assertEqual(service_list.services.service_url, '/sap/opu/odata/sap/ZSCLI_DM_B_V2')
+ self.assertEqual(service_list.services.annotation_url, '')
+ self.assertEqual(service_list.services.created, 'true')
+ self.assertEqual(service_list.services.published, 'true')
diff --git a/test/unit/test_sap_cli_rap.py b/test/unit/test_sap_cli_rap.py
index 3033fc16..7fbef91c 100644
--- a/test/unit/test_sap_cli_rap.py
+++ b/test/unit/test_sap_cli_rap.py
@@ -21,6 +21,18 @@
parse_args = generate_parse_args(sap.cli.rap.CommandGroup())
+# Single source of truth for the deprecation warning text - kept in sync with
+# `sap/cli/rap.py::_deprecation_warning`.
+DEPRECATION_PUBLISH = (
+ 'WARNING: `sapcli rap binding publish` is deprecated; use `sapcli srvb '
+ 'publish`. This alias will be removed in the next minor release.\n'
+)
+
+DEPRECATION_DEFINITION_ACTIVATE = (
+ 'WARNING: `sapcli rap definition activate` is deprecated; use `sapcli '
+ 'srvd activate`. This alias will be removed in the next minor release.\n'
+)
+
class TestRapBindingPublish(ConsoleOutputTestCase, PatcherTestCase):
'''Test rap binding Publish command'''
@@ -47,7 +59,10 @@ def setUp(self):
self.param_binding_name = 'SRVB_NAME'
self.patch_console(console=self.console)
- self.binding_patch = self.patch('sap.adt.businessservice.ServiceBinding')
+ # `rap binding publish` now delegates to `sap.cli.srvb.publish_binding`
+ # which constructs the binding via `sap.adt.ServiceBinding(...)`. Patch
+ # the module attribute the new code path actually resolves at runtime.
+ self.binding_patch = self.patch('sap.adt.ServiceBinding')
self.service = Mock()
self.service.definition = Mock()
@@ -113,7 +128,8 @@ def test_publish_service_version_ok(self):
self.binding_inst.publish.assert_called_once_with(self.service)
self.assertConsoleContents(console=self.console,
stdout=f'''Foo bar\nService {self.param_service} in Binding {self.param_binding_name} published successfully.
-''')
+''',
+ stderr=DEPRECATION_PUBLISH)
def test_publish_service_name_ok(self):
self.publish_status.SEVERITY = "OK"
@@ -127,7 +143,8 @@ def test_publish_service_name_ok(self):
self.binding_inst.publish.assert_called_once_with(self.service)
self.assertConsoleContents(console=self.console,
stdout=f'''Foo bar\nService {self.param_service} in Binding {self.param_binding_name} published successfully.
-''')
+''',
+ stderr=DEPRECATION_PUBLISH)
def test_publish_service_ok(self):
self.publish_status.SEVERITY = "OK"
@@ -140,7 +157,8 @@ def test_publish_service_ok(self):
self.binding_inst.publish.assert_called_once_with(self.service)
self.assertConsoleContents(console=self.console,
stdout=f'''Foo bar\nLong text\nService {self.param_service} in Binding {self.param_binding_name} published successfully.
-''')
+''',
+ stderr=DEPRECATION_PUBLISH)
def test_publish_service_error(self):
self.publish_status.SEVERITY = "NOTOK"
@@ -151,7 +169,7 @@ def test_publish_service_error(self):
self.binding_patch.assert_called_once()
self.binding_inst.publish.assert_called_once()
self.assertConsoleContents(console=self.console, stdout='Foo bar\n',
- stderr=f'''Failed to publish Service {self.param_service} in Binding {self.param_binding_name}
+ stderr=DEPRECATION_PUBLISH + f'''Failed to publish Service {self.param_service} in Binding {self.param_binding_name}
''')
self.assertEqual(exitcode, 1)
@@ -165,7 +183,7 @@ def test_publish_service_no_services(self):
self.binding_inst.publish.assert_not_called()
self.assertConsoleContents(console=self.console,
- stderr=f'''Business Service Biding {self.param_binding_name} does not contain any services
+ stderr=DEPRECATION_PUBLISH + f'''Business Service Biding {self.param_binding_name} does not contain any services
''')
self.assertEqual(exitcode, 1)
@@ -180,7 +198,7 @@ def test_publish_service_too_many_services(self):
self.binding_inst.publish.assert_not_called()
self.assertConsoleContents(console=self.console,
- stderr=f'''Cannot publish Business Service Biding {self.param_binding_name} without
+ stderr=DEPRECATION_PUBLISH + f'''Cannot publish Business Service Biding {self.param_binding_name} without
Service Definition filters because the business binding contains more than one
Service Definition
''')
@@ -193,7 +211,8 @@ def test_publish_service_not_found_service_name_version(self):
self.binding_inst.publish.assert_not_called()
- self.assertConsoleContents(console=self.console, stderr=f'''Business Service Binding {self.param_binding_name} has no Service Definition
+ self.assertConsoleContents(console=self.console,
+ stderr=DEPRECATION_PUBLISH + f'''Business Service Binding {self.param_binding_name} has no Service Definition
with supplied name "{self.param_service}" and version "{self.param_version}"
''')
self.assertEqual(exitcode, 1)
@@ -205,7 +224,8 @@ def test_publish_service_not_found_service_version(self):
self.binding_inst.publish.assert_not_called()
- self.assertConsoleContents(console=self.console, stderr=f'''Business Service Binding {self.param_binding_name} has no Service Definition
+ self.assertConsoleContents(console=self.console,
+ stderr=DEPRECATION_PUBLISH + f'''Business Service Binding {self.param_binding_name} has no Service Definition
with supplied name "" and version "{self.param_version}"
''')
self.assertEqual(exitcode, 1)
@@ -217,11 +237,24 @@ def test_publish_service_not_found_service_name(self):
self.binding_inst.publish.assert_not_called()
- self.assertConsoleContents(console=self.console, stderr=f'''Business Service Binding {self.param_binding_name} has no Service Definition
+ self.assertConsoleContents(console=self.console,
+ stderr=DEPRECATION_PUBLISH + f'''Business Service Binding {self.param_binding_name} has no Service Definition
with supplied name "{self.param_service}" and version ""
''')
self.assertEqual(exitcode, 1)
+ def test_rap_binding_publish_emits_deprecation_warning(self):
+ # explicit contract: a single deprecation warning lands on stderr
+ # the moment the alias is invoked.
+ self.publish_status.SEVERITY = 'OK'
+ self.publish_status.SHORT_TEXT = 'OK'
+
+ self.execute_publish_service_version()
+
+ self.assertIn(DEPRECATION_PUBLISH.strip(), self.console.caperr)
+ # Sanity: warning appears exactly once.
+ self.assertEqual(self.console.caperr.count('deprecated'), 1)
+
class TestRapDefinition(ConsoleOutputTestCase, PatcherTestCase):
'''Test rap definition command group'''
@@ -266,7 +299,8 @@ def test_activate(self):
Activation has finished
Warnings: 0
Errors: 0
-''')
+''',
+ stderr=DEPRECATION_DEFINITION_ACTIVATE)
self.connection.execs[0].assertEqual(
Request.post_xml(
@@ -280,3 +314,10 @@ def test_activate(self):
),
self
)
+
+ def test_rap_definition_activate_emits_deprecation_warning(self):
+ self.execute_definition_activate()
+
+ self.assertIn(DEPRECATION_DEFINITION_ACTIVATE.strip(), self.console.caperr)
+ # Sanity: warning appears exactly once.
+ self.assertEqual(self.console.caperr.count('deprecated'), 1)
diff --git a/test/unit/test_sap_cli_srvb.py b/test/unit/test_sap_cli_srvb.py
new file mode 100644
index 00000000..c9252802
--- /dev/null
+++ b/test/unit/test_sap_cli_srvb.py
@@ -0,0 +1,320 @@
+#!/usr/bin/env python3
+'''CLI tests for sapcli srvb (Service Binding).'''
+
+# pylint: disable=missing-function-docstring
+
+import unittest
+from unittest.mock import patch, Mock
+
+import sap.adt.businessservice
+import sap.cli.srvb
+
+from infra import generate_parse_args
+from mock import (
+ Connection,
+ Response,
+ ConsoleOutputTestCase,
+ PatcherTestCase,
+ patch_get_print_console_with_buffer,
+)
+from fixtures_adt_businessservice import (
+ SERVICE_BINDING_NAME,
+ SERVICE_BINDING_PACKAGE,
+ SERVICE_BINDING_ADT_GET_V4_XML,
+ SERVICE_GROUP_ODATAV4_GET_XML,
+)
+
+
+parse_args = generate_parse_args(sap.cli.srvb.CommandGroup())
+
+
+class TestCommandGroup(unittest.TestCase):
+
+ def test_cli_srvb_commands_constructor(self):
+ sap.cli.srvb.CommandGroup()
+
+ def test_cli_srvb_has_no_write_command(self):
+ # SRVB v1 does not support source-text write because the binding has
+ # no text/plain source body. Make this contract explicit.
+ with self.assertRaises(SystemExit):
+ parse_args('write', SERVICE_BINDING_NAME, '/dev/null')
+
+
+class TestSRVBCreate(unittest.TestCase):
+
+ @patch('sap.adt.ServiceBinding')
+ def test_cli_srvb_create_with_odata_v4(self, fake_srvb):
+ fake_conn = Mock()
+ fake_conn.user = 'TESTER'
+ fake_srvb.return_value = Mock()
+
+ args = parse_args('create', SERVICE_BINDING_NAME,
+ 'Test binding', SERVICE_BINDING_PACKAGE,
+ '--binding-type', 'ODATAV4_UI',
+ '--service-definition', 'ZSAPCLI_TEST_SRVD')
+ with patch_get_print_console_with_buffer():
+ args.execute(fake_conn, args)
+
+ self.assertEqual(fake_srvb.call_count, 1)
+ kwargs = fake_srvb.call_args.kwargs
+ self.assertEqual(kwargs['package'], SERVICE_BINDING_PACKAGE)
+ self.assertEqual(kwargs['typ'], 'ODATA')
+ self.assertEqual(kwargs['version'], 'V4')
+ self.assertEqual(kwargs['category'], '0')
+
+ fake_srvb.return_value.add_service.assert_called_once_with(SERVICE_BINDING_NAME, 'ZSAPCLI_TEST_SRVD', '0001')
+ fake_srvb.return_value.create.assert_called_once_with(corrnr=None)
+
+ @patch('sap.adt.ServiceBinding')
+ def test_cli_srvb_create_with_odata_v2(self, fake_srvb):
+ fake_conn = Mock()
+ fake_conn.user = 'TESTER'
+ fake_srvb.return_value = Mock()
+
+ args = parse_args('create', SERVICE_BINDING_NAME,
+ 'Test binding', SERVICE_BINDING_PACKAGE,
+ '--binding-type', 'ODATAV2_API',
+ '--service-definition', 'ZSAPCLI_TEST_SRVD')
+ with patch_get_print_console_with_buffer():
+ args.execute(fake_conn, args)
+
+ kwargs = fake_srvb.call_args.kwargs
+ self.assertEqual(kwargs['version'], 'V2')
+ self.assertEqual(kwargs['category'], '1')
+
+ @patch('sap.adt.ServiceBinding')
+ def test_cli_srvb_create_with_explicit_service_version(self, fake_srvb):
+ fake_conn = Mock()
+ fake_conn.user = 'TESTER'
+ fake_srvb.return_value = Mock()
+
+ args = parse_args('create', SERVICE_BINDING_NAME,
+ 'Test binding', SERVICE_BINDING_PACKAGE,
+ '--binding-type', 'ODATAV4_API',
+ '--service-definition', 'ZSAPCLI_TEST_SRVD',
+ '--service-version', '0002')
+ with patch_get_print_console_with_buffer():
+ args.execute(fake_conn, args)
+
+ fake_srvb.return_value.add_service.call_args.assert_called_once_with(SERVICE_BINDING_NAME, 'ZSAPCLI_TEST_SRVD', '0002')
+
+
+class TestSRVBRead(unittest.TestCase):
+
+ def test_cli_srvb_read_prints_summary(self):
+ conn = Connection([
+ Response(text=SERVICE_BINDING_ADT_GET_V4_XML, status_code=200,
+ headers={'Content-Type':
+ 'application/vnd.sap.adt.businessservices.servicebinding.v2+xml; charset=utf-8'}),
+ Response(text=SERVICE_GROUP_ODATAV4_GET_XML, status_code=200,
+ headers={'Content-Type':
+ 'application/vnd.sap.adt.businessservices.odatav4.v2+xml; charset=utf-8'}),
+ ])
+
+ args = parse_args('read', SERVICE_BINDING_NAME)
+ with patch_get_print_console_with_buffer() as fake_console:
+ args.execute(conn, args)
+
+ out = fake_console.capout
+ self.assertEqual(out, '''Name : ZSAPCLI_TEST_BND
+Description : Test service binding
+Package : $TMP
+Type : ODATA
+Version : V4
+Published : false
+Services:
+ ZSAPCLI_TEST_BND (version 0001, NOT_RELEASED)
+ URL: /sap/opu/odata4/sap/zscli_svcdemo_c/srvd/sap/zscli_svcdemo_c/0001/
+''')
+
+
+class TestSRVBActivate(unittest.TestCase):
+
+ @patch('sap.adt.wb.try_activate')
+ @patch('sap.adt.ServiceBinding')
+ def test_cli_srvb_activate_defaults(self, fake_srvb, fake_activate):
+ instances = []
+
+ def add_instance(conn, name, package=None, metadata=None):
+ srvb = Mock()
+ srvb.name = name
+ srvb.active = 'active'
+ instances.append(srvb)
+ return srvb
+
+ fake_srvb.side_effect = add_instance
+ fake_activate.return_value = (sap.adt.wb.CheckResults(), None)
+ fake_conn = Mock()
+
+ args = parse_args('activate', SERVICE_BINDING_NAME.lower())
+ with patch_get_print_console_with_buffer():
+ args.execute(fake_conn, args)
+
+ fake_srvb.assert_called_once_with(fake_conn, SERVICE_BINDING_NAME, package=None, metadata=None)
+ fake_activate.assert_called_once_with(instances[0])
+
+
+class TestSRVBDelete(unittest.TestCase):
+
+ @patch('sap.adt.ServiceBinding')
+ def test_cli_srvb_delete_defaults(self, fake_srvb):
+ srvb = Mock()
+ fake_srvb.return_value = srvb
+ fake_conn = Mock()
+
+ args = parse_args('delete', SERVICE_BINDING_NAME)
+ with patch_get_print_console_with_buffer():
+ args.execute(fake_conn, args)
+
+ fake_srvb.assert_called_once_with(fake_conn, SERVICE_BINDING_NAME, package=None, metadata=None)
+ srvb.delete.assert_called_once_with(corrnr=None)
+
+
+class TestSRVBWhereUsed(unittest.TestCase):
+
+ @patch('sap.adt.whereused.where_used')
+ @patch('sap.adt.ServiceBinding')
+ def test_cli_srvb_whereused_defaults(self, fake_srvb, fake_where_used):
+ fake_conn = Mock()
+ srvb = Mock()
+ srvb.full_adt_uri = '/sap/bc/adt/businessservices/bindings/zsapcli_test_bnd'
+ fake_srvb.return_value = srvb
+
+ result = Mock()
+ result.referenced_objects = []
+ fake_where_used.return_value = result
+
+ args = parse_args('whereused', SERVICE_BINDING_NAME)
+ with patch_get_print_console_with_buffer():
+ args.execute(fake_conn, args)
+
+ fake_where_used.assert_called_once_with(
+ fake_conn, '/sap/bc/adt/businessservices/bindings/zsapcli_test_bnd')
+
+
+class TestSRVBPublish(ConsoleOutputTestCase, PatcherTestCase):
+ '''Test sapcli srvb publish'''
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ PatcherTestCase.__init__(self)
+
+ def tearDown(self):
+ PatcherTestCase.unpatch_all(self)
+
+ def setUp(self):
+ super().setUp()
+ ConsoleOutputTestCase.setUp(self)
+
+ self.connection = Mock()
+ self.param_version = '0001'
+ self.param_service = 'ZSAPCLI_TEST_SRV'
+ self.param_binding_name = SERVICE_BINDING_NAME
+
+ self.patch_console(console=self.console)
+ self.binding_patch = self.patch('sap.adt.ServiceBinding')
+
+ self.service = Mock()
+ self.service.definition = Mock()
+ self.service.definition.name = self.param_service
+ self.service.version = self.param_version
+
+ self.publish_status = sap.adt.businessservice.StatusMessage()
+
+ self.binding_inst = self.binding_patch.return_value
+ self.binding_inst.find_service = Mock(return_value=self.service)
+ self.binding_inst.publish = Mock(return_value=self.publish_status)
+ self.binding_inst.services = [self.service]
+
+ def execute_publish(self, *extra):
+ args = parse_args('publish', self.param_binding_name, *extra)
+ return args.execute(self.connection, args)
+
+ def test_publish_single_service_default_ok(self):
+ self.publish_status.SEVERITY = 'OK'
+ self.publish_status.SHORT_TEXT = 'Service published successfully'
+
+ self.execute_publish()
+
+ self.binding_inst.fetch.assert_called_once_with()
+ self.binding_inst.publish.assert_called_once_with(self.service)
+ self.assertConsoleContents(
+ console=self.console,
+ stdout=(f'Service published successfully\n'
+ f'Service {self.param_service} in Binding {self.param_binding_name} '
+ f'published successfully.\n'))
+
+ def test_publish_with_service_filter(self):
+ self.publish_status.SEVERITY = 'OK'
+ self.publish_status.SHORT_TEXT = 'OK'
+
+ self.execute_publish('--service', self.param_service)
+
+ self.binding_inst.find_service.assert_called_once_with(self.param_service, None)
+ self.binding_inst.publish.assert_called_once_with(self.service)
+
+ def test_publish_with_service_and_version(self):
+ self.publish_status.SEVERITY = 'OK'
+ self.publish_status.SHORT_TEXT = 'OK'
+
+ self.execute_publish('--service', self.param_service, '--version', self.param_version)
+
+ self.binding_inst.find_service.assert_called_once_with(self.param_service, self.param_version)
+ self.binding_inst.publish.assert_called_once_with(self.service)
+
+ def test_publish_no_services_errors(self):
+ self.binding_inst.services = []
+
+ exitcode = self.execute_publish()
+
+ self.binding_inst.publish.assert_not_called()
+ self.assertEqual(exitcode, 1)
+ self.assertIn('does not contain any services', self.console.caperr)
+
+ def test_publish_too_many_services_without_filter_errors(self):
+ self.binding_inst.services = [Mock(), Mock()]
+
+ exitcode = self.execute_publish()
+
+ self.binding_inst.publish.assert_not_called()
+ self.assertEqual(exitcode, 1)
+ self.assertIn('without', self.console.caperr)
+
+ def test_publish_service_not_found_errors(self):
+ self.binding_inst.find_service.return_value = None
+
+ exitcode = self.execute_publish('--service', self.param_service, '--version', self.param_version)
+
+ self.binding_inst.publish.assert_not_called()
+ self.assertEqual(exitcode, 1)
+ self.assertIn('has no Service Definition', self.console.caperr)
+
+ def test_publish_severity_not_ok_returns_1(self):
+ self.publish_status.SEVERITY = 'ERROR'
+ self.publish_status.SHORT_TEXT = 'Local Publish failed'
+
+ exitcode = self.execute_publish()
+
+ self.assertEqual(exitcode, 1)
+ self.assertIn('Failed to publish', self.console.caperr)
+
+ def test_publish_url_uses_lowercase_name(self):
+ # The CLI delegates to ServiceBinding.publish() which builds the URL
+ # via self.objtype.basepath + .lower(name); we just assert the
+ # ServiceBinding constructor was called with the user-provided name
+ # (uppercased per CLI convention) so the URL builder gets the right
+ # input.
+ self.publish_status.SEVERITY = 'OK'
+ self.publish_status.SHORT_TEXT = 'OK'
+
+ self.execute_publish()
+
+ # First positional arg = connection, second = binding name (uppercased
+ # by the CLI adapter).
+ positional = self.binding_patch.call_args.args
+ self.assertEqual(positional[0], self.connection)
+ self.assertEqual(positional[1], self.param_binding_name)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/test/unit/test_sap_cli_srvd.py b/test/unit/test_sap_cli_srvd.py
new file mode 100644
index 00000000..e7d7e90a
--- /dev/null
+++ b/test/unit/test_sap_cli_srvd.py
@@ -0,0 +1,177 @@
+#!/usr/bin/env python3
+'''CLI tests for sapcli srvd (Service Definition).'''
+
+# pylint: disable=missing-function-docstring
+
+import unittest
+from unittest.mock import call, patch, Mock
+
+import sap.cli.srvd
+
+from infra import generate_parse_args
+from mock import patch_get_print_console_with_buffer
+
+
+parse_args = generate_parse_args(sap.cli.srvd.CommandGroup())
+
+
+class TestCommandGroup(unittest.TestCase):
+
+ def test_cli_srvd_commands_constructor(self):
+ sap.cli.srvd.CommandGroup()
+
+
+class TestSRVDCreate(unittest.TestCase):
+
+ @patch('sap.adt.ServiceDefinition')
+ def test_cli_srvd_create_defaults(self, fake_srvd):
+ fake_conn = Mock()
+ fake_conn.user = 'TESTER'
+
+ srvd = Mock()
+ fake_srvd.return_value = srvd
+
+ args = parse_args('create', 'ZSAPCLI_TEST_SRV', 'Test service', '$TMP')
+ with patch_get_print_console_with_buffer():
+ args.execute(fake_conn, args)
+
+ # Only one positional call - the instance call inside build_new_object.
+ self.assertEqual(fake_srvd.call_count, 1)
+ positional = fake_srvd.call_args.args
+ self.assertEqual(positional[0], fake_conn)
+ self.assertEqual(positional[1], 'ZSAPCLI_TEST_SRV')
+
+ kwargs = fake_srvd.call_args.kwargs
+ self.assertEqual(kwargs['package'], '$TMP')
+ self.assertIsNotNone(kwargs['metadata'])
+
+ srvd.create.assert_called_once_with(corrnr=None)
+ self.assertEqual(srvd.description, 'Test service')
+
+ @patch('sap.adt.ServiceDefinition')
+ def test_cli_srvd_create_with_corrnr(self, fake_srvd):
+ fake_conn = Mock()
+ fake_conn.user = 'TESTER'
+ fake_srvd.return_value = Mock()
+
+ args = parse_args('create', 'ZSAPCLI_TEST_SRV', 'Test service', '$TMP',
+ '--corrnr', 'TR42')
+ with patch_get_print_console_with_buffer():
+ args.execute(fake_conn, args)
+
+ fake_srvd.return_value.create.assert_called_once_with(corrnr='TR42')
+
+
+class TestSRVDRead(unittest.TestCase):
+
+ @patch('sap.adt.ServiceDefinition')
+ def test_cli_srvd_read_defaults(self, fake_srvd):
+ fake_conn = Mock()
+ fake_srvd.return_value = Mock()
+ fake_srvd.return_value.text = 'define service ZSAPCLI_TEST_SRV {}\n'
+
+ args = parse_args('read', 'ZSAPCLI_TEST_SRV')
+ with patch_get_print_console_with_buffer() as fake_console:
+ args.execute(fake_conn, args)
+
+ fake_srvd.assert_called_once_with(
+ fake_conn, 'ZSAPCLI_TEST_SRV', package=None, metadata=None)
+ self.assertEqual(fake_console.capout, 'define service ZSAPCLI_TEST_SRV {}\n\n')
+
+
+class TestSRVDActivate(unittest.TestCase):
+
+ @patch('sap.adt.wb.try_activate')
+ @patch('sap.adt.ServiceDefinition')
+ def test_cli_srvd_activate_defaults(self, fake_srvd, fake_activate):
+ instances = []
+
+ def add_instance(conn, name, package=None, metadata=None):
+ srvd = Mock()
+ srvd.name = name
+ srvd.active = 'active'
+ instances.append(srvd)
+ return srvd
+
+ fake_srvd.side_effect = add_instance
+ fake_activate.return_value = (sap.adt.wb.CheckResults(), None)
+ fake_conn = Mock()
+
+ args = parse_args('activate', 'zsapcli_test_srv', 'zsapcli_test_srv2')
+ with patch_get_print_console_with_buffer() as fake_console:
+ args.execute(fake_conn, args)
+
+ self.assertEqual(fake_srvd.mock_calls, [
+ call(fake_conn, 'ZSAPCLI_TEST_SRV', package=None, metadata=None),
+ call(fake_conn, 'ZSAPCLI_TEST_SRV2', package=None, metadata=None),
+ ])
+ self.assertEqual(fake_activate.mock_calls, [call(instances[0]), call(instances[1])])
+ self.assertEqual(fake_console.caperr, '')
+
+
+class TestSRVDDelete(unittest.TestCase):
+
+ @patch('sap.adt.ServiceDefinition')
+ def test_cli_srvd_delete_defaults(self, fake_srvd):
+ srvd = Mock()
+ fake_srvd.return_value = srvd
+ fake_conn = Mock()
+
+ args = parse_args('delete', 'ZSAPCLI_TEST_SRV')
+ with patch_get_print_console_with_buffer():
+ args.execute(fake_conn, args)
+
+ fake_srvd.assert_called_once_with(
+ fake_conn, 'ZSAPCLI_TEST_SRV', package=None, metadata=None)
+ srvd.delete.assert_called_once_with(corrnr=None)
+
+
+class TestSRVDWriteUsesPlainTextEditor(unittest.TestCase):
+
+ @patch('sap.adt.ServiceDefinition')
+ def test_cli_srvd_write_pipes_stdin_to_editor(self, fake_srvd):
+ editor = Mock()
+ editor.__enter__ = Mock(return_value=editor)
+ editor.__exit__ = Mock(return_value=False)
+
+ srvd = Mock()
+ srvd.open_editor = Mock(return_value=editor)
+ fake_srvd.return_value = srvd
+
+ fake_conn = Mock()
+
+ args = parse_args('write', 'ZSAPCLI_TEST_SRV', '-')
+ # Feed code via stdin
+ with patch('sys.stdin') as fake_stdin, \
+ patch_get_print_console_with_buffer():
+ fake_stdin.readlines.return_value = ['define service ZSAPCLI_TEST_SRV {}\n']
+ args.execute(fake_conn, args)
+
+ srvd.open_editor.assert_called_once_with(corrnr=None)
+ editor.write.assert_called_once_with('define service ZSAPCLI_TEST_SRV {}\n')
+
+
+class TestSRVDWhereUsed(unittest.TestCase):
+
+ @patch('sap.adt.whereused.where_used')
+ @patch('sap.adt.ServiceDefinition')
+ def test_cli_srvd_whereused_defaults(self, fake_srvd, fake_where_used):
+ fake_conn = Mock()
+ srvd = Mock()
+ srvd.full_adt_uri = '/sap/bc/adt/ddic/srvd/sources/zsapcli_test_srv'
+ fake_srvd.return_value = srvd
+
+ result = Mock()
+ result.referenced_objects = []
+ fake_where_used.return_value = result
+
+ args = parse_args('whereused', 'ZSAPCLI_TEST_SRV')
+ with patch_get_print_console_with_buffer():
+ args.execute(fake_conn, args)
+
+ fake_where_used.assert_called_once_with(
+ fake_conn, '/sap/bc/adt/ddic/srvd/sources/zsapcli_test_srv')
+
+
+if __name__ == '__main__':
+ unittest.main()