22import  json 
33import  importlib .util 
44import  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 
76from  functools  import  wraps 
87import  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 )
1112from  .exceptions  import  BadJSON , FileMakerError , RecordError 
1213from  .record  import  Record 
1314from  .foundset  import  Foundset 
1415
16+ 
1517class  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
0 commit comments