Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ Contents

* `Webhooks`_

* `Omitted params and null params`_

* `Advanced Usage`_

* `Setting the endpoint`_
Expand All @@ -73,6 +75,8 @@ Contents

* `Configuring the httpx client`_

* `Serializing URL search params`_

* `Development and Testing`_

* `Quickstart`_
Expand Down Expand Up @@ -447,6 +451,47 @@ see the `Svix docs for more examples in specific frameworks <https://docs.svix.c
app.run(port=8080)


Omitted params and null params
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The Seam API distinguishes an omitted param from a param explicitly set to null.
In an update request, an omitted param leaves the current value unchanged,
while a null param unsets the current value.
Some endpoints also accept null as a meaningful filter value.

Python has a single absence value, so this SDK maps the two cases as follows:

- ``None``, or simply not passing the param, omits it from the request.
- ``seam.NULL`` sends the param as null.

Sending null is rarely intended and unsetting a value cannot be undone,
so ``None`` means the safe option of omitting the param
and sending null is always explicit.
Route methods accept ``NULL`` only for the params the Seam API documents as
nullable, so a type checker reports passing it to any other param as an error:

.. code-block:: python

from seam import NULL, Seam

seam = Seam()

# Unsets the device name.
seam.devices.update(device_id=device_id, name=NULL)

# Leaves the device name unchanged.
seam.devices.update(device_id=device_id, name=None)

# Lists only the Access Grants which have no access_grant_key.
seam.access_grants.list(access_grant_key=NULL)

``NULL`` may be used at any depth, e.g., to clear a single key
while leaving the other keys unchanged:

.. code-block:: python

seam.spaces.update(space_id=space_id, customer_data={"check_in": NULL})

Advanced Usage
~~~~~~~~~~~~~~

Expand Down Expand Up @@ -518,6 +563,55 @@ precedence over the defaults the SDK sets:
},
)

Serializing URL search params
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The SDK serializes URL search params for you.
If you call the Seam API with your own HTTP client,
``serialize_url_search_params`` is exported for that purpose:

.. code-block:: python

import httpx
from seam import serialize_url_search_params

httpx.get(
"https://connect.getseam.com/devices/list",
params=serialize_url_search_params({"device_ids": ["device1", "device2"]}),
headers={"Authorization": "Bearer your-api-key"},
)

The serialization defines the name and value of each search param,
where every value is a string.
``UrlSearchParams`` holds those pairs and renders the query string,
as `URLSearchParams`_ does for the `reference implementation`_:

.. code-block:: python

from seam import UrlSearchParams, update_url_search_params

search_params = UrlSearchParams()

update_url_search_params(search_params, {"device_ids": ["device1", "device2"]})

list(search_params)
# => [('device_ids', 'device1'), ('device_ids', 'device2')]

str(search_params)
# => 'device_ids=device1&device_ids=device2'

Pass either the query string or the pairs to your HTTP client.
A client may percent-encode a few characters differently than
``URLSearchParams`` does, e.g. httpx escapes ``*`` and unescapes ``~``,
which the Seam API reads as the same params either way.

The Seam API parses these params with the corresponding `parser`_.

.. _URLSearchParams: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams

.. _reference implementation: https://github.com/seamapi/url-search-params-serializer
.. _parser: https://github.com/seamapi/url-search-params-parser

Development and Testing
-----------------------

Expand Down
2 changes: 1 addition & 1 deletion codegen/layouts/partials/method-signature.hbs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{{name}}(self{{#if params}}, *{{else}}{{#if (eq returnType "ActionAttempt")}}, *{{/if}}{{/if}}{{#each params}}, {{name}}: {{#if required}}{{type}}{{else}}Optional[{{type}}] = None{{/if}}{{/each}}{{#if (eq returnType "ActionAttempt")}}, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = None{{/if}}) -> {{returnType}}
{{name}}(self{{#if params}}, *{{else}}{{#if (eq returnType "ActionAttempt")}}, *{{/if}}{{/if}}{{#each params}}, {{name}}: {{#if required}}{{nullableType type isNullable}}{{else}}Optional[{{nullableType type isNullable}}] = None{{/if}}{{/each}}{{#if (eq returnType "ActionAttempt")}}, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = None{{/if}}) -> {{returnType}}
1 change: 1 addition & 0 deletions codegen/layouts/partials/route-method.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
raise ValueError("At least one parameter is required for {{path}}")
{{/if}}


{{#unless (eq returnType "None")}}res = {{/unless}}self.client.{{httpVerb}}("{{path}}", {{payloadArg}}={{payloadVar}})
{{#if (eq returnType "ActionAttempt")}}

Expand Down
1 change: 1 addition & 0 deletions codegen/layouts/route.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ from typing import Optional, Any, List, Dict, Union
import abc
from ..client import SeamHttpClient
from ..route import route_metadata
from ..null import Null
{{#if resourceClasses}}
from ..resources import ({{#each resourceClasses}}{{this}}{{#unless @last}},{{/unless}}{{/each}})
{{/if}}
Expand Down
3 changes: 2 additions & 1 deletion codegen/lib/class-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ export interface ClassMethodParameter {
deprecationMessage: string
position?: number | undefined
required?: boolean | undefined
isNullable?: boolean | undefined
}

export interface ClassMethod {
methodName: string
path: string
preferredMethod: string
semanticMethod: string
hasRequiredParameters: boolean
hasPagination: boolean
description: string
Expand Down
5 changes: 5 additions & 0 deletions codegen/lib/handlebars-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,8 @@ export const pythonIdentifier = (name: string): string =>
export const isListType = (type: string): boolean => type.startsWith('List[')

export const listItemType = (type: string): string => type.slice(5, -1)

// A nullable param accepts the NULL sentinel, which is sent as null.
// A param set to None is omitted from the request instead.
export const nullableType = (type: string, isNullable: boolean): string =>
isNullable ? `Union[${type}, Null]` : type
12 changes: 8 additions & 4 deletions codegen/lib/layouts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface MethodLayoutContext {
isDeprecated: boolean
deprecationMessage: string
required: boolean
isNullable: boolean
}>
returnPath: string[]
returnType: string
Expand Down Expand Up @@ -56,12 +57,14 @@ export interface RouteLayoutContext {
methods: MethodLayoutContext[]
}

// GET and DELETE carry their params in the query string, as the OpenAPI
// operations for those methods declare them; the rest read a JSON body.
const getRequestLayoutContext = (
preferredMethod: string,
semanticMethod: string,
): Pick<MethodLayoutContext, 'httpVerb' | 'payloadVar' | 'payloadArg'> => {
const httpVerb = preferredMethod.toLowerCase()
const httpVerb = semanticMethod.toLowerCase()

if (preferredMethod === 'GET' || preferredMethod === 'DELETE') {
if (semanticMethod === 'GET' || semanticMethod === 'DELETE') {
return { httpVerb, payloadVar: 'params', payloadArg: 'params' }
}

Expand All @@ -73,7 +76,7 @@ export const getMethodLayoutContext = (
): MethodLayoutContext => ({
name: method.methodName,
path: method.path,
...getRequestLayoutContext(method.preferredMethod),
...getRequestLayoutContext(method.semanticMethod),
hasRequiredParameters: method.hasRequiredParameters,
hasPagination: method.hasPagination,
description: method.description,
Expand All @@ -87,6 +90,7 @@ export const getMethodLayoutContext = (
isDeprecated: parameter.isDeprecated,
deprecationMessage: parameter.deprecationMessage,
required: parameter.required ?? false,
isNullable: parameter.isNullable ?? false,
})),
returnPath: method.returnPath,
returnType: method.returnResource,
Expand Down
3 changes: 2 additions & 1 deletion codegen/lib/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const routes = (
cls.methods.push({
methodName: endpoint.name,
path: endpoint.path,
preferredMethod: endpoint.request.preferredMethod,
semanticMethod: endpoint.request.semanticMethod,
hasRequiredParameters: endpoint.request.hasRequiredParameters,
hasPagination: endpoint.hasPagination,
description: endpoint.description,
Expand All @@ -104,6 +104,7 @@ export const routes = (
deprecationMessage: parameter.deprecationMessage,
position: parameter.name === idParameterName ? 0 : undefined,
required: parameter.isRequired,
isNullable: parameter.isNullable,
})),
...resolveResponse(response),
})
Expand Down
Loading
Loading