Skip to content

Commit 8002011

Browse files
author
Max Wang
committed
api seems to be working. need to check tests a bit
1 parent 6d80dd4 commit 8002011

2 files changed

Lines changed: 723 additions & 1 deletion

File tree

examples/quickstart_custom_api.py

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import sys
2+
from pathlib import Path
3+
import traceback
4+
import time
5+
import requests
6+
7+
# Add src to PYTHONPATH for local runs; insert at position 0 so local code overrides any installed package
8+
src_path = str(Path(__file__).resolve().parents[1] / "src")
9+
if src_path not in sys.path:
10+
sys.path.insert(0, src_path)
11+
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+
"""
27+
28+
# ---------------- Configuration ----------------
29+
base_url = "https://aurorabapenv0f528.crm10.dynamics.com" # <-- change to your environment
30+
CUSTOM_API_UNIQUE_NAME = "new_EchoMessage" # Must be globally unique in the org
31+
REQUEST_PARAM_UNIQUE = "new_EchoMessage_Message"
32+
RESPONSE_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
36+
PUBLISH_STRATEGY = "auto" # auto | skip | force. force = call PublishAllXml, auto = poll metadata first
37+
38+
# ------------------------------------------------
39+
client = DataverseClient(base_url=base_url, credential=InteractiveBrowserCredential())
40+
odata = client._get_odata() # low-level client exposing custom API helpers
41+
42+
# Small helpers: call logging and step pauses
43+
44+
def log_call(call: str) -> None:
45+
print({"call": call})
46+
47+
def plan(call: str) -> None:
48+
print({"plan": call})
49+
50+
# Simple generic backoff (same style as other quickstarts)
51+
52+
def backoff_retry(op, *, delays=(0, 2, 5), retry_http_statuses=(429, 500, 502, 503, 504)):
53+
last_exc = None
54+
for d in delays:
55+
if d:
56+
time.sleep(d)
57+
try:
58+
return op()
59+
except Exception as ex: # noqa: BLE001
60+
last_exc = ex
61+
if isinstance(ex, requests.exceptions.HTTPError):
62+
code = getattr(getattr(ex, "response", None), "status_code", None)
63+
if code in retry_http_statuses:
64+
continue
65+
break
66+
if last_exc:
67+
raise last_exc
68+
69+
# 1) List existing custom APIs with our prefix for context
70+
print("List existing custom APIs (prefix=new_):")
71+
try:
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}")
79+
80+
# 2) Create the Custom API if absent
81+
print("Recreate Custom API fresh (delete if exists then create):")
82+
existing_api = odata.get_custom_api(unique_name=CUSTOM_API_UNIQUE_NAME)
83+
if existing_api:
84+
plan("odata.delete_custom_api(existing)")
85+
try:
86+
backoff_retry(lambda: odata.delete_custom_api(unique_name=CUSTOM_API_UNIQUE_NAME))
87+
print({"deleted_prior": True})
88+
# Brief pause to allow backend cleanup
89+
time.sleep(2)
90+
except Exception as del_ex: # noqa: BLE001
91+
print({"delete_prior_error": str(del_ex)})
92+
93+
plan("odata.create_custom_api (inline request parameter + optional response property)")
94+
try:
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+
114+
api_meta = backoff_retry(lambda: odata.create_custom_api(
115+
unique_name=CUSTOM_API_UNIQUE_NAME,
116+
name="Echo Message",
117+
description="Echo sample (metadata only) created by SDK quickstart.",
118+
is_function=False,
119+
binding_type="Global",
120+
request_parameters=request_parameters,
121+
response_properties=response_properties,
122+
))
123+
print({"created": True, "customapiid": api_meta.get("customapiid")})
124+
except Exception as e:
125+
print("Create Custom API failed:")
126+
traceback.print_exc()
127+
resp = getattr(e, 'response', None)
128+
if resp is not None:
129+
try:
130+
print({"status": resp.status_code, "body": resp.text[:2000]})
131+
except Exception:
132+
pass
133+
sys.exit(1)
134+
created_this_run = True
135+
136+
customapiid = api_meta.get("customapiid") if api_meta else None
137+
if not customapiid:
138+
print("Missing customapiid; cannot continue")
139+
sys.exit(1)
140+
141+
# Publish customizations so the action metadata is available for invocation (required for freshly created APIs)
142+
print("Ensure custom API metadata is available:")
143+
144+
def _action_in_metadata(action_name: str) -> bool:
145+
try:
146+
# Must include auth headers; previously we overwrote them causing 401
147+
md_resp = odata._request(
148+
"get",
149+
f"{odata.api}/$metadata",
150+
headers={**odata._headers(), "Accept": "application/xml"},
151+
)
152+
if md_resp.status_code == 200:
153+
txt = md_resp.text
154+
return f"Name=\"{action_name}\"" in txt
155+
except Exception: # noqa: BLE001
156+
return False
157+
return False
158+
159+
def wait_for_action(action_name: str, timeout_sec: int = 60, interval: float = 2.0) -> bool:
160+
start = time.time()
161+
while time.time() - start < timeout_sec:
162+
if _action_in_metadata(action_name):
163+
return True
164+
time.sleep(interval)
165+
return _action_in_metadata(action_name)
166+
167+
published = False
168+
if PUBLISH_STRATEGY == "skip":
169+
print({"publish_strategy": "skip"})
170+
elif PUBLISH_STRATEGY in ("auto", "force"):
171+
if PUBLISH_STRATEGY == "auto":
172+
# First attempt: see if already present (often immediate)
173+
if _action_in_metadata(CUSTOM_API_UNIQUE_NAME):
174+
print({"publish_strategy": "auto", "metadata_present": True})
175+
published = True
176+
else:
177+
print({"publish_strategy": "auto", "metadata_present": False, "action": "polling"})
178+
if wait_for_action(CUSTOM_API_UNIQUE_NAME, timeout_sec=20, interval=2):
179+
print({"metadata_present_after_poll": True})
180+
published = True
181+
# Fallback (auto when still not present, or explicit force): attempt PublishAllXml with timeout
182+
if not published:
183+
try:
184+
plan("POST PublishAllXml (timeout=15s)")
185+
pub_url = f"{odata.api}/PublishAllXml"
186+
# Direct requests call so we can enforce timeout
187+
r_pub = requests.post(pub_url, headers=odata._headers(), json={}, timeout=15)
188+
if r_pub.status_code not in (200, 204):
189+
r_pub.raise_for_status()
190+
print({"published": True, "status": r_pub.status_code})
191+
# Short propagation wait + poll again
192+
time.sleep(3)
193+
if wait_for_action(CUSTOM_API_UNIQUE_NAME, timeout_sec=25, interval=2):
194+
print({"metadata_present_after_publish": True})
195+
published = True
196+
else:
197+
print({"metadata_present_after_publish": False, "hint": "Invocation retry logic will attempt anyway."})
198+
except requests.exceptions.Timeout:
199+
print({"published": False, "error": "PublishAllXml timeout (15s)", "hint": "Proceeding; action may still become available."})
200+
except Exception as pub_ex: # noqa: BLE001
201+
print({"published": False, "error": str(pub_ex)})
202+
else:
203+
print({"publish_strategy": PUBLISH_STRATEGY, "warning": "Unknown strategy value"})
204+
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:")
239+
try:
240+
params = odata.list_custom_api_request_parameters(customapiid)
241+
props = odata.list_custom_api_response_properties(customapiid)
242+
print({"parameters": [p.get("name") for p in params], "responses": [p.get("name") for p in props]})
243+
except Exception as e: # noqa: BLE001
244+
print(f"List params/props failed: {e}")
245+
246+
# 4) Invoke the Custom API (will only succeed if backed by server logic)
247+
print("Invoke Custom API:")
248+
time.sleep(1)
249+
try:
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"]
273+
last_error = None
274+
for pname in candidate_param_names:
275+
for attempt in range(1,4): # up to 3 attempts each name for propagation
276+
invoke_payload = {pname: base_message}
277+
plan(f"attempt {attempt} param '{pname}' -> odata.call_custom_api('{CUSTOM_API_UNIQUE_NAME}', {invoke_payload})")
278+
def invoke():
279+
return odata.call_custom_api(CUSTOM_API_UNIQUE_NAME, invoke_payload)
280+
try:
281+
result = invoke()
282+
print({"invoked": True, "result": result, "mode": "plugin" if INCLUDE_RESPONSE_PROPERTY else "plugin-less", "used_param": pname, "attempt": attempt})
283+
raise SystemExit # exit double loop cleanly
284+
except requests.exceptions.HTTPError as ex: # noqa: PERF203
285+
last_error = ex
286+
resp = getattr(ex, 'response', None)
287+
body = None
288+
if resp is not None:
289+
try:
290+
body = resp.text[:300]
291+
except Exception: # noqa: BLE001
292+
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
295+
time.sleep(2 + attempt)
296+
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."})
299+
time.sleep(2)
300+
continue
301+
print({"attempt": pname, "error": str(ex), "body": body})
302+
time.sleep(2)
303+
continue
304+
if last_error:
305+
raise last_error
306+
except SystemExit:
307+
pass # Successful invocation path signaled via SystemExit raise above
308+
except Exception as e: # Invocation may legitimately fail without a plug-in
309+
resp = getattr(e, 'response', None)
310+
body = None
311+
if resp is not None:
312+
try:
313+
body = resp.text[:1500]
314+
except Exception:
315+
body = None
316+
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."})
317+
318+
# 5) Update description (demonstrate patch)
319+
print("Update Custom API description:")
320+
try:
321+
plan("odata.update_custom_api(unique_name, changes={'description': 'Updated via quickstart'})")
322+
updated = backoff_retry(lambda: odata.update_custom_api(unique_name=CUSTOM_API_UNIQUE_NAME, changes={"description": "Updated via quickstart"}))
323+
print({"updated": True, "description": updated.get("description")})
324+
except Exception as e: # noqa: BLE001
325+
print({"updated": False, "error": str(e)})
326+
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"})

0 commit comments

Comments
 (0)