Skip to content

Commit a992229

Browse files
committed
fix: add proper NocoDB API v3 response/request format handling (#13)
The v3 API uses fundamentally different response and request formats: - Records list uses "records" key instead of "list" - Record IDs use lowercase "id" instead of "Id" - Field data is wrapped in a "fields" object - Pagination uses cursor-based next/prev instead of pageInfo - Create/update requests must wrap data in "fields" object Added ResponseAdapter and RequestAdapter classes that transparently handle v2/v3 format differences, keeping the public API consistent. All existing v2 behavior remains unchanged (passthrough).
1 parent db643d3 commit a992229

5 files changed

Lines changed: 541 additions & 68 deletions

File tree

src/nocodb_simple_client/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
# Async support (optional)
2727
from typing import TYPE_CHECKING
2828

29-
from .api_version import APIVersion, PathBuilder, QueryParamAdapter
29+
from .api_version import APIVersion, PathBuilder, QueryParamAdapter, RequestAdapter, ResponseAdapter
3030
from .base_resolver import BaseIdResolver
3131
from .cache import CacheManager
3232
from .client import NocoDBClient
@@ -94,6 +94,8 @@ def __init__(self, *args, **kwargs): # type: ignore[misc]
9494
"APIVersion",
9595
"PathBuilder",
9696
"QueryParamAdapter",
97+
"ResponseAdapter",
98+
"RequestAdapter",
9799
"BaseIdResolver",
98100
# Exceptions
99101
"NocoDBException",

src/nocodb_simple_client/api_version.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,197 @@ def __str__(self) -> str:
3737
return self.value
3838

3939

40+
class ResponseAdapter:
41+
"""Adapter for normalizing API responses between v2 and v3 formats.
42+
43+
v2 returns flat records with 'Id' key and 'list' wrapper.
44+
v3 returns records with 'id' key, 'fields' wrapper, and 'records' wrapper.
45+
This adapter normalizes v3 responses to v2-compatible flat format.
46+
"""
47+
48+
@staticmethod
49+
def normalize_record(record: dict[str, Any], api_version: "APIVersion") -> dict[str, Any]:
50+
"""Normalize a single record response to flat format.
51+
52+
v2 format: {"Id": 1, "Name": "John", ...}
53+
v3 format: {"id": 1, "fields": {"Name": "John", ...}}
54+
55+
Returns flat format with "Id" key for consistency.
56+
"""
57+
if api_version == APIVersion.V2:
58+
return record
59+
60+
# v3: extract fields and normalize id
61+
result: dict[str, Any] = {}
62+
if "id" in record:
63+
result["Id"] = record["id"]
64+
if "fields" in record:
65+
result.update(record["fields"])
66+
else:
67+
# Fallback: if no fields wrapper, copy all except 'id'
68+
for k, v in record.items():
69+
if k == "id":
70+
result["Id"] = v
71+
elif k != "deleted":
72+
result[k] = v
73+
return result
74+
75+
@staticmethod
76+
def normalize_records_list(
77+
response: dict[str, Any], api_version: "APIVersion"
78+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
79+
"""Normalize a records list response.
80+
81+
v2: {"list": [...], "pageInfo": {"isLastPage": true, ...}}
82+
v3: {"records": [...], "next": "...", "prev": null}
83+
84+
Returns (records_list, page_info) where page_info uses v2-compatible format.
85+
"""
86+
if api_version == APIVersion.V2:
87+
records = response.get("list", [])
88+
page_info = response.get("pageInfo", {})
89+
return records, page_info
90+
91+
# v3: extract from "records" key
92+
raw_records = response.get("records", [])
93+
records = [ResponseAdapter.normalize_record(r, api_version) for r in raw_records]
94+
95+
# Convert v3 pagination to v2-compatible pageInfo
96+
has_next = response.get("next") is not None
97+
page_info = {
98+
"isLastPage": not has_next,
99+
"next": response.get("next"),
100+
"prev": response.get("prev"),
101+
}
102+
103+
return records, page_info
104+
105+
@staticmethod
106+
def extract_record_id(response: dict[str, Any], api_version: "APIVersion") -> Any:
107+
"""Extract record ID from a create/update/delete response.
108+
109+
v2: {"Id": 123}
110+
v3: {"id": 123, "fields": {...}} or {"records": [{"id": 123, ...}]}
111+
"""
112+
if api_version == APIVersion.V2:
113+
return response.get("Id")
114+
115+
# v3: try direct id first
116+
if "id" in response:
117+
return response["id"]
118+
119+
# v3: try records wrapper (bulk responses)
120+
records = response.get("records", [])
121+
if records and isinstance(records, list) and len(records) > 0:
122+
return records[0].get("id")
123+
124+
# Fallback: try "Id" (in case of mixed format)
125+
return response.get("Id")
126+
127+
@staticmethod
128+
def extract_record_ids(
129+
response: dict[str, Any] | list[dict[str, Any]], api_version: "APIVersion"
130+
) -> list[Any]:
131+
"""Extract multiple record IDs from bulk operation responses.
132+
133+
v2: [{"Id": 1}, {"Id": 2}]
134+
v3: {"records": [{"id": 1, ...}, {"id": 2, ...}]}
135+
"""
136+
if api_version == APIVersion.V2:
137+
if isinstance(response, list):
138+
return [
139+
r.get("Id") for r in response if isinstance(r, dict) and r.get("Id") is not None
140+
]
141+
elif isinstance(response, dict) and "Id" in response:
142+
return [response["Id"]]
143+
return []
144+
145+
# v3: records wrapper
146+
if isinstance(response, dict):
147+
records = response.get("records", [])
148+
if records:
149+
return [
150+
r.get("id") for r in records if isinstance(r, dict) and r.get("id") is not None
151+
]
152+
# Fallback: direct id
153+
if "id" in response:
154+
return [response["id"]]
155+
elif isinstance(response, list):
156+
return [
157+
r.get("id") for r in response if isinstance(r, dict) and r.get("id") is not None
158+
]
159+
160+
return []
161+
162+
163+
class RequestAdapter:
164+
"""Adapter for formatting API requests between v2 and v3 formats.
165+
166+
v2 sends flat record data: {"Name": "John", "Email": "john@example.com"}
167+
v3 wraps field data: {"fields": {"Name": "John", "Email": "john@example.com"}}
168+
"""
169+
170+
@staticmethod
171+
def format_record(record: dict[str, Any], api_version: "APIVersion") -> dict[str, Any]:
172+
"""Format a record for create/update requests.
173+
174+
v2: {"Name": "John", "Email": "john@example.com"}
175+
v3: {"fields": {"Name": "John", "Email": "john@example.com"}}
176+
"""
177+
if api_version == APIVersion.V2:
178+
return record
179+
180+
# v3: wrap in fields, keeping Id/id separate
181+
fields = {}
182+
result: dict[str, Any] = {}
183+
for k, v in record.items():
184+
if k in ("Id", "id"):
185+
result["id"] = v
186+
else:
187+
fields[k] = v
188+
result["fields"] = fields
189+
return result
190+
191+
@staticmethod
192+
def format_records(
193+
records: list[dict[str, Any]], api_version: "APIVersion"
194+
) -> list[dict[str, Any]] | dict[str, Any]:
195+
"""Format multiple records for bulk create/update requests.
196+
197+
v2: [{"Name": "John"}, {"Name": "Jane"}]
198+
v3: {"records": [{"fields": {"Name": "John"}}, {"fields": {"Name": "Jane"}}]}
199+
"""
200+
if api_version == APIVersion.V2:
201+
return records
202+
203+
# v3: wrap in records array with fields
204+
return {"records": [RequestAdapter.format_record(r, api_version) for r in records]}
205+
206+
@staticmethod
207+
def format_delete(record_id: int | str, api_version: "APIVersion") -> dict[str, Any]:
208+
"""Format a delete request body.
209+
210+
v2: {"Id": 123}
211+
v3: {"id": 123}
212+
"""
213+
if api_version == APIVersion.V2:
214+
return {"Id": record_id}
215+
return {"id": record_id}
216+
217+
@staticmethod
218+
def format_bulk_delete(
219+
record_ids: list[int | str], api_version: "APIVersion"
220+
) -> list[dict[str, Any]] | dict[str, Any]:
221+
"""Format bulk delete request body.
222+
223+
v2: [{"Id": 1}, {"Id": 2}]
224+
v3: {"records": [{"id": 1}, {"id": 2}]}
225+
"""
226+
if api_version == APIVersion.V2:
227+
return [{"Id": rid} for rid in record_ids]
228+
return {"records": [{"id": rid} for rid in record_ids]}
229+
230+
40231
class QueryParamAdapter:
41232
"""Adapter for converting query parameters between API versions."""
42233

0 commit comments

Comments
 (0)