From 7fc5ae9b4ed216a9971779582dbdac922ec810dc Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 3 Mar 2020 21:35:39 -0600 Subject: [PATCH] Add basic CORE webservices API client, for Vendor data lots more to come yet, once the basic patterns are proven --- corepos/api.py | 174 ++++++++++++++++++++++++++++++++++ corepos/db/office_op/model.py | 5 +- 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 corepos/api.py diff --git a/corepos/api.py b/corepos/api.py new file mode 100644 index 0000000..c444eae --- /dev/null +++ b/corepos/api.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# pyCOREPOS -- Python Interface to CORE POS +# Copyright © 2018-2020 Lance Edgar +# +# This file is part of pyCOREPOS. +# +# pyCOREPOS is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# pyCOREPOS is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# pyCOREPOS. If not, see . +# +################################################################################ +""" +CORE-POS webservices API +""" + +import json +import logging + +import requests + + +log = logging.getLogger(__name__) + + +class CoreAPIError(Exception): + """ + Base class for errors coming from the CORE API proper. + """ + + def __init__(self, message): + self.message = message + + def __str__(self): + return "CORE API returned an error: {}".format(self.message) + + +class CoreWebAPI(object): + """ + Client implementation for the CORE webservices API. + """ + + def __init__(self, url, verify=True): + """ + Constructor for the API client. + + :param str url: URL to the CORE webservices API, + e.g. ``'http://localhost/fannie/ws/'`` + + :param bool verify: How to handle certificate validation for HTTPS + URLs. This value is passed as-is to the ``requests`` library, so + see those docs for more info. The default value for this is + ``True`` because the assumption is that security should be on by + default. Set it to ``False`` in order to disable validation + entirely, e.g. for self-signed certs. (This may also be needed for + basic HTTP URLs?) Other values may be possible also; again see the + ``requests`` docs for more info. + """ + self.url = url + self.verify = verify + + def post(self, params, method=None): + """ + Issue a POST request to the API, with the given ``params``. If not + specified, ``method`` will be CORE's ``FannieEntity`` webservice. + """ + if not method: + method = r'\COREPOS\Fannie\API\webservices\FannieEntity' + + payload = { + 'jsonrpc': '2.0', + 'method': method, + 'params': params, + # we're not dealing with async here, so KISS for this 'id' + # https://stackoverflow.com/questions/4390369/json-rpc-how-can-one-make-a-unique-id#comment4786119_4391070 + 'id': 1, + } + + response = requests.post(self.url, data=json.dumps(payload), + verify=self.verify) + response.raise_for_status() + return response + + def parse_response(self, response): + """ + Generic method to "parse" a response from the API. Really this just + converts the JSON to a dict (etc.), and then checks for error. If an + error is found in the response, it will be raised here. + """ + try: + js = response.json() + except: + raise CoreAPIError("Received invalid response: {}".format(response.content)) + + if 'error' in js: + raise CoreAPIError(js['error']) + + assert set(js.keys()) == set(['jsonrpc', 'id', 'result']) + assert set(js['result'].keys()) == set(['result']) + return js['result']['result'] + + def get_vendors(self, **columns): + """ + Fetch some or all of Vendor records from CORE. + + :returns: A (potentially empty) list of vendor dict records. + + To fetch all vendors:: + + api.get_vendors() + + To fetch only vendors named "UNFI":: + + api.get_vendors(vendorName='UNFI') + """ + params = { + 'entity': 'Vendors', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + return [json.loads(rec) for rec in result] + + def get_vendor(self, vendorID, **columns): + """ + Fetch an existing Vendor record from CORE. + + :returns: Either a vendor dict record, or ``None``. + """ + columns['vendorID'] = vendorID + params = { + 'entity': 'Vendors', + 'submethod': 'get', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + if result: + if len(result) > 1: + log.warning("CORE API returned %s vendor results", len(result)) + return json.loads(result[0]) + + def set_vendor(self, vendorID, **columns): + """ + Update an existing Vendor record in CORE. + + :returns: Boolean indicating success of the operation. + """ + columns['vendorID'] = vendorID + params = { + 'entity': 'Vendors', + 'submethod': 'set', + 'columns': columns, + } + response = self.post(params) + result = self.parse_response(response) + + if result == 'OK': + return True + + # TODO: need to see what happens here, when it happens + raise NotImplementedError("Unexpected result for set_vendor: {}".format(result)) + # return False diff --git a/corepos/db/office_op/model.py b/corepos/db/office_op/model.py index e1e54f7..1227039 100644 --- a/corepos/db/office_op/model.py +++ b/corepos/db/office_op/model.py @@ -137,7 +137,10 @@ class Vendor(Base): """ __tablename__ = 'vendors' - id = sa.Column('vendorID', sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + # TODO: this maybe should be the pattern we use going forward, for all + # models? for now it was deemed necessary to "match" the API output + vendorID = sa.Column(sa.Integer(), primary_key=True, autoincrement=False, nullable=False) + id = orm.synonym('vendorID') name = sa.Column('vendorName', sa.String(length=50), nullable=True)