Coverage for src/backend/InvenTree/plugin/base/integration/APICallMixin.py: 66%
66 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Mixin class for making calls to an external API."""
3import json as json_pkg
4from collections.abc import Iterable
5from typing import Optional
7import requests
8import structlog
10from plugin import PluginMixinEnum
11from plugin.helpers import MixinNotImplementedError
13logger = structlog.get_logger('inventree')
16class APICallMixin:
17 """Mixin that enables easier API calls for a plugin.
19 Steps to set up:
20 1. Add this mixin before (left of) SettingsMixin and PluginBase
21 2. Add two settings for the required url and token/password (use `SettingsMixin`)
22 3. Save the references to keys of the settings in `API_URL_SETTING` and `API_TOKEN_SETTING`
23 4. (Optional) Set `API_TOKEN` to the name required for the token by the external API - Defaults to `Bearer`
24 5. (Optional) Override the `api_url` property method if the setting needs to be extended
25 6. (Optional) Override `api_headers` to add extra headers (by default the token and Content-Type are contained)
26 7. Access the API in you plugin code via `api_call`
28 Example:
29 ```
30 from plugin import InvenTreePlugin
31 from plugin.mixins import APICallMixin, SettingsMixin
34 class SampleApiCallerPlugin(APICallMixin, SettingsMixin, InvenTreePlugin):
35 '''
36 A small api call sample
37 '''
38 NAME = "Sample API Caller"
40 SETTINGS = {
41 'API_TOKEN': {
42 'name': 'API Token',
43 'protected': True,
44 },
45 'API_URL': {
46 'name': 'External URL',
47 'description': 'Where is your API located?',
48 'default': 'reqres.in',
49 },
50 }
51 API_URL_SETTING = 'API_URL'
52 API_TOKEN_SETTING = 'API_TOKEN'
54 def get_external_url(self):
55 '''
56 returns data from the sample endpoint
57 '''
58 return self.api_call('api/users/2')
59 ```
60 """
62 API_METHOD = 'https'
63 API_URL_SETTING = None
64 API_TOKEN_SETTING = None
66 API_TOKEN = 'Bearer'
68 class MixinMeta:
69 """Meta options for this mixin."""
71 MIXIN_NAME = 'API calls'
73 def __init__(self):
74 """Register mixin."""
75 super().__init__()
76 self.add_mixin(PluginMixinEnum.API_CALL, 'has_api_call', __class__)
78 @property
79 def has_api_call(self):
80 """Is the mixin ready to call external APIs?"""
81 if not bool(self.API_URL_SETTING):
82 raise MixinNotImplementedError('API_URL_SETTING must be defined')
83 if not bool(self.API_TOKEN_SETTING):
84 raise MixinNotImplementedError('API_TOKEN_SETTING must be defined')
85 return True
87 @property
88 def api_url(self):
89 """Base url path."""
90 return f'{self.API_METHOD}://{self.get_setting(self.API_URL_SETTING)}'
92 @property
93 def api_headers(self):
94 """Returns the default headers for requests with api_call.
96 Contains a header with the key set in `API_TOKEN` for the plugin it `API_TOKEN_SETTING` is defined.
97 Check the mixin class docstring for a full example.
98 """
99 headers = {'Content-Type': 'application/json'}
100 if getattr(self, 'API_TOKEN_SETTING', None): 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 token = self.get_setting(self.API_TOKEN_SETTING)
103 if token:
104 headers[self.API_TOKEN] = token
105 headers['Authorization'] = f'{self.API_TOKEN} {token}'
107 return headers
109 def api_build_url_args(self, arguments: dict) -> str:
110 """Returns an encoded path for the provided dict."""
111 groups = []
112 for key, val in arguments.items():
113 if isinstance(val, Iterable) and not isinstance(val, str): 113 ↛ 115line 113 didn't jump to line 115 because the condition on line 113 was always true
114 val = ','.join([str(a) for a in val])
115 groups.append(f'{key}={val}')
116 return f'?{"&".join(groups)}'
118 def api_call(
119 self,
120 endpoint: str,
121 method: str = 'GET',
122 url_args: Optional[dict] = None,
123 data=None,
124 json=None,
125 headers: Optional[dict] = None,
126 simple_response: bool = True,
127 endpoint_is_url: bool = False,
128 **kwargs,
129 ):
130 """Do an API call.
132 Simplest call example:
133 ```python
134 self.api_call('hello')
135 ```
136 Will call the `{base_url}/hello` with a GET request and - if set - the token for this plugin.
138 Args:
139 endpoint (str): Path to current endpoint. Either the endpoint or the full or if the flag is set
140 method (str, optional): HTTP method that should be uses - capitalized. Defaults to 'GET'.
141 url_args (dict, optional): arguments that should be appended to the url. Defaults to None.
142 data (Any, optional): Data that should be transmitted in the body - url-encoded. Defaults to None.
143 json (Any, optional): Data that should be transmitted in the body - must be JSON serializable. Defaults to None.
144 headers (dict, optional): Headers that should be used for the request. Defaults to self.api_headers.
145 simple_response (bool, optional): Return the response as JSON. Defaults to True.
146 endpoint_is_url (bool, optional): The provided endpoint is the full url - do not use self.api_url as base. Defaults to False.
148 Returns:
149 Response
150 """
151 if url_args: 151 ↛ 154line 151 didn't jump to line 154 because the condition on line 151 was always true
152 endpoint += self.api_build_url_args(url_args)
154 if headers is None: 154 ↛ 157line 154 didn't jump to line 157 because the condition on line 154 was always true
155 headers = self.api_headers
157 if endpoint_is_url: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true
158 url = endpoint
159 else:
160 if endpoint.startswith('/'): 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true
161 endpoint = endpoint[1:]
163 url = f'{self.api_url}/{endpoint}'
165 # build kwargs for call
166 kwargs.update({'headers': headers})
167 kwargs.pop('url', None)
169 if data and json: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 raise ValueError('You can either pass `data` or `json` to this function.')
172 if json: 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 kwargs['data'] = json_pkg.dumps(json)
175 if data: 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true
176 kwargs['data'] = data
178 # run command
179 response = requests.request(method, url=url, **kwargs)
181 # return
182 if simple_response: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 return response.json()
184 return response