Skip to content
Closed
88 changes: 77 additions & 11 deletions templates/python/base/params.twig
Original file line number Diff line number Diff line change
@@ -1,31 +1,97 @@
api_params = {}
{% if method.parameters.all | length %}
{% for parameter in method.parameters.all %}
{% if parameter.required and not parameter.nullable %}
if {{ parameter.name | escapeKeyword | caseSnake }} is None:
raise {{spec.title | caseUcfirst}}Exception('Missing required parameter: "{{ parameter.name | escapeKeyword | caseSnake }}"')
nullable_params = []

{% if method.parameters.all | length %}
{% for parameter in method.parameters.all %}
{% if parameter.nullable %}
nullable_params.append('{{ parameter.name }}')
{% endif %}
{% endfor %}

{% for parameter in method.parameters.path %}
api_path = api_path.replace('{{ '{' }}{{ parameter.name | caseCamel }}{{ '}' }}', {{ parameter.name | escapeKeyword | caseSnake }})
{% if parameter.required %}
# Required path parameters - convert None to explicit null
if {{ parameter.name | escapeKeyword | caseSnake }} is None:
path_value = 'null'
else:
path_value = str({{ parameter.name | escapeKeyword | caseSnake }})
api_path = api_path.replace('{{ '{' }}{{ parameter.name | caseCamel }}{{ '}' }}', path_value)
Comment on lines +13 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Confirm server semantics for required path None → 'null'.
Replacing a required path segment with literal "null" may not map to the intended resource. Some servers 404 or treat it as a string. Verify this matches the API contract.


🌐 Web query:

For Appwrite REST endpoints, should absent/None path parameters be represented as the literal "null" segment, or should requests fail early?

💡 Result:

Short answer: do not represent absent/None path parameters as a literal "null" path segment — requests should fail early (400-level) and the client should omit or validate the parameter before building the URL.

Why (brief):

  • Appwrite endpoints expect real path segments; passing "null" as a segment produces routing/type errors in practice (users report TypeError/invalid-param failures). [1]
  • URI/URL builders and server frameworks treat path segments as non-null strings (many implementations throw on null segments), so using a literal "null" is ambiguous and breaks routing/validation. [2]

What to do instead:

  • Validate on the client: if a path parameter is optional, either omit that path from the URL or use a different API design (query param, distinct endpoint, or explicit 0/empty-id semantics).
  • On the server, return a client error (400/422) when a required path parameter is missing or when the client sends an invalid literal like "null".

Sources:

  • Appwrite community thread showing errors when null is used for parameters. [1]
  • JAX‑RS / UriBuilder documentation showing path segments must be non-null (frameworks reject null segments). [2]

Fix path parameter validation—do not convert None to literal "null" segment.

Appwrite REST endpoints reject requests with a literal "null" path segment; this causes routing/type errors. Required path parameters should fail early with validation (400-level error) on the client side before building the URL, not be replaced with a string.

Update lines 13–18 to either validate that the parameter is not None and raise an exception, or design the endpoint to accept optional parameters via a different mechanism (query parameter or distinct endpoint).

Appwrite endpoints expect real path segments; passing "null" as a segment produces routing/type errors in practice, and URI/URL builders and server frameworks treat path segments as non-null strings.

🤖 Prompt for AI Agents
templates/python/base/params.twig around lines 13 to 18: currently None path
params are converted to the literal string "null", which breaks Appwrite
routing; instead validate required path parameters and raise a client-side error
if None. Change the logic to check if {{ parameter.name | escapeKeyword |
caseSnake }} is None and raise an exception (e.g., ValueError with a clear
message indicating the missing required path parameter) so the caller gets a 4xx
failure; otherwise convert to str and proceed to replace the path segment as
before.

{% else %}
# Optional path parameters - only include if not None
if {{ parameter.name | escapeKeyword | caseSnake }} is not None:
api_path = api_path.replace('{{ '{' }}{{ parameter.name | caseCamel }}{{ '}' }}', str({{ parameter.name | escapeKeyword | caseSnake }}))
{% endif %}
{% endfor %}

{% for parameter in method.parameters.query %}
api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }}
{% if parameter.required %}
# Required query parameters - convert None to explicit null
if {{ parameter.name | escapeKeyword | caseSnake }} is None:
api_params['{{ parameter.name }}'] = self.client.null()
else:
api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }}
{% else %}
# Optional query parameters - only include if not None
if {{ parameter.name | escapeKeyword | caseSnake }} is not None:
api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }}
{% endif %}
{% endfor %}

{% for parameter in method.parameters.body %}
{% if parameter.required %}
# Required body parameters - convert None to explicit null
if {{ parameter.name | escapeKeyword | caseSnake }} is None:
api_params['{{ parameter.name }}'] = self.client.null()
else:
{% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" ) %}
api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if type({{ parameter.name | escapeKeyword | caseSnake }}) is bool else {{ parameter.name | escapeKeyword | caseSnake }}
api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if isinstance({{ parameter.name | escapeKeyword | caseSnake }}, bool) else {{ parameter.name | escapeKeyword | caseSnake }}
{% else %}
api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }}
api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }}
{% endif %}
{% else %}
# Optional body parameters - only include if not None
if {{ parameter.name | escapeKeyword | caseSnake }} is not None:
{% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" ) %}
api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if isinstance({{ parameter.name | escapeKeyword | caseSnake }}, bool) else {{ parameter.name | escapeKeyword | caseSnake }}
{% else %}
api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }}
{% endif %}
{% endif %}
{% endfor %}

{% for parameter in method.parameters.formData %}
{% if parameter.required %}
# Required form data parameters - convert None to explicit null
if {{ parameter.name | escapeKeyword | caseSnake }} is None:
api_params['{{ parameter.name }}'] = self.client.null()
else:
{% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" ) %}
api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if isinstance({{ parameter.name | escapeKeyword | caseSnake }}, bool) else {{ parameter.name | escapeKeyword | caseSnake }}
{% else %}
api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }}
{% endif %}
{% else %}
# Optional form data parameters - only include if not None
if {{ parameter.name | escapeKeyword | caseSnake }} is not None:
{% if method.consumes[0] == "multipart/form-data" and ( parameter.type != "string" and parameter.type != "array" ) %}
api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if type({{ parameter.name | escapeKeyword | caseSnake }}) is bool else {{ parameter.name | escapeKeyword | caseSnake }}
api_params['{{ parameter.name }}'] = str({{ parameter.name | escapeKeyword | caseSnake }}).lower() if isinstance({{ parameter.name | escapeKeyword | caseSnake }}, bool) else {{ parameter.name | escapeKeyword | caseSnake }}
{% else %}
api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }}
{% endif %}
{% endif %}
{% endfor %}

{% for parameter in method.parameters.header %}
{% if parameter.required %}
# Required header parameters - convert None to explicit null
if {{ parameter.name | escapeKeyword | caseSnake }} is None:
self.client.add_header('{{ parameter.name }}', self.client.null())
else:
self.client.add_header('{{ parameter.name }}', {{ parameter.name | escapeKeyword | caseSnake }})
{% else %}
api_params['{{ parameter.name }}'] = {{ parameter.name | escapeKeyword | caseSnake }}
# Optional header parameters - only include if not None
if {{ parameter.name | escapeKeyword | caseSnake }} is not None:
self.client.add_header('{{ parameter.name }}', {{ parameter.name | escapeKeyword | caseSnake }})
{% endif %}
Comment on lines +86 to 95
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t send explicit nulls in HTTP headers.
Headers cannot carry JSON null. Passing client.null() will become None and may break requests or send "None".

Apply this diff to omit headers when None:

-{% if parameter.required %}
-        # Required header parameters - convert None to explicit null
-        if {{ parameter.name | escapeKeyword | caseSnake }} is None:
-            self.client.add_header('{{ parameter.name }}', self.client.null())
-        else:
-            self.client.add_header('{{ parameter.name }}', {{ parameter.name | escapeKeyword | caseSnake }})
+{% if parameter.required %}
+        # Required header parameters - omit when None (HTTP headers have no null representation)
+        if {{ parameter.name | escapeKeyword | caseSnake }} is not None:
+            self.client.add_header('{{ parameter.name }}', {{ parameter.name | escapeKeyword | caseSnake }})

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In templates/python/base/params.twig around lines 86 to 95, the current logic
sends explicit nulls for header parameters by calling
self.client.add_header(..., self.client.null()) when a required header value is
None, which is invalid; change the logic so no header is added when the
parameter is None for both required and optional cases (i.e., only call
self.client.add_header(...) when the parameter is not None), and remove the
branch that converts None to client.null(); if business rules require a missing
required header to be surfaced, replace the null-send with raising a clear
ValueError before attempting to add the header.

{% endfor %}
{% endif %}
2 changes: 1 addition & 1 deletion templates/python/base/requests/api.twig
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
{% for key, header in method.headers %}
'{{ key }}': '{{ header }}',
{% endfor %}
Comment on lines 5 to 7
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize static header keys to lowercase to match client checks.
Client.call looks up 'content-type' (lowercase). If spec emits 'Content-Type', JSON/multipart branches won’t trigger.

Apply this diff:

-{% for key, header in method.headers %}
-            '{{ key }}': '{{ header }}',
-{% endfor %}
+{% for key, header in method.headers %}
+            '{{ key | lower }}': '{{ header }}',
+{% endfor %}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{% for key, header in method.headers %}
'{{ key }}': '{{ header }}',
{% endfor %}
{% for key, header in method.headers %}
'{{ key | lower }}': '{{ header }}',
{% endfor %}
🤖 Prompt for AI Agents
In templates/python/base/requests/api.twig around lines 5 to 7, static header
keys are emitted with their original casing which can be 'Content-Type' and thus
won't match client.call's lowercase lookup; update the template to normalize
header names to lowercase when rendering (e.g., convert each key to lower-case
before output) so emitted headers use lowercase keys like 'content-type',
preserving header values as-is.

}, api_params{% if method.type == 'webAuth' %}, response_type='location'{% endif %})
}, api_params, nullable_params{% if method.type == 'webAuth' %}, response_type='location'{% endif %})
50 changes: 43 additions & 7 deletions templates/python/package/client.py.twig
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ class Client:
{% endfor %}
}

@staticmethod
def _is_explicitly_null(value):
"""Helper method to distinguish between None (undefined) and explicit null values"""
# Check if value is a special marker indicating an explicit null
return isinstance(value, type('NullValue', (), {'is_null': True}))

@staticmethod
def null():
"""Helper method to create an explicit null value to be sent to the API
Use this when you want to explicitly set a field to null, as opposed to
not sending the field at all (which happens when you use None)"""
return type('NullValue', (), {'is_null': True})()

Comment on lines +28 to +40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Explicit‑null detection is broken (always False).
Using isinstance(value, type('NullValue', ...)) creates a new class each time; no instance matches. This breaks filtering and leads to object reprs leaking into headers/params.

Apply this diff to introduce a stable sentinel and fix detection:

-    @staticmethod
-    def _is_explicitly_null(value):
-        """Helper method to distinguish between None (undefined) and explicit null values"""
-        # Check if value is a special marker indicating an explicit null
-        return isinstance(value, type('NullValue', (), {'is_null': True}))
-
-    @staticmethod
-    def null():
-        """Helper method to create an explicit null value to be sent to the API
-        Use this when you want to explicitly set a field to null, as opposed to 
-        not sending the field at all (which happens when you use None)"""
-        return type('NullValue', (), {'is_null': True})()
+    # Explicit NULL sentinel
+    class _NullValue:
+        __slots__ = ()
+        is_null = True
+    _NULL = _NullValue()
+
+    @staticmethod
+    def _is_explicitly_null(value):
+        """Distinguish explicit null sentinel (or any object with is_null=True) from None."""
+        return (value is Client._NULL) or (getattr(value, 'is_null', False) is True)
+
+    @staticmethod
+    def null():
+        """Create an explicit null value to be sent to the API."""
+        return Client._NULL
🤖 Prompt for AI Agents
In templates/python/package/client.py.twig around lines 28 to 40, the
explicit-null detection is broken because type('NullValue', ...) creates a new
dynamic class each call so isinstance never matches; replace that with a stable
module-level sentinel by defining a private sentinel class (e.g., _NullValue)
and a single instance (e.g., _NULL) and then change null() to return that
singleton and _is_explicitly_null(value) to detect it (use identity check `value
is _NULL` or isinstance against the stable class), ensuring all code uses the
same sentinel instance.

def set_self_signed(self, status=True):
self._self_signed = status
return self
Expand All @@ -50,28 +63,41 @@ class Client:
return self
{% endfor %}

def call(self, method, path='', headers=None, params=None, response_type='json'):
def call(self, method, path='', headers=None, params=None, nullable_params=None, response_type='json'):
if headers is None:
headers = {}

if params is None:
params = {}

if nullable_params is None:
nullable_params = []

# Process headers and params to handle explicit nulls while removing undefined (None) values
headers = {k: v for k, v in headers.items() if v is not None or self._is_explicitly_null(v)}
params = {k: v for k, v in params.items() if v is not None or self._is_explicitly_null(v) or k in nullable_params}

params = {k: v for k, v in params.items() if v is not None} # Remove None values from params dictionary
# Replace explicit null markers with None for JSON serialization
headers = {k: None if self._is_explicitly_null(v) else v for k, v in headers.items()}
params = {k: None if self._is_explicitly_null(v) or (v is None and k in nullable_params) else v for k, v in params.items()}

# Merge with global headers
headers = {**self._global_headers, **headers}

Comment on lines +66 to 86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize header keys and make content-type checks robust.
Per-call headers may have mixed casing; code later reads 'content-type'. Normalize keys and use .get to avoid KeyError.

Apply this diff:

     def call(self, method, path='', headers=None, params=None, nullable_params=None, response_type='json'):
@@
         if params is None:
             params = {}
-            
+        # Normalize header keys to lowercase once
+        headers = { (k.lower() if isinstance(k, str) else k): v for k, v in headers.items() }
         if nullable_params is None:
             nullable_params = []
 
-        # Process headers and params to handle explicit nulls while removing undefined (None) values
-        headers = {k: v for k, v in headers.items() if v is not None or self._is_explicitly_null(v)}
+        # Process headers and params to handle explicit nulls while removing undefined (None) values
+        headers = {k: v for k, v in headers.items() if v is not None or self._is_explicitly_null(v)}
         params = {k: v for k, v in params.items() if v is not None or self._is_explicitly_null(v) or k in nullable_params}
 
-        # Replace explicit null markers with None for JSON serialization
-        headers = {k: None if self._is_explicitly_null(v) else v for k, v in headers.items()}
+        # Replace explicit null markers with None for JSON serialization (headers: better to drop None later if present)
+        headers = {k: None if self._is_explicitly_null(v) else v for k, v in headers.items()}
         params = {k: None if self._is_explicitly_null(v) or (v is None and k in nullable_params) else v for k, v in params.items()}
 
         # Merge with global headers
         headers = {**self._global_headers, **headers}

And update downstream content-type checks (see next comment).

🤖 Prompt for AI Agents
In templates/python/package/client.py.twig around lines 66 to 86, per-call
headers may use mixed casing and later code reads 'content-type' directly
causing KeyError or missed matches; normalize header keys to lower-case and
ensure lookups use .get. After merging global and per-call headers, rebuild the
headers dict with all keys lowercased (e.g., headers = {k.lower(): v for k, v in
headers.items()}) so downstream checks like headers.get('content-type') are
robust; also ensure any direct header access in this file uses
.get('content-type') instead of indexing.

data = {}
files = {}
stringify = False

headers = {**self._global_headers, **headers}

# Move params to data for non-GET requests
if method != 'get':
data = params
params = {}

# Handle JSON content
if headers['content-type'].startswith('application/json'):
data = json.dumps(data, cls=ValueClassEncoder)

# Handle multipart form data
if headers['content-type'].startswith('multipart/form-data'):
del headers['content-type']
stringify = True
Expand All @@ -81,9 +107,10 @@ class Client:
del data[key]
data = self.flatten(data, stringify=stringify)

# Make the HTTP request
response = None
try:
response = requests.request( # call method dynamically https://stackoverflow.com/a/4246075/2299554
response = requests.request(
method=method,
url=self._endpoint + path,
params=self.flatten(params, stringify=stringify),
Expand All @@ -96,22 +123,25 @@ class Client:

response.raise_for_status()

# Handle warnings
warnings = response.headers.get('x-{{ spec.title | lower }}-warning')
if warnings:
for warning in warnings.split(';'):
print(f'Warning: {warning}', file=sys.stderr)

content_type = response.headers['Content-Type']

# Handle different response types
if response_type == 'location':
return response.headers.get('Location')

if content_type.startswith('application/json'):
return response.json()

return response._content

except Exception as e:
if response != None:
if response is not None:
content_type = response.headers['Content-Type']
if content_type.startswith('application/json'):
raise {{spec.title | caseUcfirst}}Exception(response.json()['message'], response.status_code, response.json().get('type'), response.text)
Expand Down Expand Up @@ -203,6 +233,7 @@ class Client:
return result

def flatten(self, data, prefix='', stringify=False):
"""Flatten a nested dictionary/list into a flat dictionary with dot notation."""
output = {}
i = 0

Expand All @@ -212,7 +243,12 @@ class Client:
finalKey = prefix + '[' + str(i) +']' if isinstance(data, list) else finalKey
i += 1

if isinstance(value, list) or isinstance(value, dict):
if value is None or self._is_explicitly_null(value): # Handle null values
if stringify:
output[finalKey] = ''
else:
output[finalKey] = None
elif isinstance(value, (list, dict)):
output = {**output, **self.flatten(value, finalKey, stringify)}
else:
if stringify:
Expand Down
17 changes: 14 additions & 3 deletions templates/python/package/encoders/value_class_encoder.py.twig
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,22 @@ import json
from ..enums.{{ enum.name | caseSnake }} import {{ enum.name | caseUcfirst | overrideIdentifier }}
{%~ endfor %}


from enum import Enum

class ValueClassEncoder(json.JSONEncoder):
def default(self, o):
{%~ for enum in spec.allEnums %}
# Handle explicit null values
if hasattr(o, 'is_null') and o.is_null:
return None

# Handle any enum type
if isinstance(o, Enum):
return o.value

{%~ for enum in spec.enums %}
if isinstance(o, {{ enum.name | caseUcfirst | overrideIdentifier }}):
return o.value

{%~ endfor %}
return super().default(o)

return super().default(o)
Loading