├── .github └── workflows │ ├── jira_create_issue.yml │ ├── jira_update_issue_closed.yml │ └── jira_update_issue_reopen.yml ├── .gitignore ├── LICENSE ├── README.md ├── app.py ├── cache └── .gitkeep ├── default_settings.py ├── logging_settings.py ├── requirements.txt ├── templates ├── base.html └── code.html └── utils.py /.github/workflows/jira_create_issue.yml: -------------------------------------------------------------------------------- 1 | name: Create Issue in Jira 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | create_jira_issue: 10 | uses: XeroAPI/Xero-OpenAPI/.github/workflows/jira_create_issue.yml@master 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.github/workflows/jira_update_issue_closed.yml: -------------------------------------------------------------------------------- 1 | name: Update Jira Ticket Status To Done 2 | 3 | on: 4 | issues: 5 | types: 6 | - closed 7 | 8 | jobs: 9 | create_jira_issue: 10 | uses: XeroAPI/Xero-OpenAPI/.github/workflows/jira_update_issue_closed.yml@master 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.github/workflows/jira_update_issue_reopen.yml: -------------------------------------------------------------------------------- 1 | name: Update Jira Ticket Status To Backlog 2 | 3 | on: 4 | issues: 5 | types: 6 | - reopened 7 | 8 | jobs: 9 | create_jira_issue: 10 | uses: XeroAPI/Xero-OpenAPI/.github/workflows/jira_update_issue_reopen.yml@master 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | config.py 3 | cache/ 4 | __pycache__/ 5 | 6 | .DS_Store 7 | .vscode/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Xero Developer API 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xero-python-oauth-starter 2 | 3 | This is a starter app with the code to perform OAuth 2.0 authentication 4 | 5 | You'll be able to connect to a Xero Organisation and make real API calls - we recommend you connect to the Demo company. 6 | Please use your Demo Company organisation for your testing. 7 | [Here](https://central.xero.com/s/article/Use-the-demo-company) is how to turn it on. 8 | 9 | ## Getting Started 10 | 11 | ### Prerequirements 12 | * python3.5+ installed 13 | * git installed 14 | * SSH keys setup for your github profile. 15 | 16 | ### Download the code 17 | * Clone this repo to your local drive. 18 | 19 | ### Local installation 20 | * Open terminal window and navigate to your `xero-python-oauth2-starter` local drive directory 21 | * Create new python virtual environment by running `python3 -m venv venv` 22 | * Activate new virtual environment by running `source venv/bin/activate` 23 | * Install project dependencies by running `pip install -r requirements.txt` 24 | 25 | ## Create a Xero App 26 | To obtain your API keys, follow these steps and create a Xero app 27 | 28 | * Create a [free Xero user account](https://www.xero.com/us/signup/api/) (if you don't have one) 29 | * Login to [Xero developer center](https://developer.xero.com/myapps) 30 | * Click "New App" link 31 | * Enter your App name, company url, privacy policy url. 32 | * Enter the redirect URI (your callback url - i.e. `http://localhost:5000/callback`) 33 | * Be aware `http://localhost/` and `http:/127.0.0.1/` are different urls 34 | * Agree to terms and condition and click "Create App". 35 | * Click "Generate a secret" button. 36 | * Copy your client id and client secret and save for use later. 37 | * Click the "Save" button. You secret is now hidden. 38 | 39 | ## Configure API keys 40 | * Create a `config.py` file in the root directory of this project & add the 2 variables 41 | ```python 42 | CLIENT_ID = "...client id string..." 43 | CLIENT_SECRET = "...client secret string..." 44 | ``` 45 | 46 | ## Take it for a spin 47 | 48 | * Make sure your python virtual environment activated `source venv/bin/activate` 49 | * Start flask application `python3 app.py` 50 | * Launch your browser and navigate to http://localhost:5000/login 51 | * You should be redirected to Xero login page. 52 | * Grant access to your user account and select the Demo company to connect to. 53 | * Done - try out the different API calls 54 | 55 | ### This starter app functions include: 56 | 57 | * connect & reconnect to xero 58 | * storing Xero token in a permanent flask session (in local drive file) 59 | * refresh Xero access token on expiry (happens automatically) 60 | * read organisation information from /organisation endpoint 61 | * read invoices information from /invoices endpoint 62 | * create a new contact in Xero 63 | 64 | ## License 65 | 66 | This software is published under the [MIT License](http://en.wikipedia.org/wiki/MIT_License). 67 | 68 | Copyright (c) 2020 Xero Limited 69 | 70 | Permission is hereby granted, free of charge, to any person 71 | obtaining a copy of this software and associated documentation 72 | files (the "Software"), to deal in the Software without 73 | restriction, including without limitation the rights to use, 74 | copy, modify, merge, publish, distribute, sublicense, and/or sell 75 | copies of the Software, and to permit persons to whom the 76 | Software is furnished to do so, subject to the following 77 | conditions: 78 | 79 | The above copyright notice and this permission notice shall be 80 | included in all copies or substantial portions of the Software. 81 | 82 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 83 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 84 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 85 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 86 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 87 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 88 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 89 | OTHER DEALINGS IN THE SOFTWARE. 90 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from functools import wraps 4 | from io import BytesIO 5 | from logging.config import dictConfig 6 | 7 | from flask import Flask, url_for, render_template, session, redirect, json, send_file 8 | from flask_oauthlib.contrib.client import OAuth, OAuth2Application 9 | from flask_session import Session 10 | from xero_python.accounting import AccountingApi, ContactPerson, Contact, Contacts 11 | from xero_python.api_client import ApiClient, serialize 12 | from xero_python.api_client.configuration import Configuration 13 | from xero_python.api_client.oauth2 import OAuth2Token 14 | from xero_python.exceptions import AccountingBadRequestException 15 | from xero_python.identity import IdentityApi 16 | from xero_python.utils import getvalue 17 | 18 | import logging_settings 19 | from utils import jsonify, serialize_model 20 | 21 | dictConfig(logging_settings.default_settings) 22 | 23 | # configure main flask application 24 | app = Flask(__name__) 25 | app.config.from_object("default_settings") 26 | app.config.from_pyfile("config.py", silent=True) 27 | 28 | if app.config["ENV"] != "production": 29 | # allow oauth2 loop to run over http (used for local testing only) 30 | os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" 31 | 32 | # configure persistent session cache 33 | Session(app) 34 | 35 | # configure flask-oauthlib application 36 | # TODO fetch config from https://identity.xero.com/.well-known/openid-configuration #1 37 | oauth = OAuth(app) 38 | xero = oauth.remote_app( 39 | name="xero", 40 | version="2", 41 | client_id=app.config["CLIENT_ID"], 42 | client_secret=app.config["CLIENT_SECRET"], 43 | endpoint_url="https://api.xero.com/", 44 | authorization_url="https://login.xero.com/identity/connect/authorize", 45 | access_token_url="https://identity.xero.com/connect/token", 46 | refresh_token_url="https://identity.xero.com/connect/token", 47 | scope="offline_access openid profile email accounting.transactions " 48 | "accounting.journals.read accounting.transactions payroll.payruns accounting.reports.read " 49 | "files accounting.settings.read accounting.settings accounting.attachments payroll.payslip payroll.settings files.read openid assets.read profile payroll.employees projects.read email accounting.contacts.read accounting.attachments.read projects assets accounting.contacts payroll.timesheets accounting.budgets.read", 50 | ) # type: OAuth2Application 51 | 52 | 53 | # configure xero-python sdk client 54 | api_client = ApiClient( 55 | Configuration( 56 | debug=app.config["DEBUG"], 57 | oauth2_token=OAuth2Token( 58 | client_id=app.config["CLIENT_ID"], client_secret=app.config["CLIENT_SECRET"] 59 | ), 60 | ), 61 | pool_threads=1, 62 | ) 63 | 64 | 65 | # configure token persistence and exchange point between flask-oauthlib and xero-python 66 | @xero.tokengetter 67 | @api_client.oauth2_token_getter 68 | def obtain_xero_oauth2_token(): 69 | return session.get("token") 70 | 71 | 72 | @xero.tokensaver 73 | @api_client.oauth2_token_saver 74 | def store_xero_oauth2_token(token): 75 | session["token"] = token 76 | session.modified = True 77 | 78 | 79 | def xero_token_required(function): 80 | @wraps(function) 81 | def decorator(*args, **kwargs): 82 | xero_token = obtain_xero_oauth2_token() 83 | if not xero_token: 84 | return redirect(url_for("login", _external=True)) 85 | 86 | return function(*args, **kwargs) 87 | 88 | return decorator 89 | 90 | 91 | @app.route("/") 92 | def index(): 93 | xero_access = dict(obtain_xero_oauth2_token() or {}) 94 | return render_template( 95 | "code.html", 96 | title="Home | oauth token", 97 | code=json.dumps(xero_access, sort_keys=True, indent=4), 98 | ) 99 | 100 | 101 | @app.route("/tenants") 102 | @xero_token_required 103 | def tenants(): 104 | identity_api = IdentityApi(api_client) 105 | accounting_api = AccountingApi(api_client) 106 | 107 | available_tenants = [] 108 | for connection in identity_api.get_connections(): 109 | tenant = serialize(connection) 110 | if connection.tenant_type == "ORGANISATION": 111 | organisations = accounting_api.get_organisations( 112 | xero_tenant_id=connection.tenant_id 113 | ) 114 | tenant["organisations"] = serialize(organisations) 115 | 116 | available_tenants.append(tenant) 117 | 118 | return render_template( 119 | "code.html", 120 | title="Xero Tenants", 121 | code=json.dumps(available_tenants, sort_keys=True, indent=4), 122 | ) 123 | 124 | 125 | @app.route("/create-contact-person") 126 | @xero_token_required 127 | def create_contact_person(): 128 | xero_tenant_id = get_xero_tenant_id() 129 | accounting_api = AccountingApi(api_client) 130 | 131 | contact_person = ContactPerson( 132 | first_name="John", 133 | last_name="Smith", 134 | email_address="john.smith@24locks.com", 135 | include_in_emails=True, 136 | ) 137 | contact = Contact( 138 | name="FooBar", 139 | first_name="Foo", 140 | last_name="Bar", 141 | email_address="ben.bowden@24locks.com", 142 | contact_persons=[contact_person], 143 | ) 144 | contacts = Contacts(contacts=[contact]) 145 | try: 146 | created_contacts = accounting_api.create_contacts( 147 | xero_tenant_id, contacts=contacts 148 | ) # type: Contacts 149 | except AccountingBadRequestException as exception: 150 | sub_title = "Error: " + exception.reason 151 | code = jsonify(exception.error_data) 152 | else: 153 | sub_title = "Contact {} created.".format( 154 | getvalue(created_contacts, "contacts.0.name", "") 155 | ) 156 | code = serialize_model(created_contacts) 157 | 158 | return render_template( 159 | "code.html", title="Create Contacts", code=code, sub_title=sub_title 160 | ) 161 | 162 | 163 | @app.route("/create-multiple-contacts") 164 | @xero_token_required 165 | def create_multiple_contacts(): 166 | xero_tenant_id = get_xero_tenant_id() 167 | accounting_api = AccountingApi(api_client) 168 | 169 | contact = Contact( 170 | name="George Jetson", 171 | first_name="George", 172 | last_name="Jetson", 173 | email_address="george.jetson@aol.com", 174 | ) 175 | # Add the same contact twice - the first one will succeed, but the 176 | # second contact will fail with a validation error which we'll show. 177 | contacts = Contacts(contacts=[contact, contact]) 178 | try: 179 | created_contacts = accounting_api.create_contacts( 180 | xero_tenant_id, contacts=contacts, summarize_errors=False 181 | ) # type: Contacts 182 | except AccountingBadRequestException as exception: 183 | sub_title = "Error: " + exception.reason 184 | result_list = None 185 | code = jsonify(exception.error_data) 186 | else: 187 | sub_title = "" 188 | result_list = [] 189 | for contact in created_contacts.contacts: 190 | if contact.has_validation_errors: 191 | error = getvalue(contact.validation_errors, "0.message", "") 192 | result_list.append("Error: {}".format(error)) 193 | else: 194 | result_list.append("Contact {} created.".format(contact.name)) 195 | 196 | code = serialize_model(created_contacts) 197 | 198 | return render_template( 199 | "code.html", 200 | title="Create Multiple Contacts", 201 | code=code, 202 | result_list=result_list, 203 | sub_title=sub_title, 204 | ) 205 | 206 | 207 | @app.route("/invoices") 208 | @xero_token_required 209 | def get_invoices(): 210 | xero_tenant_id = get_xero_tenant_id() 211 | accounting_api = AccountingApi(api_client) 212 | 213 | invoices = accounting_api.get_invoices( 214 | xero_tenant_id, statuses=["DRAFT", "SUBMITTED"] 215 | ) 216 | code = serialize_model(invoices) 217 | sub_title = "Total invoices found: {}".format(len(invoices.invoices)) 218 | 219 | return render_template( 220 | "code.html", title="Invoices", code=code, sub_title=sub_title 221 | ) 222 | 223 | 224 | @app.route("/login") 225 | def login(): 226 | redirect_url = url_for("oauth_callback", _external=True) 227 | response = xero.authorize(callback_uri=redirect_url) 228 | return response 229 | 230 | 231 | @app.route("/callback") 232 | def oauth_callback(): 233 | try: 234 | response = xero.authorized_response() 235 | except Exception as e: 236 | print(e) 237 | raise 238 | # todo validate state value 239 | if response is None or response.get("access_token") is None: 240 | return "Access denied: response=%s" % response 241 | store_xero_oauth2_token(response) 242 | return redirect(url_for("index", _external=True)) 243 | 244 | 245 | @app.route("/logout") 246 | def logout(): 247 | store_xero_oauth2_token(None) 248 | return redirect(url_for("index", _external=True)) 249 | 250 | 251 | @app.route("/export-token") 252 | @xero_token_required 253 | def export_token(): 254 | token = obtain_xero_oauth2_token() 255 | buffer = BytesIO("token={!r}".format(token).encode("utf-8")) 256 | buffer.seek(0) 257 | return send_file( 258 | buffer, 259 | mimetype="x.python", 260 | as_attachment=True, 261 | attachment_filename="oauth2_token.py", 262 | ) 263 | 264 | 265 | @app.route("/refresh-token") 266 | @xero_token_required 267 | def refresh_token(): 268 | xero_token = obtain_xero_oauth2_token() 269 | new_token = api_client.refresh_oauth2_token() 270 | return render_template( 271 | "code.html", 272 | title="Xero OAuth2 token", 273 | code=jsonify({"Old Token": xero_token, "New token": new_token}), 274 | sub_title="token refreshed", 275 | ) 276 | 277 | 278 | def get_xero_tenant_id(): 279 | token = obtain_xero_oauth2_token() 280 | if not token: 281 | return None 282 | 283 | identity_api = IdentityApi(api_client) 284 | for connection in identity_api.get_connections(): 285 | if connection.tenant_type == "ORGANISATION": 286 | return connection.tenant_id 287 | 288 | 289 | if __name__ == '__main__': 290 | app.run(host='localhost', port=5000) 291 | -------------------------------------------------------------------------------- /cache/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XeroAPI/xero-python-oauth2-starter/e321814ec9cdbbac073d1d26178302f259753916/cache/.gitkeep -------------------------------------------------------------------------------- /default_settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from os.path import dirname, join 4 | 5 | SECRET_KEY = os.urandom(16) 6 | # configure file based session 7 | SESSION_TYPE = "filesystem" 8 | SESSION_FILE_DIR = join(dirname(__file__), "cache") 9 | 10 | # configure flask app for local development 11 | ENV = "development" 12 | -------------------------------------------------------------------------------- /logging_settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | default_settings = { 4 | "version": 1, 5 | "formatters": { 6 | "default": {"format": "[%(asctime)s] %(levelname)s in %(module)s: %(message)s"}, 7 | "verbose": { 8 | "format": "%(asctime)s | %(levelname)s [%(name)s.%(filename)s:%(lineno)s] %(message)s", 9 | "datefmt": "%Y-%m-%d %H:%M:%S%z", 10 | }, 11 | }, 12 | "handlers": { 13 | "console": { 14 | "class": "logging.StreamHandler", 15 | "stream": "ext://flask.logging.wsgi_errors_stream", 16 | "formatter": "verbose", 17 | "level": "DEBUG", 18 | } 19 | }, 20 | "loggers": { 21 | "requests_oauthlib": {"handlers": ["console"], "level": "DEBUG"}, 22 | "xero_python": {"handlers": ["console"], "level": "DEBUG"}, 23 | "urllib3": {"handlers": ["console"], "level": "DEBUG"}, 24 | }, 25 | # "root": {"level": "DEBUG", "handlers": ["console"]}, 26 | } 27 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask-session==0.3.2 3 | flask-oauthlib==0.9.6 4 | xero-python==1.5.3 -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% if title %}{{ title }}{% else %}Welcome to Xero Python oauth starter{% endif %} 6 | 7 | 8 | Index | 9 | Login | 10 | Logout | 11 | Tenants | 12 | Export Token | 13 | Create Contact | 14 | Create Multiple Contacts | 15 | Invoices | 16 | Refresh Token | 17 | {% block content %}{% endblock %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /templates/code.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

{{ title }}

6 | {% if sub_title %}

{{ sub_title }}

{% endif %} 7 | {% if result_list %} 8 | 13 | {% endif %} 14 |
{{ code }}
15 |
16 | {% endblock %} -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import uuid 4 | from datetime import datetime, date 5 | from decimal import Decimal 6 | 7 | from xero_python.api_client.serializer import serialize 8 | 9 | 10 | class JSONEncoder(json.JSONEncoder): 11 | def default(self, o): 12 | if isinstance(o, datetime): 13 | return o.isoformat() 14 | if isinstance(o, date): 15 | return o.isoformat() 16 | if isinstance(o, (uuid.UUID, Decimal)): 17 | return str(o) 18 | return super(JSONEncoder, self).default(o) 19 | 20 | 21 | def parse_json(data): 22 | return json.loads(data, parse_float=Decimal) 23 | 24 | 25 | def serialize_model(model): 26 | return jsonify(serialize(model)) 27 | 28 | 29 | def jsonify(data): 30 | return json.dumps(data, sort_keys=True, indent=4, cls=JSONEncoder) 31 | --------------------------------------------------------------------------------