99if src_path not in sys .path :
1010 sys .path .insert (0 , src_path )
1111
12- from dataverse_sdk import DataverseClient # noqa: E402
13- from azure .identity import InteractiveBrowserCredential # noqa: E402
14-
15- """Quickstart: Custom API lifecycle (create -> add params -> invoke -> update -> delete).
16-
17- Two operating modes:
18- 1. Plug-in backed (you supply PLUGIN_TYPENAME): request + response property, plug-in sets the output.
19- 2. Plug-in-less (business event style): ONLY a request parameter is created. Invocation will succeed
20- (HTTP 204 / empty body or {{}}) even though no plug-in logic runs. This matches docs stating a
21- custom API does not strictly require a plug-in (it can just raise events). In this mode we omit
22- response properties because without server logic they cannot be populated and may cause confusion.
23-
24- Below we auto-detect: if PLUGIN_TYPENAME is blank we skip creating the response property and use a
25- simple string parameter value. If a plug-in name is provided we also create a response property.
26- """
12+ from dataverse_sdk import DataverseClient
13+ from azure .identity import InteractiveBrowserCredential
2714
2815# ---------------- Configuration ----------------
2916base_url = "https://aurorabapenv0f528.crm10.dynamics.com" # <-- change to your environment
3017CUSTOM_API_UNIQUE_NAME = "new_EchoMessage" # Must be globally unique in the org
3118REQUEST_PARAM_UNIQUE = "new_EchoMessage_Message"
3219RESPONSE_PROP_UNIQUE = "new_EchoMessage_Response"
33- CLEANUP = True # Set True to delete the Custom API at the end
34- PLUGIN_TYPENAME = "" # e.g. "Contoso.Plugins.EchoMessagePlugin" (leave blank for plug-in-less mode)
35- INCLUDE_RESPONSE_PROPERTY = bool (PLUGIN_TYPENAME ) # Only create a response property when a plug-in can populate it
3620PUBLISH_STRATEGY = "auto" # auto | skip | force. force = call PublishAllXml, auto = poll metadata first
21+ # Parameter type codes (subset): 10=String, 7=Int32, 6=Float. Using Int32 for this run.
22+ REQUEST_PARAMETERS = [{
23+ "uniquename" : REQUEST_PARAM_UNIQUE ,
24+ "name" : "Message" ,
25+ "displayname" : "Message" ,
26+ "type" : 7 , # Int32
27+ "description" : "Integer value to echo / raise event with" ,
28+ "isoptional" : False ,
29+ }]
30+ RESPONSE_PROPERTIES = [{
31+ "uniquename" : RESPONSE_PROP_UNIQUE ,
32+ "name" : "ResponseMessage" ,
33+ "displayname" : "ResponseMessage" ,
34+ "type" : 10 , # String response
35+ "description" : "Echoed string" ,
36+ }]
3737
3838# ------------------------------------------------
3939client = DataverseClient (base_url = base_url , credential = InteractiveBrowserCredential ())
@@ -56,7 +56,7 @@ def backoff_retry(op, *, delays=(0, 2, 5), retry_http_statuses=(429, 500, 502, 5
5656 time .sleep (d )
5757 try :
5858 return op ()
59- except Exception as ex : # noqa: BLE001
59+ except Exception as ex :
6060 last_exc = ex
6161 if isinstance (ex , requests .exceptions .HTTPError ):
6262 code = getattr (getattr (ex , "response" , None ), "status_code" , None )
@@ -66,18 +66,16 @@ def backoff_retry(op, *, delays=(0, 2, 5), retry_http_statuses=(429, 500, 502, 5
6666 if last_exc :
6767 raise last_exc
6868
69- # 1) List existing custom APIs with our prefix for context
70- print ("List existing custom APIs (prefix=new_) :" )
69+ # 1) Check if target Custom API exists
70+ print ("Check target Custom API existence :" )
7171try :
72- plan ("odata.list_custom_apis(filter_expr=uniquename startswith 'new_')" )
73- existing = backoff_retry (lambda : odata .list_custom_apis (filter_expr = "startswith(uniquename,'new_')" ))
74- print ({"count" : len (existing )})
75- for item in existing [:5 ]: # show a few
76- print (" -" , item .get ("uniquename" ), "isfunction=" + str (item .get ("isfunction" )))
77- except Exception as e : # noqa: BLE001
78- print (f"List custom APIs failed: { e } " )
72+ plan ("odata.get_custom_api(unique_name)" )
73+ existing = backoff_retry (lambda : odata .get_custom_api (unique_name = CUSTOM_API_UNIQUE_NAME ))
74+ print ({"exists" : bool (existing )})
75+ except Exception as e :
76+ print (f"Existence check failed: { e } " )
7977
80- # 2) Create the Custom API if absent
78+ # 2) Create the Custom API, remove the existing one first if present
8179print ("Recreate Custom API fresh (delete if exists then create):" )
8280existing_api = odata .get_custom_api (unique_name = CUSTOM_API_UNIQUE_NAME )
8381if existing_api :
@@ -87,40 +85,30 @@ def backoff_retry(op, *, delays=(0, 2, 5), retry_http_statuses=(429, 500, 502, 5
8785 print ({"deleted_prior" : True })
8886 # Brief pause to allow backend cleanup
8987 time .sleep (2 )
90- except Exception as del_ex : # noqa: BLE001
88+ except Exception as del_ex :
9189 print ({"delete_prior_error" : str (del_ex )})
9290
93- plan ("odata.create_custom_api (inline request parameter + optional response property)" )
91+ plan ("odata.create_custom_api (inline request parameter + response property)" )
9492try :
95- # Parameter type codes (subset): 10=String, 7=Integer, 6=Float. We use String for plug-in-less clarity.
96- request_parameters = [{
97- "uniquename" : REQUEST_PARAM_UNIQUE ,
98- "name" : "Message" ,
99- "displayname" : "Message" ,
100- "type" : 6 , # Int32 (common & simple)
101- "description" : "Integer message to echo / raise event with" ,
102- "isoptional" : False ,
103- }]
104- response_properties = []
105- if INCLUDE_RESPONSE_PROPERTY :
106- response_properties .append ({
107- "uniquename" : RESPONSE_PROP_UNIQUE ,
108- "name" : "ResponseMessage" ,
109- "displayname" : "ResponseMessage" ,
110- "type" : 6 , # Int32 response
111- "description" : "Echoed integer (set by plug-in)" ,
112- })
113-
11493 api_meta = backoff_retry (lambda : odata .create_custom_api (
11594 unique_name = CUSTOM_API_UNIQUE_NAME ,
11695 name = "Echo Message" ,
11796 description = "Echo sample (metadata only) created by SDK quickstart." ,
11897 is_function = False ,
11998 binding_type = "Global" ,
120- request_parameters = request_parameters ,
121- response_properties = response_properties ,
99+ request_parameters = REQUEST_PARAMETERS ,
100+ response_properties = RESPONSE_PROPERTIES ,
122101 ))
123- print ({"created" : True , "customapiid" : api_meta .get ("customapiid" )})
102+ print ({
103+ "created" : True ,
104+ "message" : "Created Custom API with the following parameters" ,
105+ "unique_name" : CUSTOM_API_UNIQUE_NAME ,
106+ "customapiid" : api_meta .get ("customapiid" ),
107+ "description" : "Echo sample (metadata only) created by SDK quickstart." ,
108+ "is_function" : False ,
109+ "request_parameters" : [p .get ("name" ) for p in REQUEST_PARAMETERS ],
110+ "response_properties" : [p .get ("name" ) for p in RESPONSE_PROPERTIES ]
111+ })
124112except Exception as e :
125113 print ("Create Custom API failed:" )
126114 traceback .print_exc ()
@@ -131,19 +119,35 @@ def backoff_retry(op, *, delays=(0, 2, 5), retry_http_statuses=(429, 500, 502, 5
131119 except Exception :
132120 pass
133121 sys .exit (1 )
134- created_this_run = True
135122
136123customapiid = api_meta .get ("customapiid" ) if api_meta else None
137124if not customapiid :
138125 print ("Missing customapiid; cannot continue" )
139126 sys .exit (1 )
140127
128+ # 3) Read back the Custom API metadata just created
129+ print ("Read Custom API metadata:" )
130+ try :
131+ plan ("odata.get_custom_api(unique_name)" )
132+ read_back = backoff_retry (lambda : odata .get_custom_api (unique_name = CUSTOM_API_UNIQUE_NAME ))
133+ if read_back :
134+ # Display a concise subset of fields
135+ subset = {k : read_back .get (k ) for k in [
136+ "customapiid" , "uniquename" , "isfunction" , "bindingtype" , "allowedcustomprocessingsteptype" , "isprivate" , "executeprivilegename" , "description"
137+ ]}
138+ subset ["request_param_count" ] = len (REQUEST_PARAMETERS )
139+ subset ["response_prop_count" ] = len (RESPONSE_PROPERTIES )
140+ print ({"read_back" : subset })
141+ else :
142+ print ({"read_back" : None })
143+ except Exception as e :
144+ print ({"read_custom_api_error" : str (e )})
145+
141146# Publish customizations so the action metadata is available for invocation (required for freshly created APIs)
142147print ("Ensure custom API metadata is available:" )
143148
144149def _action_in_metadata (action_name : str ) -> bool :
145150 try :
146- # Must include auth headers; previously we overwrote them causing 401
147151 md_resp = odata ._request (
148152 "get" ,
149153 f"{ odata .api } /$metadata" ,
@@ -152,7 +156,7 @@ def _action_in_metadata(action_name: str) -> bool:
152156 if md_resp .status_code == 200 :
153157 txt = md_resp .text
154158 return f"Name=\" { action_name } \" " in txt
155- except Exception : # noqa: BLE001
159+ except Exception :
156160 return False
157161 return False
158162
@@ -197,108 +201,60 @@ def wait_for_action(action_name: str, timeout_sec: int = 60, interval: float = 2
197201 print ({"metadata_present_after_publish" : False , "hint" : "Invocation retry logic will attempt anyway." })
198202 except requests .exceptions .Timeout :
199203 print ({"published" : False , "error" : "PublishAllXml timeout (15s)" , "hint" : "Proceeding; action may still become available." })
200- except Exception as pub_ex : # noqa: BLE001
204+ except Exception as pub_ex :
201205 print ({"published" : False , "error" : str (pub_ex )})
202206else :
203207 print ({"publish_strategy" : PUBLISH_STRATEGY , "warning" : "Unknown strategy value" })
204208
205- # Optional: bind an existing plug-in type so invocation returns something meaningful.
206- # Provide the fully-qualified class name in PLUGIN_TYPENAME above. The plug-in must
207- # set OutputParameters["ResponseMessage"] (or whatever your response property name is).
208- if PLUGIN_TYPENAME :
209- print ("Attempt plug-in bind:" )
210- try :
211- plan (f"lookup plugintype '{ PLUGIN_TYPENAME } ' then patch custom api" )
212- # Lookup plugintypeid by typename
213- url = f"{ odata .api } /plugintypes"
214- params = {"$select" : "plugintypeid,typename" , "$filter" : f"typename eq '{ PLUGIN_TYPENAME } '" }
215- r = odata ._request ("get" , url , headers = odata ._headers (), params = params )
216- r .raise_for_status ()
217- vals = r .json ().get ("value" , [])
218- if vals :
219- plugintypeid = vals [0 ]["plugintypeid" ]
220- log_call ("odata.update_custom_api (attach plugintype)" )
221- patched = odata .update_custom_api (unique_name = CUSTOM_API_UNIQUE_NAME , changes = {
222- "plugintypeid@odata.bind" : f"/plugintypes({ plugintypeid } )"
223- })
224- print ({"plugin_attached" : True , "plugintypeid" : plugintypeid })
225- else :
226- print ({"plugin_attached" : False , "reason" : "Plugin typename not found" })
227- except Exception as ex : # noqa: BLE001
228- resp = getattr (ex , 'response' , None )
229- body = None
230- if resp is not None :
231- try :
232- body = resp .text [:800 ]
233- except Exception : # noqa: BLE001
234- body = None
235- print ({"plugin_attach_error" : str (ex ), "body" : body })
236-
237- # 3) (Re)List parameters / response properties for visibility
238- print ("Parameters / Response Properties:" )
209+ # 4) (Re)List parameters / response properties for visibility
210+ print ("List Parameters / Response Properties:" )
239211try :
240212 params = odata .list_custom_api_request_parameters (customapiid )
241213 props = odata .list_custom_api_response_properties (customapiid )
242214 print ({"parameters" : [p .get ("name" ) for p in params ], "responses" : [p .get ("name" ) for p in props ]})
243- except Exception as e : # noqa: BLE001
215+ except Exception as e :
244216 print (f"List params/props failed: { e } " )
245217
246- # 4 ) Invoke the Custom API (will only succeed if backed by server logic)
218+ # 5 ) Invoke the Custom API
247219print ("Invoke Custom API:" )
248- time .sleep (1 )
249220try :
250- # Fetch $metadata to inspect the expected parameter names (diagnostic aid)
251- try :
252- meta_url = f"{ odata .api } /$metadata"
253- md_resp = odata ._request (
254- "get" ,
255- meta_url ,
256- headers = {** odata ._headers (), "Accept" : "application/xml" },
257- )
258- md_resp .raise_for_status ()
259- md_text = md_resp .text
260- # Extract the Action definition snippet for debugging
261- snippet = None
262- idx = md_text .find (f"Name=\" { CUSTOM_API_UNIQUE_NAME } \" " )
263- if idx != - 1 :
264- snippet = md_text [max (0 , idx - 200 ): idx + 400 ]
265- if snippet :
266- print ({"metadata_snippet" : snippet .replace ('\n ' , ' ' )[:400 ]})
267- except Exception as md_ex : # noqa: BLE001
268- print ({"metadata_fetch_error" : str (md_ex )})
269-
270- base_message = 123 # Int matches parameter type 6
271- # Prefer the unique name first (proved to work in plug-in-less mode); keep logical name fallback for completeness
272- candidate_param_names = [REQUEST_PARAM_UNIQUE , "Message" ]
221+ base_message = 42 # Matches Int32 parameter type
222+ candidate_param_names = [REQUEST_PARAM_UNIQUE ]
273223 last_error = None
274224 for pname in candidate_param_names :
275- for attempt in range (1 ,4 ): # up to 3 attempts each name for propagation
225+ for attempt in range (1 ,4 ): # up to 3 attempts each name for propagation / publish delay
276226 invoke_payload = {pname : base_message }
277227 plan (f"attempt { attempt } param '{ pname } ' -> odata.call_custom_api('{ CUSTOM_API_UNIQUE_NAME } ', { invoke_payload } )" )
278228 def invoke ():
279229 return odata .call_custom_api (CUSTOM_API_UNIQUE_NAME , invoke_payload )
280230 try :
281231 result = invoke ()
282- print ({"invoked" : True , "result " : result , "mode" : "plugin" if INCLUDE_RESPONSE_PROPERTY else "plugin-less" , "used_param" : pname , "attempt" : attempt })
232+ print ({"invoked" : True , "message " : "note the None in new_EchoMessage_Response is expected as there is no server logic attached to the workflow" , "result" : result , "used_param" : pname , "attempt" : attempt })
283233 raise SystemExit # exit double loop cleanly
284- except requests .exceptions .HTTPError as ex : # noqa: PERF203
234+ except requests .exceptions .HTTPError as ex :
285235 last_error = ex
286236 resp = getattr (ex , 'response' , None )
237+ status = getattr (resp , 'status_code' , None )
287238 body = None
288239 if resp is not None :
289240 try :
290- body = resp .text [:300 ]
291- except Exception : # noqa: BLE001
241+ body = resp .text [:600 ]
242+ except Exception :
292243 body = None
293- if resp is not None and resp .status_code == 400 and "not a valid parameter" in (body or "" ):
294- # Wait and retry
244+ body_lc = (body or "" ).lower ()
245+ # Handle not yet routable (sdkmessage) 404 specially
246+ if status == 404 and 'sdkmessage' in body_lc :
247+ print ({"retry" : True , "reason" : "404 sdkmessage not found (known issue where the custom api exists but metadata is not updated yet)" , "attempt" : attempt })
248+ time .sleep (2 + attempt )
249+ continue
250+ if status == 400 and "not a valid parameter" in body_lc :
295251 time .sleep (2 + attempt )
296252 continue
297- if resp is not None and resp . status_code == 400 and "Int32 " in ( body or "" ) :
298- print ({"hint" : "Server expects Int32; ensure payload is int (it is). If still failing, metadata not published yet ." })
253+ if status == 400 and "int32 " in body_lc :
254+ print ({"hint" : "Server expects Int32; payload is int. Likely metadata publish delay ." })
299255 time .sleep (2 )
300256 continue
301- print ({"attempt" : pname , "error" : str (ex ), "body" : body })
257+ print ({"attempt" : pname , "error" : str (ex ), "status" : status , " body" : body })
302258 time .sleep (2 )
303259 continue
304260 if last_error :
@@ -315,23 +271,20 @@ def invoke():
315271 body = None
316272 print ({"invoked" : False , "error" : str (e ), "body" : body , "hint" : "If 400, verify parameter Type code & payload match; for plug-in-less mode only request param should be present." })
317273
318- # 5 ) Update description (demonstrate patch)
319- print ("Update Custom API description :" )
274+ # 6 ) Update custom api
275+ print ("Update Custom API:" )
320276try :
321277 plan ("odata.update_custom_api(unique_name, changes={'description': 'Updated via quickstart'})" )
322278 updated = backoff_retry (lambda : odata .update_custom_api (unique_name = CUSTOM_API_UNIQUE_NAME , changes = {"description" : "Updated via quickstart" }))
323279 print ({"updated" : True , "description" : updated .get ("description" )})
324- except Exception as e : # noqa: BLE001
280+ except Exception as e :
325281 print ({"updated" : False , "error" : str (e )})
326282
327- # 6) Conditional cleanup
328- if CLEANUP and created_this_run :
329- print ("Cleanup: delete Custom API created in this run" )
330- try :
331- plan ("odata.delete_custom_api(unique_name)" )
332- backoff_retry (lambda : odata .delete_custom_api (unique_name = CUSTOM_API_UNIQUE_NAME ))
333- print ({"deleted" : True })
334- except Exception as e : # noqa: BLE001
335- print ({"deleted" : False , "error" : str (e )})
336- else :
337- print ({"cleanup" : False , "reason" : "CLEANUP flag False or pre-existing API" })
283+ # 7) Cleanup
284+ print ("Cleanup: delete Custom API created in this run" )
285+ try :
286+ plan ("odata.delete_custom_api(unique_name)" )
287+ backoff_retry (lambda : odata .delete_custom_api (unique_name = CUSTOM_API_UNIQUE_NAME ))
288+ print ({"deleted" : True })
289+ except Exception as e :
290+ print ({"deleted" : False , "error" : str (e )})
0 commit comments