Skip to content

Commit 8bcfd4e

Browse files
committed
make api version (v1, v2, vLatest) configurable (#48) when initializing Server; bump version; update README; add sponsor message
1 parent c3c2a47 commit 8bcfd4e

File tree

9 files changed

+106
-88
lines changed

9 files changed

+106
-88
lines changed

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ Quick example:
1111
user='admin',
1212
password='admin',
1313
database='Contacts',
14-
layout='Contacts')
14+
layout='Contacts',
15+
api_version='v1')
1516
>>> fms.login()
1617
>>> record = fms.get_record(1)
1718
>>> record.name
@@ -30,6 +31,10 @@ All API paths can be served:
3031

3132
Access to meta routes is also supported.
3233

34+
## Sponsor
35+
36+
python-fmrest development is supported by [allgood.systems](https://allgood.systems). Monitor your web sites and get notifications when your scheduled FileMaker scripts or system scripts stop running.
37+
3338
## Feel free to contribute!
3439

3540
If you would like to contribute, you can help with the code, try it out and report 🐞🐞, propose new features, write tests, add examples and documentation.

fmrest/cloudserver.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ def __init__(self,
3737
data_sources: Optional[List[Dict]] = None,
3838
verify_ssl: Union[bool, str] = True,
3939
type_conversion: bool = False,
40-
auto_relogin: bool = False
41-
) -> None:
40+
auto_relogin: bool = False,
41+
api_version: Optional[str] = None) -> None:
4242
"""Initialize the CloudServer class.
4343
4444
Parameters
@@ -101,7 +101,8 @@ def __init__(self,
101101
data_sources=data_sources,
102102
verify_ssl=verify_ssl,
103103
type_conversion=type_conversion,
104-
auto_relogin=auto_relogin)
104+
auto_relogin=auto_relogin,
105+
api_version=api_version)
105106

106107
self.cognito_userpool_id = cognito_userpool_id
107108
self.cognito_client_id = cognito_client_id
@@ -119,7 +120,7 @@ def _get_cognito_token(self) -> str:
119120
def _get_bearer_token(self) -> Optional[str]:
120121
"""Retrieve the bearer token needed to authenticate FileMaker Data API calls."""
121122

122-
path = API_PATH['auth'].format(database=self.database, token='')
123+
path = self._get_api_path('auth').format(token='')
123124
data = {'fmDataSource': self.data_sources}
124125

125126
response = self._call_filemaker('POST', path, data=data)

fmrest/const.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,28 @@
22
import os
33
from enum import Enum, unique
44
from pkg_resources import get_distribution
5+
from typing import Dict, Any
56

67
__version__ = get_distribution('python-fmrest').version
78

89
PORTAL_PREFIX = 'portal_'
910
TIMEOUT = int(os.environ.get('fmrest_timeout', 10))
1011

11-
API_PATH = {
12+
API_VERSIONS = ('v1', 'v2', 'vLatest')
13+
API_PATH_PREFIX = '/fmi/data/{version}'
14+
API_PATH: Dict[str, Any] = {
1215
'meta': {
13-
'product': '/fmi/data/v1/productInfo',
14-
'databases': '/fmi/data/v1/databases',
15-
'layouts': '/fmi/data/v1/databases/{database}/layouts',
16-
'scripts': '/fmi/data/v1/databases/{database}/scripts'
16+
'product': '/productInfo',
17+
'databases': '/databases',
18+
'layouts': '/databases/{database}/layouts',
19+
'scripts': '/databases/{database}/scripts'
1720
},
18-
'auth': '/fmi/data/v1/databases/{database}/sessions/{token}',
19-
'record': '/fmi/data/v1/databases/{database}/layouts/{layout}/records',
20-
'record_action': '/fmi/data/v1/databases/{database}/layouts/{layout}/records/{record_id}',
21-
'find': '/fmi/data/v1/databases/{database}/layouts/{layout}/_find',
22-
'script': '/fmi/data/v1/databases/{database}/layouts/{layout}/script/{script_name}',
23-
'global': '/fmi/data/v1/databases/{database}/globals'
21+
'auth': '/databases/{database}/sessions/{token}',
22+
'record': '/databases/{database}/layouts/{layout}/records',
23+
'record_action': '/databases/{database}/layouts/{layout}/records/{record_id}',
24+
'find': '/databases/{database}/layouts/{layout}/_find',
25+
'script': '/databases/{database}/layouts/{layout}/script/{script_name}',
26+
'global': '/databases/{database}/globals'
2427
}
2528

2629

fmrest/server.py

Lines changed: 57 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22
import json
33
import importlib.util
44
import warnings
5-
from typing import (List, Dict, Optional, Any, IO, Tuple, Union, Iterator,
6-
Callable)
5+
from typing import List, Dict, Optional, Any, IO, Tuple, Union, Iterator
76
from functools import wraps
87
import requests
9-
from .utils import request, build_portal_params, build_script_params, filename_from_url
10-
from .const import API_PATH, PORTAL_PREFIX, FMSErrorCode
8+
from .utils import (request, build_portal_params, build_script_params,
9+
filename_from_url, PlaceholderDict)
10+
from .const import (PORTAL_PREFIX, FMSErrorCode, API_VERSIONS, API_PATH_PREFIX,
11+
API_PATH)
1112
from .exceptions import BadJSON, FileMakerError, RecordError
1213
from .record import Record
1314
from .foundset import Foundset
1415

16+
1517
class Server(object):
1618
"""The server class provides easy access to the FileMaker Data API
1719
@@ -22,7 +24,8 @@ class Server(object):
2224
user='db user name',
2325
password='db password',
2426
database='db name',
25-
layout='db layout'
27+
layout='db layout',
28+
api_version='v1'
2629
)
2730
fms.login()
2831
fms.get_record(1)
@@ -41,7 +44,8 @@ def __init__(self, url: str, user: str,
4144
verify_ssl: Union[bool, str] = True,
4245
type_conversion: bool = False,
4346
auto_relogin: bool = False,
44-
proxies: Optional[Dict] = None) -> None:
47+
proxies: Optional[Dict] = None,
48+
api_version: Optional[str] = None) -> None:
4549
"""Initialize the Server class.
4650
4751
Parameters
@@ -83,6 +87,10 @@ def __init__(self, url: str, user: str,
8387
proxies : dict, optional
8488
Pass requests through a proxy, configure like so:
8589
{ 'https': 'http://127.0.0.1:8080' }
90+
api_version : str, optional
91+
Configure which version of the data API should be queried (e.g. v1)
92+
It is recommended to set the version explicitly to prevent
93+
potential future breaking changes.
8694
"""
8795

8896
self.url = url
@@ -95,6 +103,14 @@ def __init__(self, url: str, user: str,
95103
self.auto_relogin = auto_relogin
96104
self.proxies = proxies
97105

106+
if not api_version:
107+
warnings.warn('No api_version given. Defaulting to v1.')
108+
self.api_version = 'v1'
109+
elif api_version not in API_VERSIONS:
110+
raise ValueError(f'Invalid API version. Choose one of {API_VERSIONS}')
111+
else:
112+
self.api_version = api_version
113+
98114
self.type_conversion = type_conversion
99115
if type_conversion and not importlib.util.find_spec("dateutil"):
100116
warnings.warn('Turning on type_conversion needs the dateutil module, which '
@@ -121,6 +137,20 @@ def __repr__(self) -> str:
121137
bool(self._token), self.database, self.layout
122138
)
123139

140+
def _get_api_path(self, resource: str) -> str:
141+
resource_path = resource.split('.')
142+
if len(resource_path) > 1:
143+
if resource_path[0] == 'meta':
144+
path = API_PATH['meta'][resource_path[1]]
145+
else:
146+
raise ValueError('Invalid API path')
147+
else:
148+
path = API_PATH[resource_path[0]]
149+
150+
return (API_PATH_PREFIX.format(version=self.api_version) +
151+
path.format_map(PlaceholderDict(database=self.database,
152+
layout=self.layout)))
153+
124154
def _with_auto_relogin(f):
125155
@wraps(f)
126156
def wrapper(self, *args, **kwargs):
@@ -148,7 +178,7 @@ def login(self) -> Optional[str]:
148178
Note that OAuth is currently not supported.
149179
"""
150180

151-
path = API_PATH['auth'].format(database=self.database, token='')
181+
path = self._get_api_path('auth').format(token='')
152182
data = {'fmDataSource': self.data_sources}
153183

154184
response = self._call_filemaker('POST', path, data, auth=(self.user, self.password))
@@ -163,7 +193,7 @@ def logout(self) -> bool:
163193
"""
164194

165195
# token is expected in endpoint for logout
166-
path = API_PATH['auth'].format(database=self.database, token=self._token)
196+
path = self._get_api_path('auth').format(token=self._token)
167197

168198
# remove token, so that the Authorization header is not sent for logout
169199
# (_call_filemaker() will update the headers)
@@ -199,10 +229,7 @@ def create_record(self, field_data: Dict[str, Any],
199229
{'TO::field': 'another record'}
200230
]
201231
"""
202-
path = API_PATH['record'].format(
203-
database=self.database,
204-
layout=self.layout,
205-
)
232+
path = self._get_api_path('record')
206233

207234
request_data: Dict = {'fieldData': field_data}
208235
if portals:
@@ -254,11 +281,7 @@ def edit_record(self, record_id: int, field_data: Dict[str, Any],
254281
Allowed types: 'prerequest', 'presort', 'after'
255282
List should have length of 2 (both script name and parameter are required.)
256283
"""
257-
path = API_PATH['record_action'].format(
258-
database=self.database,
259-
layout=self.layout,
260-
record_id=record_id
261-
)
284+
path = self._get_api_path('record_action').format(record_id=record_id)
262285

263286
request_data: Dict = {'fieldData': field_data}
264287
if mod_id:
@@ -299,11 +322,7 @@ def delete_record(self, record_id: int, scripts: Optional[Dict[str, List]] = Non
299322
Allowed types: 'prerequest', 'presort', 'after'
300323
List should have length of 2 (both script name and parameter are required.)
301324
"""
302-
path = API_PATH['record_action'].format(
303-
database=self.database,
304-
layout=self.layout,
305-
record_id=record_id
306-
)
325+
path = self._get_api_path('record_action').format(record_id=record_id)
307326

308327
params = build_script_params(scripts) if scripts else None
309328

@@ -337,11 +356,7 @@ def get_record(self, record_id: int, portals: Optional[List[Dict]] = None,
337356
This is helpful, for example, if you want to limit the number of fields/portals being
338357
returned and have a dedicated response layout.
339358
"""
340-
path = API_PATH['record_action'].format(
341-
database=self.database,
342-
layout=self.layout,
343-
record_id=record_id
344-
)
359+
path = self._get_api_path('record_action').format(record_id=record_id)
345360

346361
params = build_portal_params(portals, True) if portals else {}
347362
params['layout.response'] = layout
@@ -371,11 +386,7 @@ def perform_script(self, name: str,
371386
param: str
372387
Optional script parameter
373388
"""
374-
path = API_PATH['script'].format(
375-
database=self.database,
376-
layout=self.layout,
377-
script_name=name
378-
)
389+
path = self._get_api_path('script').format(script_name=name)
379390

380391
response = self._call_filemaker('GET', path, params={'script.param': param})
381392

@@ -397,11 +408,8 @@ def upload_container(self, record_id: int, field_name: str, file_: IO) -> bool:
397408
file_ : fileobj
398409
File object as returned by open() in binary mode.
399410
"""
400-
path = API_PATH['record_action'].format(
401-
database=self.database,
402-
layout=self.layout,
403-
record_id=record_id
404-
) + '/containers/' + field_name + '/1'
411+
path = self._get_api_path('record_action').format(record_id=record_id)
412+
path += '/containers/' + field_name + '/1'
405413

406414
# requests library handles content type for multipart/form-data incl. boundary
407415
self._set_content_type(False)
@@ -441,10 +449,7 @@ def get_records(self, offset: int = 1, limit: int = 100,
441449
This is helpful, for example, if you want to limit the number of fields/portals being
442450
returned and have a dedicated response layout.
443451
"""
444-
path = API_PATH['record'].format(
445-
database=self.database,
446-
layout=self.layout
447-
)
452+
path = self._get_api_path('record')
448453

449454
params = build_portal_params(portals, True) if portals else {}
450455
params['_offset'] = offset
@@ -507,10 +512,7 @@ def find(self, query: List[Dict[str, Any]],
507512
This is helpful, for example, if you want to limit the number of fields/portals being
508513
returned and have a dedicated response layout.
509514
"""
510-
path = API_PATH['find'].format(
511-
database=self.database,
512-
layout=self.layout
513-
)
515+
path = self._get_api_path('find')
514516

515517
data = {
516518
'query': query,
@@ -589,7 +591,7 @@ def set_globals(self, globals_: Dict[str, Any]) -> bool:
589591
Example:
590592
{ 'Table::myField': 'whatever' }
591593
"""
592-
path = API_PATH['global'].format(database=self.database)
594+
path = self._get_api_path('global')
593595

594596
data = {'globalFields': globals_}
595597

@@ -634,7 +636,7 @@ def get_product_info(self) -> Dict:
634636
-----------
635637
none
636638
"""
637-
path = API_PATH['meta']['product']
639+
path = self._get_api_path('meta.product')
638640

639641
response = self._call_filemaker('GET', path)
640642

@@ -647,18 +649,15 @@ def get_databases(self) -> Dict:
647649
-----------
648650
none
649651
"""
650-
path = API_PATH['meta']['databases']
652+
path = self._get_api_path('meta.databases')
651653

652654
# https://fmhelp.filemaker.com/docs/18/en/dataapi/#get-metadata_get-database-names
653-
# = > If Filter Databases in Client Applications is disabled,
655+
# If Filter Databases in Client Applications is disabled:
654656
# no Authorization header is required.
655-
#response = self._call_filemaker('GET', path)
656-
657-
# https://fmhelp.filemaker.com/docs/18/en/dataapi/#get-metadata_get-database-names
657+
#
658658
# If Filter Databases in Client Applications is enabled:
659-
# Authorization: a base64-encoded string representing the account name
660-
# and password to use to log in to the hosted database.
661-
#response = self._call_filemaker('GET', path, auth=(self.user, self.password))
659+
# Authorization: a base64-encoded string representing the account name
660+
# and password to use to log in to the hosted database.
662661

663662
# See discussion here: https://github.com/davidhamann/python-fmrest/pull/46#issuecomment-1079328531
664663
# "This [will] eventually overwrite the Authorization header with the data provided in the auth
@@ -677,9 +676,7 @@ def get_layouts(self) -> Dict:
677676
-----------
678677
none
679678
"""
680-
path = API_PATH['meta']['layouts'].format(
681-
database=self.database
682-
)
679+
path = self._get_api_path('meta.layouts')
683680

684681
response = self._call_filemaker('GET', path)
685682

@@ -693,9 +690,7 @@ def get_scripts(self) -> Dict:
693690
-----------
694691
none
695692
"""
696-
path = API_PATH['meta']['scripts'].format(
697-
database=self.database
698-
)
693+
path = self._get_api_path('meta.scripts')
699694

700695
response = self._call_filemaker('GET', path)
701696

@@ -709,10 +704,7 @@ def get_layout(self) -> Dict:
709704
-----------
710705
none
711706
"""
712-
path = API_PATH['meta']['layouts'].format(
713-
database=self.database,
714-
layout=self.layout
715-
) + f'/{self.layout}'
707+
path = self._get_api_path('meta.layouts') + f'/{self.layout}'
716708

717709
response = self._call_filemaker('GET', path)
718710

fmrest/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,14 @@ def convert_string_type(value):
164164

165165
# fall back to string
166166
return value
167+
168+
169+
class PlaceholderDict(dict):
170+
"""Used to allow missing values in format maps and keep placeholders intact
171+
Example:
172+
> '{a}, {b}'.format_map(PlaceholderDict(a='hello'))
173+
'hello, {b}'
174+
Also see: https://docs.python.org/3/library/stdtypes.html#str.format_map
175+
"""
176+
def __missing__(self, key):
177+
return key.join('{}')

0 commit comments

Comments
 (0)