-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature/11 combine profile update and fi association #38
Feature/11 combine profile update and fi association #38
Conversation
Added UserProfile dto. Abstracted lei association method. Added lei association to admin update user api.
Updated json for all related calls. Changed update test to incorporate association testing. Added test where leis not present in call. Updated associate api test.
Coverage reportThe coverage rate went from
Diff Coverage details (click to unfold)src/entities/models/init.py
src/entities/models/dto.py
src/oauth2/oauth2_admin.py
src/routers/admin.py
|
src/entities/models/dto.py
Outdated
firstName: str | ||
lastName: str |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's keep the variable naming to the python convention of snake case, and use alias_generator to convert them to camel case: https://docs.pydantic.dev/latest/api/config/#pydantic.alias_generators.to_camel
https://stackoverflow.com/questions/67995510/how-to-inflect-from-snake-case-to-camel-case-post-the-pydantic-schema-validation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The body was to look like:
{ "firstName": "First", "lastName": "Last", "leis": ["LEI1", "LEI2"] }
So, we need aliasing for the request as well as when generating the dict?
To match the request reqs and the syntax that keycloak expects, we would need it in the form above, but to change to snake case would require aliasing to snake when request is sent and when creating dict being sent to keycloak.
Just double-checking here. Should the request body be changed to snake case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
request body to the api should be snake case, we would then alias it to camel case for keycloak.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just so I understand, our API's User resource will look like:
{
"first_name":"Jane",
"last_name":"Doe",
"leis": ["123456789012","234567890123"],
}
Meanwhile, the Keycloak Admin API's UserRepresentation expects:
{
"firstName": "Jane",
"lastName": "Doe"
}
...and to associated LEIs to said User, it's reeeaaally associating a Keycloak User to a Keycloak Group which is named after a given LEI? So, to complete the transaction, and second call that's made to...
PUT /admin/realms/{realm}/users/{id}/groups/{groupId}
If yes, 👍. If no, I may need some help here. 😅
I think what @lchen-2101 is getting at is that we shouldn't couple our API format to Keycloak's, which I agree with.
As to Pydantic's to_camel
, I'm not sure we want to do that. There's only 2 attributes we care about. Aaaand, what happens to leis
on our model when you do the to_camel
? Wouldn't that still be there in the data we'd be submitting to Keycloak? Seems like that might break.
To me, it doesn't seem that gross to just construct the dict at time of request in update_me()
. 🤷 Looks like we're already doing it that way in upsert_group
now anyway.
src/oauth2/oauth2_admin.py
Outdated
@@ -88,5 +88,9 @@ def associate_to_lei(self, user_id: str, lei: str) -> None: | |||
detail="No institution found for given LEI", | |||
) | |||
|
|||
def associate_to_lei_set(self, user_id, leis: Set[str]): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
normally if we can, we'd like to keep the implementation detail of the collection out of the var name / method name; this one should be good with just associate_to_leis
src/entities/models/dto.py
Outdated
def get_user(self) -> Dict[str, Any]: | ||
return {"firstName": self.firstName, "lastName": self.lastName} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pydantic should have something built-in that will generate the dict for you, I don't think you need to create a separate method for this.
src/routers/admin.py
Outdated
@@ -19,12 +20,13 @@ async def get_me(request: Request): | |||
|
|||
@router.put("/me/", status_code=HTTPStatus.ACCEPTED, dependencies=[Depends(check_domain)]) | |||
@requires("manage-account") | |||
async def update_me(request: Request, user: Dict[str, Any]): | |||
oauth2_admin.update_user(request.user.id, user) | |||
async def update_me(request: Request, user: UserProfile): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should API functions that use the Keycloak client be async
? It looks like python-keycloak uses requests under the hood, which is not async. I think they'd need to use something like httpx to support that. As it stands now, I think this could lead to unintentional blocking during these calls.
Dropping async
is not a bad thing. FastAPI will just run these requests in a new thread, which I think is what we'd want for synchronous processes like this.
src/entities/models/dto.py
Outdated
firstName: str | ||
lastName: str |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just so I understand, our API's User resource will look like:
{
"first_name":"Jane",
"last_name":"Doe",
"leis": ["123456789012","234567890123"],
}
Meanwhile, the Keycloak Admin API's UserRepresentation expects:
{
"firstName": "Jane",
"lastName": "Doe"
}
...and to associated LEIs to said User, it's reeeaaally associating a Keycloak User to a Keycloak Group which is named after a given LEI? So, to complete the transaction, and second call that's made to...
PUT /admin/realms/{realm}/users/{id}/groups/{groupId}
If yes, 👍. If no, I may need some help here. 😅
I think what @lchen-2101 is getting at is that we shouldn't couple our API format to Keycloak's, which I agree with.
As to Pydantic's to_camel
, I'm not sure we want to do that. There's only 2 attributes we care about. Aaaand, what happens to leis
on our model when you do the to_camel
? Wouldn't that still be there in the data we'd be submitting to Keycloak? Seems like that might break.
To me, it doesn't seem that gross to just construct the dict at time of request in update_me()
. 🤷 Looks like we're already doing it that way in upsert_group
now anyway.
Updated dto to snake case. Changed associate lei method name. Utilized pydantic dictionaries. Updated tests to account for method name change.
src/entities/models/dto.py
Outdated
class UserProfile(BaseModel): | ||
first_name: str = Field(alias="firstName") | ||
last_name: str = Field(alias="lastName") | ||
leis: Optional[Set[str]] = Field(alias="leis") | ||
|
||
class Config: | ||
alias_generator = to_camel |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
found a couple of things with the configs, the to_camel
generator is actually doing pascal case; there is a to_lower_camel
which would achieve the goal, without you having to specify Field
; there is also an allow_population_by_field_name
option which would allow the request payload to be snake case instead of just camel case; while it doesn't change the swagger doc to show camel case, it does allow the API to accept both camel case and snake case. So what you end up with would be:
class UserProfile(BaseModel):
first_name: str
last_name: str
leis: Optional[Set[str]]
class Config(BaseConfig):
alias_generator = to_lower_camel
allow_population_by_field_name = True
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just a side note; when we update fastapi later, the included pydantic package also changes to 2.x from 1.x, and some of these configurations will be changed. Nothing to worry about here, but in the pydantic settings issue, the dependency will be updated, so more changes will happen here as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need/want to have our API accept camelCase
? Is that just an artifact of us using the same UserProfile
model for both our API and for submitting to Keycloak's API? To me it seems more explicit and future-proof to decouple the two, and just do a simple translation to the expected JSON format at the time we call the Keycloak API. If we go this route, we have one oddball model object with extra Config
section that likely needs an explainer comment for what it's doing there, and we'll need to keep the exclude={"leis"}
bit up-to-date every time you add another field UserProfile
.
This is all what I was getting at when I asked #38 (comment), so it's also possible I'm just missing something.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@hkeeler It has to do with keycloak and the dictionary vals it's expecting, which are in camelCase.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I get that. My question is, why are we doing these Pydantic gymnastics when we could just...
def update_me(request: Request, user: UserProfile):
keycloak_user = {'firstName': user.first_name, 'lastName': user.last_name}
oauth2_admin.update_user(request.user.id, keycloak_user)
if user.leis:
oauth2_admin.associate_to_leis(request.user.id, user.leis)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
my thought would be still to have a well defined model to communicate with Keycloak instead of constructing a dict on the spot. I was hoping to leverage what's built into pydantic, and having the config take care of SerDe; but if we want to do away with the configs, having a method on the model would be more preferable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But that model is for our API, not Keycloak's, right? It feels like we're using the fact that the two APIs happen to be similar enough in message structure (currently) that we're using the same model for both, but doing so comes with the baggage of having their fields now coupled, having to understand/explain why one model is different from the rest, and having to adjust a disconnected method to exclude future fields we add.
Now, we could have a dedicated model for calls to Keycloak. I mean really, that should be the job of python-keycloak, but that seems to just be a very a light wrapper around their API. I think that's probably more than we need for this PR, though.
For now, I agree having a to_keycloak_user()
on the user model works. It's simple and explicit.
src/oauth2/oauth2_admin.py
Outdated
@@ -88,5 +88,9 @@ def associate_to_lei(self, user_id: str, lei: str) -> None: | |||
detail="No institution found for given LEI", | |||
) | |||
|
|||
def associate_to_leis(self, user_id, leis: Set[str]): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
def associate_to_leis(self, user_id, leis: Set[str]): | |
def associate_to_leis(self, user_id: str, leis: Set[str]): |
src/entities/models/dto.py
Outdated
class UserProfile(BaseModel): | ||
first_name: str = Field(alias="firstName") | ||
last_name: str = Field(alias="lastName") | ||
leis: Optional[Set[str]] = Field(alias="leis") | ||
|
||
class Config: | ||
alias_generator = to_camel |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need/want to have our API accept camelCase
? Is that just an artifact of us using the same UserProfile
model for both our API and for submitting to Keycloak's API? To me it seems more explicit and future-proof to decouple the two, and just do a simple translation to the expected JSON format at the time we call the Keycloak API. If we go this route, we have one oddball model object with extra Config
section that likely needs an explainer comment for what it's doing there, and we'll need to keep the exclude={"leis"}
bit up-to-date every time you add another field UserProfile
.
This is all what I was getting at when I asked #38 (comment), so it's also possible I'm just missing something.
src/entities/models/dto.py
Outdated
class Config: | ||
allow_population_by_field_name = True | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this be removed now too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
Closes #11