Skip to content

Commit

Permalink
refactor api to split create in new and create, refactor/simplify sav…
Browse files Browse the repository at this point in the history
…e_link
  • Loading branch information
jensens committed Dec 12, 2024
1 parent 4276b9a commit 31737a9
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 94 deletions.
149 changes: 76 additions & 73 deletions src/edutap/wallet_google/api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
from .models.bases import Model
from .models.datatypes.general import Pagination
from .models.datatypes.jwt import JWTClaims
from .models.datatypes.jwt import JWTPayload
from .models.datatypes.jwt import Reference
from .models.datatypes.message import Message
from .models.misc import AddMessageRequest
from .models.misc import ObjectWithClassReference
from .models.passes.bases import ClassModel
from .models.passes.bases import ObjectModel
from .registry import lookup_metadata_by_model_instance
from .registry import lookup_metadata_by_model_type
from .registry import lookup_metadata_by_name
from .registry import lookup_model
from .registry import lookup_model_by_plural_name
from .registry import lookup_model_by_name
from .registry import raise_when_operation_not_allowed
from .session import session_manager
from collections.abc import Generator
Expand Down Expand Up @@ -65,22 +70,38 @@ def _validate_data_and_convert_to_json(
return (identifier, verified_json)


def create(
def new(
name: str,
data: dict[str, typing.Any] | Model,
data: dict[str, typing.Any] = {},
):
"""
Factors a new registered Google Wallet Model by name, based on the given data.
:param name: Registered name of the model to use
:param data: Data to initialize the model with.
A simple JSON compatible Python data structure using built-ins.
:raises Exception: When the data does not validate.
:return: The created model instance.
"""
model = lookup_model_by_name(name)
return _validate_data(model, data)


def create(
data: Model,
) -> Model:
"""
Creates a Google Wallet items. `C` in CRUD.
:param name: Registered name of the model to use
:param data: Data to pass to the Google RESTful API.
Either a simple python data structure using built-ins,
or a Pydantic model instance matching the registered name's model.
A model instance, has to be a registered model.
:raises Exception: When the response status code is not 200.
:return: The created model based on the data returned by the Restful API.
"""
model_metadata = lookup_metadata_by_model_instance(data)
name = model_metadata["name"]
raise_when_operation_not_allowed(name, "create")
model = lookup_model(name)
model = model_metadata["model"]
resource_id, verified_json = _validate_data_and_convert_to_json(model, data)
session = session_manager.session
url = session_manager.url(name)
Expand Down Expand Up @@ -123,42 +144,35 @@ def read(
raise LookupError(f"{url}: {name} not found")

if response.status_code == 200:
model = lookup_model(name)
model = lookup_model_by_name(name)
logger.debug(f"RAW-Response: {response.content!r}")
return model.model_validate_json(response.content)

raise Exception(f"{url} {response.status_code} - {response.text}")


def update(
name: str,
data: dict[str, typing.Any] | Model,
data: Model,
*,
partial: bool = True,
) -> Model:
"""
Updates a Google Wallet Class or Object. `U` in CRUD.
:param name: Registered name of the model to use
:param data: Data to pass to the Google RESTful API.
Either a simple python data structure using built-ins,
or a Pydantic model instance matching the registered name's model.
:param override_all: When True, all fields will be overwritten, otherwise only given fields.
A model instance, has to be a registered model.
:param partial: Whether a partial update is executed or a full replacement.
:raises LookupError: When the resource was not found (404)
:raises Exception: When the response status code is not 200 or 404
:return: The created model based on the data returned by the Restful API
"""
model_metadata = lookup_metadata_by_model_instance(data)
name = model_metadata["name"]
raise_when_operation_not_allowed(name, "update")
model_metadata = lookup_metadata_by_name(name)
model = model_metadata["model"]
if not isinstance(data, Model) and partial:
resource_id = data[model_metadata["resource_id"]]
# we can not validate partial data for patch yet
verified_json = json.dumps(data)
else:
resource_id, verified_json = _validate_data_and_convert_to_json(
model, data, existing=True, resource_id_key=model_metadata["resource_id"]
)
resource_id, verified_json = _validate_data_and_convert_to_json(
model, data, existing=True, resource_id_key=model_metadata["resource_id"]
)
session = session_manager.session
if partial:
response = session.patch(
Expand Down Expand Up @@ -259,7 +273,7 @@ def listing(
if resource_id and issuer_id:
raise ValueError("resource_id and issuer_id are mutually exclusive")

model = lookup_model(name) # early, also to test if name is registered
model = lookup_model_by_name(name) # early, also to test if name is registered

params = {}
is_pageable = False
Expand Down Expand Up @@ -319,7 +333,7 @@ def listing(


def save_link(
resources: dict[str, list[Model | dict]],
models: list[ClassModel | ObjectModel | Reference],
*,
origins: list[str] = [],
) -> str:
Expand All @@ -328,64 +342,53 @@ def save_link(
Besides the capability to save an object to the wallet, it is also able create classes on-the-fly.
:param resources: Dictionary of resources to save.
Each dictionary key is the registered plural name of a model.
Usually, this is the name with a lower first character and as plural.
The value is either a simple python data structure using built-ins,
or a Pydantic model instance matching the registered name's model.
If a resource is an Object, it can be an ObjectReference instance too.
More information about the construction of the save_link can be found here:
- https://developers.google.com/wallet/reference/rest/v1/jwt
- https://developers.google.com/wallet/generic/web
- https://developers.google.com/wallet/generic/use-cases/jwt
:param models: List of ObjectModels or ClassModels to save.
A resource can be an ObjectReference instance too.
:param origins: List of domains to approve for JWT saving functionality.
The Google Wallet API button will not render when the origins field is not defined.
You could potentially get an "Load denied by X-Frame-Options" or "Refused to display"
messages in the browser console when the origins field is not defined.
:return: Link with JWT to save the resources to the wallet.
"""
# validate resources
payload: dict[str, typing.Any] = {}
for name, objs in resources.items():
payload[name] = []
for obj in objs:

# first look if this is a reference to an existing wallet object passed as dict
if isinstance(obj, dict) and (
("id" in obj and len(obj.keys()) == 1)
or ("id" in obj and "classReference" in obj and len(obj.keys()) == 2)
):
obj = ObjectWithClassReference.model_validate(obj)

# if it is not a reference, it must be a full wallet object model
if not isinstance(obj, ObjectWithClassReference):
model = lookup_model_by_plural_name(name)
obj = _validate_data(model, obj)

# dump the model to json
obj_json = obj.model_dump(
# explicitly set to model_dump(mode="json") instead of model_dump_json due to problems
# reported by jensens
mode="json",
exclude_none=True, # exclude None values - here we create something new, no updates.
exclude_unset=True, # exclude unset values - this are values not set explicitly by the code
by_alias=True,
)
# append to the current payload section
payload[name].append(obj_json)

claims = {
"iat": "",
"iss": session_manager.settings.credentials_info["client_email"],
"aud": "google",
"origins": origins,
"typ": "savetowallet",
"payload": payload,
}
payload = JWTPayload()
for model in models:
if isinstance(model, Reference):
if model.model_name is not None:
name = model.model_name
elif model.model_type is not None:
name = lookup_metadata_by_model_type(model.model_type)["plural"]
else:
name = lookup_metadata_by_model_instance(model)["plural"]
if getattr(payload, name) is None:
setattr(payload, name, [])
getattr(payload, name).append(model)

claims = JWTClaims(
iss=session_manager.settings.credentials_info["client_email"],
origins=origins,
payload=payload,
)
signer = crypt.RSASigner.from_service_account_file(
session_manager.settings.credentials_file
)
jwt_string = jwt.encode(signer, claims).decode("utf-8")
jwt_string = jwt.encode(
signer,
claims.model_dump(
exclude_unset=False,
exclude_defaults=False,
exclude_none=True,
),
).decode("utf-8")
if len(jwt_string) >= 1800:
logger.debug(
"JWT-Length: %d, is larger than recommended 1800 bytes: %s",
len(jwt_string),
len(jwt_string) >= 1800,
)
return f"{session_manager.settings.save_url}/{jwt_string}"
return session_manager.url("Jwt", f"/{jwt_string}")
1 change: 1 addition & 0 deletions src/edutap/wallet_google/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# import to register models
from . import misc # noqa: F401
from . import passes # noqa: F401
71 changes: 71 additions & 0 deletions src/edutap/wallet_google/models/datatypes/jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""
Models to be used to assemble the JWT for the save link (add to wallet link).
"""

from ..bases import Model
from ..bases import WithIdModel
from ..passes import generic
from ..passes import retail
from ..passes import tickets_and_transit
from pydantic import Field
from pydantic import model_validator


class Reference(WithIdModel):
"""
References an existing wallet object.
It is used to create the JWT for the add to wallet link.
The id must be an existing wallet object id.
Either model_name or mode_type must be set.
"""

# inherits id

# mode_name and model_type are implementation specific for this package
model_name: str | None = Field(exclude=True, default=None)
model_type: type[Model] | None = Field(exclude=True, default=None)

@model_validator(mode="after")
def check_one_of(self) -> "Reference":
if self.model_name is None and self.model_type is None:
raise ValueError("One of [model_name, model_type] must be set")
if self.model_name is not None and self.model_type is not None:
raise ValueError("Only one of [model_name, model_type] must be set")
return self


class JWTPayload(Model):

eventTicketClasses: (
list[tickets_and_transit.EventTicketClass | Reference] | None
) = None
eventTicketObjects: (
list[tickets_and_transit.EventTicketObject | Reference] | None
) = None
flightClasses: list[tickets_and_transit.FlightClass | Reference] | None = None
flightObjects: list[tickets_and_transit.FlightObject | Reference] | None = None
giftCardClasses: list[retail.GiftCardClass | Reference] | None = None
giftCardObjects: list[retail.GiftCardObject | Reference] | None = None
loyaltyClasses: list[retail.LoyaltyClass | Reference] | None = None
loyaltyObjects: list[retail.LoyaltyObject | Reference] | None = None
offerClasses: list[retail.OfferClass | Reference] | None = None
offerObjects: list[retail.OfferObject | Reference] | None = None
transitClasses: list[tickets_and_transit.TransitClass | Reference] | None = None
transitObjects: list[tickets_and_transit.TransitObject | Reference] | None = None
genericClasses: list[generic.GenericClass | Reference] | None = None
genericObjects: list[generic.GenericObject | Reference] | None = None


class JWTClaims(Model):
"""
see: https://developers.google.com/wallet/reference/rest/v1/Jwt
"""

iss: str
aud: str = "google"
typ: str = "savettowallet"
iat: str = ""
payload: JWTPayload
origins: list[str]
11 changes: 0 additions & 11 deletions src/edutap/wallet_google/models/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,6 @@
from .passes import generic
from .passes import retail
from .passes import tickets_and_transit
from .passes.bases import ClassModel


class ObjectWithClassReference(WithIdModel):
"""
Google Wallet Object with a classReferences attribute, that reflects the whole class data.
This class is used to create the save_link only, never inherit from it.
"""

classReference: ClassModel | None = None


@register_model(
Expand Down
11 changes: 9 additions & 2 deletions src/edutap/wallet_google/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def __call__(
return cls


def lookup_model(name: str) -> type[Model]:
def lookup_model_by_name(name: str) -> type[Model]:
"""
Returns the model with the given name.
"""
Expand All @@ -117,11 +117,18 @@ def lookup_metadata_by_name(name: str) -> RegistryMetadataDict:

def lookup_metadata_by_model_instance(model: Model) -> RegistryMetadataDict:
"""
Returns the registry metadata by a given instacne of a model
Returns the registry metadata by a given instance of a model
"""
return _MODEL_REGISTRY_BY_MODEL[type(model)]


def lookup_metadata_by_model_type(model_type: type[Model]) -> RegistryMetadataDict:
"""
Returns the registry metadata by a given model type
"""
return _MODEL_REGISTRY_BY_MODEL[model_type]


def raise_when_operation_not_allowed(name: str, operation: str) -> None:
"""Verifies that the given operation is allowed for the given registered name.
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/test_create_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@
@pytest.mark.parametrize("class_type,class_data", params_for_create)
def test_class_creation(class_type, class_data, integration_test_id):
from edutap.wallet_google.api import create
from edutap.wallet_google.api import new
from edutap.wallet_google.api import session_manager

class_data["id"] = (
f"{session_manager.settings.issuer_id}.{integration_test_id}.test_class_creation.wallet_google.edutap"
)
result = create(class_type, class_data)
data = new(class_type, class_data)
result = create(data)
assert result is not None
8 changes: 5 additions & 3 deletions tests/test_api_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@
@pytest.mark.parametrize("prefix,name,method,checkdata", testdata)
def test_api_create(mock_request_response, prefix, name, method, checkdata):
from edutap.wallet_google.api import create
from edutap.wallet_google.registry import lookup_model
from edutap.wallet_google.api import new
from edutap.wallet_google.registry import lookup_model_by_name
from edutap.wallet_google.session import session_manager

request_data = mock_request_response(
f"{prefix}{name}", session_manager.url(name), method
)
result = create(name, request_data["request"]["body"])
data = new(name, request_data["request"]["body"])
result = create(data)

model = lookup_model(name)
model = lookup_model_by_name(name)
assert isinstance(result, model)
for key, value in checkdata.items():
assert getattr(result, key) == value
Loading

0 comments on commit 31737a9

Please sign in to comment.