| # Copyright 2018 IBM Corporation |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| """ Minimal OpenWhisk Client for Python """ |
| import urllib.parse |
| import os |
| import requests |
| import base64 |
| import json |
| |
| class Client: |
| def __init__(self, options=None): |
| self.options = self.parse_options(options if options is not None else {}) |
| self.actions = Action(self) |
| |
| def parse_options(self, options): |
| api_key = options['api_key'] if 'api_key' in options else (os.environ['__OW_API_KEY'] if '__OW_API_KEY' in os.environ else None) |
| ignore_certs = options['ignore_certs'] if 'ignore_certs' in options else False |
| # if apihost is available, parse this into full API url |
| api = options['api'] if 'api' in options else self.url_from_apihost(options['apihost'] if 'apihost' in options else (os.environ['__OW_APIHOST'] if '__OW_API___OW_APIHOST' in os.environ else None)) |
| |
| if api_key is None: |
| raise Exception(invalid_options_error, 'Missing api_key parameter.') |
| elif api is None: |
| raise Exception(invalid_options_error, 'Missing either api or apihost parameters.') |
| |
| namespace = options['namespace'] if 'namespace' in options else None |
| return {'api_key':api_key, 'api': api, 'ignore_certs':ignore_certs, 'namespace': namespace } |
| |
| def url_from_apihost(self, apihost): |
| if apihost is None: |
| return apihost |
| url = apihost+'/api/v1/' |
| |
| # if apihost does not the protocol, assume HTTPS |
| if not url.startswith('http') : |
| url = 'https://' + url |
| |
| return url |
| |
| def request(self, method, path, options): |
| url = self.path_url(path) |
| params = options['qs'] if 'qs' in options else None |
| body = options['body'] if 'body' in options else None |
| |
| serializer = options['serializer'] if 'serializer' in options else None |
| payload = json.dumps(body, default=serializer) |
| headers = { 'Authorization': self.auth_header(), 'Content-Type': 'application/json' } |
| verify = not self.options['ignore_certs'] |
| |
| resp = requests.request(method, url, params=params, data=payload, headers=headers, verify=verify) |
| |
| if resp.status_code >= 400: |
| # we turn >=400 statusCode responses into exceptions |
| error = Exception() |
| error.status_code = resp.status_code |
| error.error = resp.json() |
| raise error |
| else: |
| # otherwise, the response body is the expected return value |
| return resp.json() |
| |
| def path_url(self, url_path): |
| endpoint = self.api_url() |
| return urllib.parse.urlunparse(urllib.parse.ParseResult(endpoint.scheme, endpoint.netloc, urllib.parse.urljoin(endpoint.path, url_path), endpoint.params, endpoint.query, endpoint.fragment)) |
| |
| def api_url(self): |
| return urllib.parse.urlparse(self.options['api'] if self.options['api'].endswith('/') else self.options['api'] + '/') |
| |
| def auth_header(self): |
| return 'Basic '+ base64.b64encode(self.options['api_key'].encode()).decode() |
| |
| default_namespace = os.environ['__OW_NAMESPACE'] if '__OW_NAMESPACE' in os.environ else '_' |
| |
| class BaseOperation: |
| def __init__(self, client, resource): |
| self.client = client |
| self.resource = resource |
| |
| def request(self, params): |
| namespace = self.namespace(params['options']) |
| path = self.resource_path(namespace, params['id']) |
| return self.client.request(params['method'], path, params['options']) |
| |
| def resource_path(self, namespace, id=None): |
| path = 'namespaces/'+namespace+'/'+self.resource |
| |
| if id is not None: |
| path += '/'+id |
| |
| return path |
| |
| def namespace(self, options=None): |
| |
| if options is not None and isinstance(options['namespace'], str): |
| return urllib.parse.quote(options['namespace'].encode('utf-8')) |
| |
| if 'namespace' in self.client.options and isinstance(self.client.options['namespace'], str): |
| return urllib.parse.quote(self.client.options['namespace'].encode('utf-8')) |
| return urllib.parse.quote(default_namespace) |
| |
| def qs(self, options, names): |
| filtered = {} |
| for name in names: |
| if name in options: |
| filtered[name] = options[name] |
| return filtered |
| |
| class Resource(BaseOperation): |
| def __init__(self, client, resource): |
| super(Resource, self).__init__(client, resource) |
| self.identifiers = ['name'] |
| self.qs_options = {} |
| |
| def list(self, options): |
| return self.operation('GET', options) |
| |
| def get(self, options): |
| return self.operation_with_id('GET', options) |
| |
| def invoke(self, options=None): |
| options = options if options is not None else {} |
| |
| if isinstance(options, dict): |
| options['qs'] = self.qs(options, self.qs_options['invoke'] if 'invoke' in self.qs_options else []) |
| options['body'] = self.payload(options) |
| |
| return self.operation_with_id('POST', options) |
| |
| def create(self, options): |
| return self.operation_with_id('PUT', options) |
| |
| def delete(self, options): |
| return self.operation_with_id('DELETE', options) |
| |
| def update(self, options): |
| options = self.parse_options(options) |
| options['overwrite'] = True |
| return self.create(options) |
| |
| def operation(self, method, options): |
| options = self.parse_options(options) |
| id = options['id'] |
| return self.request({ 'method':method, 'id':id, 'options':options }) |
| |
| def operation_with_id(self, method, options): |
| if isinstance(options, list): |
| return list(map(lambda i: self.operation_with_id(method, i), options)) |
| |
| options = self.parse_options(options) |
| options['namespace'] = self.parse_namespace(options) |
| options['id'] = self.parse_id(options) |
| return self.operation(method, options) |
| |
| def parse_options(self, options=None): |
| if isinstance(options, str): |
| options = { 'name': options } |
| |
| return options if options is not None else {} |
| |
| def parse_id(self, options): |
| id = self.retrieve_id(options) |
| return parse_id(id) |
| |
| def parse_namespace(self, options): |
| id = self.retrieve_id(options) |
| |
| if id.startswith('/'): |
| return parse_namespace(id) |
| |
| return options['namespace'] if 'namespace' in options else None |
| |
| def retrieve_id(self, options=None): |
| options = options if options is not None else {} |
| id = next((x for x in self.identifiers if x in options)) |
| |
| if id is None: |
| raise Exception('Missing resource identifier from parameters, supported parameter names: '+', '.join(self.identifiers)) |
| |
| return options[id] |
| |
| def payload(self, options): |
| if not 'params' in options: |
| return {} |
| |
| if isinstance(options['params'], dict): |
| return options['params'] |
| |
| raise Exception('Invalid payload type, must be an dictionary.') |
| |
| class Action(Resource): |
| def __init__(self, client): |
| super(Action, self).__init__(client, 'actions') |
| self.identifiers.append('actionName') |
| self.qs_options['invoke'] = ['blocking'] |
| |
| def list(self, options=None): |
| options = options if options is not None else {} |
| options['qs'] = self.qs(options, ['skip', 'limit']) |
| |
| return super().list(options) |
| |
| def invoke(self, options=None): |
| options = options if options is not None else {} |
| |
| if 'blocking' in options and 'result' in options: |
| return super().invoke(options)['response']['result'] |
| |
| return super().invoke(options) |
| |
| def create(self, options): |
| options['qs'] = self.qs(options, ['overwrite']) |
| options['body'] = self.action_body(options) |
| |
| return super().create(options) |
| |
| def action_body(self, options): |
| if 'action' not in options: |
| raise Exception(missing_action_body_error) |
| if isinstance(options['action'], dict): |
| return options['action'] |
| |
| body = { 'exec': { 'kind': options['kind'] if 'kind' in options else 'python:3', 'code': options['action'] } } |
| |
| if isinstance(options['action'], bytes): |
| body['exec']['code'] = base64.encodebytes(options['action']) |
| |
| if 'limits' in options: |
| body['limits'] = options['limits'] |
| |
| if 'annotations' in options and isinstance(options['annotations'], dict): |
| annotations = [] |
| for key, value in options['annotations']: |
| annotations.append({ 'key':key, 'value': value}) |
| |
| return body |
| |
| |
| def parse_id_and_ns(name): |
| name = name.strip() |
| if len(name) == 0: |
| raise Exception('Name is not specified') |
| parts = name.split('/') |
| |
| n = len(parts) - 1 |
| leadingSlash = name[0] == '/' if len(name) > 0 else False |
| |
| if n == 0 or (n == 1 and not leadingSlash): |
| return { 'namespace': default_namespace, 'id': name } |
| |
| # checking for `/namespace/resource_name` and `namespace/package/resource_name` |
| if n == 2: |
| if leadingSlash: |
| return { 'namespace': parts[1], 'id': parts[2] } |
| else: |
| return { 'namespace': parts[0], 'id': parts[1]+'/'+parts[2] } |
| |
| # checking for `/namespace/package/resource_name` |
| if n == 3 and leadingSlash: |
| return { 'namespace': parts[1], 'id': parts[2]+'/'+parts[3] } |
| |
| raise Exception(invalid_resource_error) |
| |
| def parse_id(name): |
| return parse_id_and_ns(name)['id'] |
| |
| def parse_namespace(name): |
| return parse_id_and_ns(name)['namespace'] |
| |
| missing_feed_name_error= 'Missing mandatory feedName or id parameters from options.', |
| missing_feed_trigger_error= 'Missing mandatory trigger parameter from options.', |
| missing_action_error= 'Missing mandatory actionName parameter from options.', |
| invalid_action_error= 'Invalid actionName parameter from options. Should be "action", "/namespace/action" or "/namespace/package/action".', |
| invalid_resource_error= 'Invalid resource identifier from options. Should be "resource", "/namespace/resource" or "/namespace/package/resource".', |
| missing_action_body_error= 'Missing mandatory action parameter from options.', |
| missing_rule_error= 'Missing mandatory ruleName parameter from options.', |
| missing_trigger_error= 'Missing mandatory triggerName parameter from options.', |
| missing_package_error= 'Missing mandatory packageName parameter from options.', |
| missing_activation_id_error= 'Missing mandatory activation parameter from options.', |
| missing_rule_action_error= 'Missing mandatory action parameter from options.', |
| missing_rule_trigger_error= 'Missing mandatory trigger parameter from options.', |
| missing_trigger_body_error= 'Missing mandatory trigger parameter from options.', |
| missing_package_body_error= 'Missing mandatory package parameter from options.', |
| missing_namespace_error= 'Missing namespace from options, please set a default namespace or pass one in the options.', |
| invalid_options_error= 'Invalid constructor options.', |
| missing_basepath_error= 'Missing mandatory parameters: basepath or name.', |
| invalid_basepath_error= 'Invalid parameters: use basepath or name, not both.' |