Skip to content
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

Enode API Changes #87

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open

Enode API Changes #87

wants to merge 23 commits into from

Conversation

AndrewLester
Copy link
Contributor

@AndrewLester AndrewLester commented Apr 21, 2023

Pull Request

This pull request adds all of the necessary endpoints we'll need for minimal Enode support in the pv-sites-mobile UI. For reference, it adds this functionality:

  • Linking an inverter vendor account with Enode
  • Getting the inverter data for all of a client's linked inverters
  • Getting the inverter data for all of a client's inverters assigned to a specific site
  • Assigning inverter IDs to a specific site

Unrelated to Enode, but necessary for our project, is another endpoint:

  • Update site metadata

We could move this latter endpoint to another PR if necessary.

Description

Adds support for linking inverters, storing linked inverters in our database, and retrieving inverter information from Enode.

Fixes: #71
Fixes: #72
Fixes: #73
Fixes: #74

Several new environment variables are now used:

  • ENODE_API_BASE_URL, though not required (defaults to sandbox) found here
  • ENODE_TOKEN_URL, though not required (defaults to Enode token URL specific in docs) found here
  • ENODE_CLIENT_ID the Enode client ID
  • ENODE_CLIENT_SECRET the Enode client secret

How Has This Been Tested?

This has been tested with the addition of new test files as well as run through manual testing.

Checklist:

  • My code follows OCF's coding style guidelines
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • I have checked my code and corrected any misspellings

@codecov
Copy link

codecov bot commented Apr 21, 2023

Codecov Report

Merging #87 (819f55f) into main (5eecace) will increase coverage by 0.78%.
The diff coverage is 99.31%.

@@            Coverage Diff             @@
##             main      #87      +/-   ##
==========================================
+ Coverage   93.82%   94.60%   +0.78%     
==========================================
  Files          10       10              
  Lines         405      538     +133     
==========================================
+ Hits          380      509     +129     
- Misses         25       29       +4     
Impacted Files Coverage Δ
pv_site_api/auth.py 89.28% <90.00%> (-1.20%) ⬇️
pv_site_api/__init__.py 100.00% <100.00%> (ø)
pv_site_api/_db_helpers.py 100.00% <100.00%> (ø)
pv_site_api/cache.py 89.18% <100.00%> (-7.96%) ⬇️
pv_site_api/fake.py 100.00% <100.00%> (ø)
pv_site_api/main.py 94.89% <100.00%> (+2.03%) ⬆️
pv_site_api/pydantic_models.py 100.00% <100.00%> (ø)
pv_site_api/utils.py 100.00% <100.00%> (ø)

📣 We’re building smart automated test selection to slash your CI/CD build times. Learn more

anyaparekh and others added 5 commits April 24, 2023 01:43
Fix lint errors

Finish both endpoints

Fix formatting and lint errors

Update pv_site_api/main.py

Co-authored-by: Andrew Lester <[email protected]>

Update pv_site_api/main.py

Co-authored-by: Andrew Lester <[email protected]>

Update pv_site_api/main.py

Co-authored-by: Andrew Lester <[email protected]>

Address PR comments

Update pyproject.toml

Fix lint errors

Fix formatting

Fix imports

Fix bugs

Remove unused import

Fix formatting

Fix import order

Create enode_auth.py

Define basic auth function

Get api key from env

Finish part 1
Remove test end point

clean up

Remove print messages

Make getenv set variable to str

make format

lint and format

Remove unecessary check

Remove test files

Use env var for enode token url

Change get_enode_access_token to return request

Refactor get_enode_access_token() into EnodeAuth class

change 404 to 401

format

Remove test code

Remove print

Address PR changes

Update enode constructor to take in client_id, secret, and url

Add type hints

Add inverters test for site specific

Fix lint errors

Modularize code

Fix lint errors
* Add enode link URL endpoint

* Fix lint

* Rename test var for clarity

* Add comment to mock urls fixtuer

* Use redirect response
* Bump version: 0.0.36 → 0.0.37

* TDD: add failing test, looking at forecasts in the future

* add start utc filter on forecast future qery

* lint

* lint

* isort

* add freeze gun to dev dependencies

* self PR comments

* Bump version: 0.0.37 → 0.0.38

* Make site_uuid field on PVSiteMetadata model optional

* Make pydantic field optional

* Move test site with real UUID to real db test

* Don't use uuid in tests

* Add part of check back

* Ensure autogenerated site uuid is not null in database

* Add WHERE statement on query for past forecasts

This doesn't change the result but speeds up the query in general.

* Bump version: 0.0.38 → 0.0.39

* Bump version: 0.0.39 → 0.0.40

* add logging

* lint

* run blacks

* add extra logs

* Bump version: 0.0.40 → 0.0.41

* add structlogging

* add structlog to requiremwnts

* add logging

* Bump version: 0.0.41 → 0.0.42

* Add structlog initialisation

* blacks

* isort

* Bump version: 0.0.42 → 0.0.43

* Uniformize check to FAKE and fix related test

* Set codecov targets

* Hard-code the "now" time for all tests

This means that the same tests are run now matter the time at which we
run them.

* add caching

* fix

* lint

* isort

* fix for routes calling routes

* lint

* Bump version: 0.0.43 → 0.0.44

* PR comment

* add args to cache

* lint

* Update pv_site_api/cache.py

Co-authored-by: Simon Lemieux <[email protected]>

* tidy

* Bump version: 0.0.44 → 0.0.45

* What I've got

* Add basic authorization to the /sites* routes

The caller of the routes must have a proper Bearer authorization
header set.

* refactor

* add tests

* fix error in import

* update pvsite-datamodel (in line with main)

* fix bug

* fix incompatible types

* lint and format

* add site existence check

* add back correct datamodel dependency

* fix test name

* allow tests to pass by adding fake condition

* remove 404 check

* use or instead of is not none

* Bump version: 0.0.45 → 0.0.46

* add site existence check

* format and lint

* Bump version: 0.0.46 → 0.0.47

* add post endpoint

* add test back in

* fix errors and format

* add site name to test

* fix tiny error

* fix another small error

* run tests

* fix bugs

* lint and format

* fix fake

* :pleading-face:

* yet another is_fake() addition

* address comments

---------

Co-authored-by: Peter Dudfield <[email protected]>
Co-authored-by: BumpVersion Action <bumpversion@github-actions>
Co-authored-by: peterdudfield <[email protected]>
Co-authored-by: AndrewLester <[email protected]>
Co-authored-by: Simon Lemieux <[email protected]>
Co-authored-by: devsjc <[email protected]>

Fix some functions

Fix Enode link endpoint

Remove duplicate fixture

Rename /inverters to /enode/inverters
Move a few things

Fix pyproject

Fix

Fix tests

Fix import order

Default the token URL

Add some tests for other states

Fix last missing coverage in main.py
@AndrewLester AndrewLester changed the title [WIP] Enode API Changes Enode API Changes Apr 24, 2023
@AndrewLester AndrewLester marked this pull request as ready for review April 24, 2023 07:06
pv_site_api/main.py Show resolved Hide resolved
pv_site_api/main.py Outdated Show resolved Hide resolved
pv_site_api/main.py Outdated Show resolved Hide resolved
pv_site_api/main.py Outdated Show resolved Hide resolved


@app.get("/enode/inverters")
async def get_inverters(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Any reason why some of the new routes are async and others not?

I think we should keep everything non-async if possible since we are not using the async version of sqlalchemy. This means that any query to the database will actually be blocking.

That being said I'm not sure of the implications for httpx.

Copy link
Contributor Author

@AndrewLester AndrewLester Apr 27, 2023

Choose a reason for hiding this comment

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

The reason we made some of these new routes async is ultimately because of this function:
https://github.com/openclimatefix/pv-site-api/pull/87/files#diff-d682b0ba7c0698e66b6ea6e320db5feb17c1460dbbe67aa2852801b0867f311fR16-R26
(get_inverters_list)

The function needs to make several requests to Enode, one for each of the user's inverters to get detailed inverter data. For performance reasons, we choose to make the requests in parallel, using httpx's AsyncClient and aggregating the results with asyncio. Given we use an AsyncClient instance and we await the asyncio call, we must make the get_inverters_list function async. In turn, we then must make all routes that call get_inverters_list async too.

You're right that the current queries will also block the server in these async routes, which is my mistake, as I thought FastAPI would run all routes in an external thread pool no matter what. Given that the await on get_inverters_list depends on the database queries in these async routes, I think setting up the SQL queries as path dependencies rather than in the route body will prevent the blocking. It seems like any non async dependency will run in an external thread pool even if the path function itself is async docs.

Copy link
Contributor Author

@AndrewLester AndrewLester Apr 27, 2023

Choose a reason for hiding this comment

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

I'm now realizing the Auth path dependency might actually be a good place to grab the client from the database... the way you've implemented it now is quite abstract, so maybe it makes more sense to create a new dependency called get_client, which depends on Auth, and grabs the client (for now, just returns the first one), from the Auth state.

Then, each route which needs the client can just depend on that, and if a route doesn't need the client, can depend on Auth. Or, they can all just use get_client since I'm not sure when a route body would use the decoded JWT payload by itself?

In any case, this fixes our blocking problem by putting the query for the client in a non async path dependency.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree with you that if a route body doesn't call the DB (which might be possible here if the information about what is the user's client is coming from Auth0), then we can use async.

This might be the case in this function but it's not the case for all the other async functions in this PR.

Any reason why we can't simply use httpx's httpx.Client() instead of the AsyncClient? It would be simpler and harder to shoot ourselves in the foot in our otherwise non-async setup.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This might be the case in this function but it's not the case for all the other async functions in this PR.

Yes, so the other async functions which make other DB accesses will have to move those accesses to a path dependency as well, one which depends on auth/client.

The reason we can't use httpx.Client() is because it blocks within the thread the route is running on when making requests, and since we're making several independent requests to Enode, we would rather make those requests in parallel, which is only possible with httpx using httpx.AsyncClient().

Copy link
Collaborator

Choose a reason for hiding this comment

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

OK I see your point.

If those really need to run in parallel, the alternative (without async) would be to use threads. Given that we not otherwise using async in the API at the moment, I think that would be the cleaner solution.


This brought to mind a more general question: why do we need to go through the (OCF) API to get the Enode inverter data? Can't we call Enode's API directly from the frontend? The only thing that really needs to get to our backend is the inverter id. In other words, could we have this:

GET /sites/{site_id}/inverters => Returns the list of inverter ids for given site
PUT /sites/{site_id}/inverters => Set the inverter id for given site

And that's it. The frontend would then call Enode with those ids if it needs the full inverter data.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good, I've also got an implementation of the non-blocking (via path dependencies) database accesses with the async routes, that I'll push in a second. We can definitely switch this to threads though.

Regarding accessing Enode's API directly from the frontend, since their API requires Enode secrets, and has no access control (any one with a token can access all users), it wouldn't be feasible. This would make things significantly simpler though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry for taking so long on the above message @simlmx, it took me a while to figure out a bug with the way I've set it up and the new cache system, and for now I've temporarily ignored the issue by just ignoring the auth dependency in the cache key. This is not mergable at this point since the auth dependency must be in the cache key, but I still need to add a JSON encoder for the ClientSQL model, or something similar that won't require a pv-site-datamodel change ideally... all in all the code I just pushed is still a work in progress, but hopefully you can see the idea.

This current code does fix the server blocking issue by putting DB accesses in the async def routes into a dependency.

Let me know if you think this general idea works, or if switching to threads is the better option. My only concern with a thread based solution is it will involve additional complexity to do with creating the several threads and waiting on them.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hi @AndrewLester

Whats the status on this? Is the auth cache key still an issue?
The requests to ENODE to get the inverter data? How long does each on roughly take?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The auth cache key is no longer an issue since I serialize the current request's corresponding client, and use that as part of the key here:

# translate authenticated client to serializable type
route_variables["auth"] = (
route_variables["auth"] and client_to_pydantic(route_variables["auth"]).json()
)

However, since we currently still get the first client in the database for every request, every request that is authenticated will share the same cache for the time being. This doesn't change behavior since the endpoints used the same client anyway.

The requests to Enode to get inverter data take on average 560ms in the sandbox API and, although I can't test the actual inverter endpoints with production since we don't have any users linked there at the moment, the endpoints take around 550ms before returning nothing.

This is on the longer side, but given that most users we'll work with will only have 1-2 inverters, we'd only be looking at a 2x slowdown at most, if were to make these requests in series.

tests/conftest.py Outdated Show resolved Hide resolved
tests/conftest.py Outdated Show resolved Hide resolved
pv_site_api/main.py Outdated Show resolved Hide resolved
@AndrewLester
Copy link
Contributor Author

AndrewLester commented May 4, 2023

I was just able to remove our EnodeAuth class, which was really just an OAuth2 client credentials authorization wrapper that worked for Enode. Since it was so general, I think it makes sense to not include the code/tests in this repo, and instead pull it from a library like the one I've installed: httpx-auth.

Right now this is failing tests though because the library's class requires a non-empty string for the client_id and client_secret, but these aren't provided by the GitHub actions. They can either be added (test values), or I can default them to non-empty test values here:

client_id=os.getenv("ENODE_CLIENT_ID", ""),
client_secret=os.getenv("ENODE_CLIENT_SECRET", ""),

Alternatively, the enode_auth instance there could be provided as a mocked app dependency.

@AndrewLester
Copy link
Contributor Author

To make the tests pass without those environment variables, I just opted to default them to unusable values. This doesn't change any behavior since the tests don't even use the enode_auth object, given that all the test httpx requests are mocked out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
7 participants