Skip to content

Commit 2ec1de2

Browse files
author
Max Wang
committed
working version
1 parent 8002011 commit 2ec1de2

3 files changed

Lines changed: 126 additions & 241 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ A minimal Python SDK to use Microsoft Dataverse as a database for Azure AI Found
66
- OData CRUD — Thin wrappers over Dataverse Web API (create/get/update/delete).
77
- Metadata helpers — Create/inspect/delete simple custom tables (EntityDefinitions + Attributes).
88
- Pandas helpers — Convenience DataFrame oriented wrappers for quick prototyping/notebooks.
9+
- Custom API — CRUD for Custom API that wraps over Dataverse Web API
910
- Auth — Azure Identity (`TokenCredential`) injection.
1011

1112
## Features
1213

1314
- Simple `DataverseClient` facade for CRUD, SQL (read-only), and table metadata.
1415
- SQL-over-API: T-SQL routed through Custom API endpoint (no ODBC / TDS driver required).
1516
- Table metadata ops: create simple custom tables with primitive columns (string/int/decimal/float/datetime/bool) and delete them.
17+
- Custom API support for CRUD and invoking
1618
- Optional pandas integration (`PandasODataClient`) for DataFrame based create / get / query.
1719

1820
Auth:
@@ -139,7 +141,9 @@ Notes:
139141
- For CRUD methods that take a record id, pass the GUID string (36-char hyphenated). Parentheses around the GUID are accepted but not required.
140142
- SQL is routed through the Custom API named in `DataverseConfig.sql_api_name` (default: `McpExecuteSqlQuery`).
141143

144+
### Custom API functionalities
142145

146+
See `examples/quickstart_custom_api.py` for a Custom API workflow from create -> read- > call -> update -> delete.
143147

144148
### Pandas helpers
145149

@@ -152,6 +156,7 @@ VS Code Tasks
152156
## Limitations / Future Work
153157
- No batching, upsert, or association operations yet.
154158
- Minimal retry policy in library (network-error only); examples include additional backoff for transient Dataverse consistency.
159+
- Custom API SDK doesn't support adding service logic like Plug-in currently, so it can only function like business events.
155160

156161
## Contributing
157162

examples/quickstart_custom_api.py

Lines changed: 94 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,31 @@
99
if 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 ----------------
2916
base_url = "https://aurorabapenv0f528.crm10.dynamics.com" # <-- change to your environment
3017
CUSTOM_API_UNIQUE_NAME = "new_EchoMessage" # Must be globally unique in the org
3118
REQUEST_PARAM_UNIQUE = "new_EchoMessage_Message"
3219
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
3620
PUBLISH_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
# ------------------------------------------------
3939
client = 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:")
7171
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}")
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
8179
print("Recreate Custom API fresh (delete if exists then create):")
8280
existing_api = odata.get_custom_api(unique_name=CUSTOM_API_UNIQUE_NAME)
8381
if 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)")
9492
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-
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+
})
124112
except 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

136123
customapiid = api_meta.get("customapiid") if api_meta else None
137124
if 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)
142147
print("Ensure custom API metadata is available:")
143148

144149
def _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)})
202206
else:
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:")
239211
try:
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
247219
print("Invoke Custom API:")
248-
time.sleep(1)
249220
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"]
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:")
320276
try:
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

Comments
 (0)