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()