Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added option to run ADT calls over RFC #64

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions bin/sapcli
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ def parse_command_line(argv):
arg_parser.add_argument("--msserv", default=os.getenv("SAP_MSSERV"), help="Message server port")
arg_parser.add_argument("--sysid", default=os.getenv("SAP_SYSID"), help="System ID (use if connecting via a "
"message server")
arg_parser.add_argument("--group", default=os.getenv("SAP_GROUP"), help="Group (use if connecting via a message "
"server")
arg_parser.add_argument("--rfc_group", # name "group" is already in use
default=os.getenv("SAP_GROUP"), help="Group (use if connecting via a message server")
arg_parser.add_argument("--snc_qop", default=os.getenv("SNC_QOP"), help="SAP Secure Login Client QOP")
arg_parser.add_argument("--snc_myname", default=os.getenv("SNC_MYNAME"), help="SAP Secure Login Client MyName")
arg_parser.add_argument("--snc_partnername",
Expand All @@ -86,6 +86,11 @@ def parse_command_line(argv):
help="SAP Secure Login Client library (e.g. "
"/Applications/Secure Login Client.app/Contents/MacOS/lib/libsapcrypto.dylib")

arg_parser.add_argument("--rest-over-rfc", action='store_true', dest="rest_over_rfc",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yay, I didn't notice earlier. The generic name 'rest-over-rfc' suggest that all HTTP traffic will be routed via RFC but I do believe only ADT HTTP traffic can be tunneled via RFC. Am I right?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a very interesting question. In theory, all of the HTTP requests can be tunneled because it directly connects to the HTTP dispatcher on Netweaver Server. However, I did not try with other types of requests and it is not implemented.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, so since the implementation does not route entire HTTP traffic via RFC, I suggest to rename to "adt_rest_over_rfc".

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a good point.
I think it is better to keep the name but show an error message. With this, the parameter would not have to change if this is also implemented for gtcs. This is ultimately your call.

default=os.getenv("SAP_REST_OVER_RFC") not in [None, 'n', 'no', 'false', 'off'],
help="Prefer doing rest call over SAP RFC client (to use SNC or if HTTP port is firewalled)"
)
akreuzer marked this conversation as resolved.
Show resolved Hide resolved

subparsers = arg_parser.add_subparsers()
# pylint: disable=not-an-iterable
for connection, cmd in sap.cli.get_commands():
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
requests>=2.20.0
pyodata==1.7.0
PyYAML==5.4.1
PyYAML==5.4.1
2 changes: 1 addition & 1 deletion sap/adt/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Base classes for ADT functionality modules"""

from sap.adt.core import Connection # noqa: F401
from sap.adt.core import ConnectionViaHTTP, ConnectionViaRFC, Connection # noqa: F401
from sap.adt.function import FunctionGroup, FunctionModule # noqa: F401
from sap.adt.objects import ADTObject, ADTObjectType, ADTCoreData, OrderedClassMembers # noqa: F401
from sap.adt.objects import Class, Interface, DataDefinition # noqa: F401
Expand Down
297 changes: 223 additions & 74 deletions sap/adt/core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""Base ADT functionality module"""

import os
import urllib
from abc import ABC, abstractmethod
from typing import Any, NoReturn, Optional, Union
from dataclasses import dataclass

import xml.sax
from xml.sax.handler import ContentHandler
Expand All @@ -15,8 +19,7 @@
HTTPRequestError,
UnexpectedResponseContent,
UnauthorizedError,
TimedOutRequestError
)
TimedOutRequestError)


def mod_log():
Expand Down Expand Up @@ -87,9 +90,145 @@ def _get_collection_accepts(discovery_xml):
return xml_handler.result


@dataclass
class Response:
"""Response Dataclass
It abstracts from requests Response class and can also be used for RFC requests"""
text: str
headers: dict[str, str]
status_code: int
status_line: str = ""


# pylint: disable=too-many-instance-attributes
class Connection:
"""ADT Connection for HTTP communication built on top Python requests.
class Connection(ABC):
"""Base class for ADT Connection for HTTP communication.
"""

def __init__(self):
self._adt_uri = 'sap/bc/adt'
self._query_args = ''
self._base_url = ''
self._collection_types = None

@property
def uri(self) -> str:
"""ADT path for building URLs (e.g. sap/bc/adt)"""

return self._adt_uri

def _build_adt_url(self, adt_uri) -> str:
"""Creates complete URL from a fragment of ADT URI
where the fragment usually refers to an ADT object
"""

return f'{self._base_url}/{adt_uri}?{self._query_args}'


@abstractmethod
def _execute_raw(self, method: str, uri: str, params: Optional[dict[str,
str]],
headers: Optional[dict[str, str]],
body: Optional[str]) -> Response:
pass

# pylint: disable=too-many-arguments
def execute(self,
method: str,
adt_uri: str,
params: Optional[dict[str, str]] = None,
headers: Optional[dict[str, str]] = None,
body: Optional[str] = None,
accept: Optional[Union[str, list[str]]] = None,
content_type: Optional[str] = None) -> Response:
"""Executes the given ADT URI as an HTTP request and returns
the requests response object
"""

url = self._build_adt_url(adt_uri)

if headers is None:
headers = {}

if accept is not None:
if isinstance(accept, list):
headers['Accept'] = ', '.join(accept)
else:
headers['Accept'] = accept

if content_type is not None:
headers['Content-Type'] = content_type

if not headers:
headers = None

method = method.upper()

resp = self._execute_raw(method, url, params, headers, body)

if accept:
resp_content_type = resp.headers['Content-Type']

if isinstance(accept, str):
accept = [accept]

if not any((resp_content_type.startswith(accepted)
for accepted in accept)):
raise UnexpectedResponseContent(accept, resp_content_type,
resp.text)

return resp

def get_text(self, relativeuri):
"""Executes a GET HTTP request with the headers Accept = text/plain.
"""

return self.execute('GET',
relativeuri,
headers={
'Accept': 'text/plain'
}).text

@property
def collection_types(self):
"""Returns dictionary of Object type URI fragment and list of
supported MIME types.
"""

if self._collection_types is None:
response = self.execute('GET', 'discovery')
self._collection_types = _get_collection_accepts(response.text)

return self._collection_types

def get_collection_types(self, basepath, default_mimetype):
"""Returns the accepted object XML format - mime type"""

uri = f'/{self._adt_uri}/{basepath}'
try:
return self.collection_types[uri]
except KeyError:
return [default_mimetype]

def _handle_http_error(self, req, res: Response) -> NoReturn:
"""Raise the correct exception based on response content."""

if res.headers['content-type'] == 'application/xml':
error = new_adt_error_from_xml(res.text)

if error is not None:
raise error

# else - unformatted text
if res.status_code == 401:
user = getattr(self, "_user", default="") # type: ignore
raise UnauthorizedError(req, res, user)

raise HTTPRequestError(req, res)


class ConnectionViaHTTP(Connection):
"""ADT Connection using requests and standard HTTP protocol.
"""

# pylint: disable=too-many-arguments
Expand All @@ -104,6 +243,7 @@ def __init__(self, host, client, user, password, port=None, ssl=True, verify=Tru
- ssl: boolean to switch between http and https
- verify: boolean to switch SSL validation on/off
"""
super().__init__()

setup_keepalive()

Expand All @@ -123,7 +263,6 @@ def __init__(self, host, client, user, password, port=None, ssl=True, verify=Tru
self._user = user
self._auth = HTTPBasicAuth(user, password)
self._session = None
self._collection_types = None
self._timeout = config_get('http_timeout')

@property
Expand All @@ -132,19 +271,6 @@ def user(self):

return self._user

@property
def uri(self):
"""ADT path for building URLs (e.g. sap/bc/adt)"""

return self._adt_uri

def _build_adt_url(self, adt_uri):
"""Creates complete URL from a fragment of ADT URI
where the fragment usually refers to an ADT object
"""

return f'{self._base_url}/{adt_uri}?{self._query_args}'

def _handle_http_error(self, req, res):
"""Raise the correct exception based on response content."""

Expand Down Expand Up @@ -249,66 +375,89 @@ def _get_session(self):

return self._session

def execute(self, method, adt_uri, params=None, headers=None, body=None, accept=None, content_type=None):
"""Executes the given ADT URI as an HTTP request and returns
the requests response object
"""
def _execute_raw(self, method: str, uri: str, params: Optional[dict[str,
str]],
headers: Optional[dict[str, str]], body: Optional[str]):

session = self._get_session()

url = self._build_adt_url(adt_uri)

if headers is None:
headers = {}

if accept is not None:
if isinstance(accept, list):
headers['Accept'] = ', '.join(accept)
else:
headers['Accept'] = accept

if content_type is not None:
headers['Content-Type'] = content_type

if not headers:
headers = None

resp = self._execute_with_session(session, method, url, params=params, headers=headers, body=body)

if accept:
resp_content_type = resp.headers['Content-Type']

if isinstance(accept, str):
accept = [accept]

if not any((resp_content_type.startswith(accepted) for accepted in accept)):
raise UnexpectedResponseContent(accept, resp_content_type, resp.text)

return resp

def get_text(self, relativeuri):
"""Executes a GET HTTP request with the headers Accept = text/plain.
"""

return self.execute('GET', relativeuri, headers={'Accept': 'text/plain'}).text

@property
def collection_types(self):
"""Returns dictionary of Object type URI fragment and list of
supported MIME types.
"""
resp = self._execute_with_session(session,
method,
uri,
params=params,
headers=headers,
body=body)

if self._collection_types is None:
response = self.execute('GET', 'discovery')
self._collection_types = _get_collection_accepts(response.text)
return Response(text=resp.text or "",
headers=resp.headers or {},
status_code=resp.status_code,
status_line="")

return self._collection_types

def get_collection_types(self, basepath, default_mimetype):
"""Returns the accepted object XML format - mime type"""
class ConnectionViaRFC(Connection):
"""ConnetionViaRFC is a ADT Connection that dispatches the HTTP requests via SAP's RFC connector"""

uri = f'/{self._adt_uri}/{basepath}'
try:
return self.collection_types[uri]
except KeyError:
return [default_mimetype]
def __init__(self, rfc_conn):
super().__init__()
self.rfc_conn = rfc_conn

def _make_request(self, method: str, uri: str, params: Optional[dict[str,
str]],
headers: Optional[dict[str, str]],
body: Optional[str]) -> dict[str, Any]:
if params:
params_encoded = "?" + urllib.parse.urlencode(params)
else:
params_encoded = ""

req: dict[str, Any] = {
"REQUEST_LINE": {
"METHOD": method,
"URI": f"/{self._adt_uri}{uri}{params_encoded}",
}
}

if headers:
req['HEADER_FIELDS'] = [{
"NAME": name,
"VALUE": value
} for name, value in headers.items()]

if body:
req["MESSAGE_BODY"] = body.encode("utf-8")

return req

@staticmethod
def _parse_response(resp) -> Response:
status_code = int(resp["STATUS_LINE"]["STATUS_CODE"])
status_line = resp['STATUS_LINE']['REASON_PHRASE']

body = resp["MESSAGE_BODY"].decode("utf-8", "strict")
headers = {}
for field in resp["HEADER_FIELDS"]:
headers[field["NAME"].lower()] = field["VALUE"]

return Response(text=body,
headers=headers,
status_code=status_code,
status_line=status_line)

def _execute_raw(self, method: str, uri: str, params: Optional[dict[str,
str]],
headers: Optional[dict[str, str]],
body: Optional[str]) -> Response:
req = self._make_request(method=method,
uri=uri,
params=params,
headers=headers,
body=body)
mod_log().info('Executing RFC request line %s', req)
resp = self.rfc_conn.call("SADT_REST_RFC_ENDPOINT", REQUEST=req)
mod_log().info('Got response %s', resp)
parsed_resp = self._parse_response(resp["RESPONSE"])

if parsed_resp.status_code >= 400:
self._handle_http_error(req, parsed_resp)

return parsed_resp
Loading