Skip to content

Add vendor lookup endpoint #3354

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

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open

Add vendor lookup endpoint #3354

wants to merge 9 commits into from

Conversation

stveit
Copy link
Contributor

@stveit stveit commented Apr 28, 2025

Fixes #3337

Adds endpoint that accepts queries of a single MAC address with GET requests using url parameter ?mac, and a bulk query using POST with a JSON body.

Endpoint: /api/vendor/

@stveit stveit added the api label Apr 28, 2025
@stveit stveit self-assigned this Apr 28, 2025
@stveit stveit force-pushed the oui-api-endpoint branch from 6c15ccf to ff172ed Compare April 28, 2025 17:16
Copy link

github-actions bot commented Apr 28, 2025

🦙 MegaLinter status: ✅ SUCCESS

Descriptor Linter Files Fixed Errors Warnings Elapsed time
✅ PYTHON black 3 0 0 0.81s
✅ PYTHON ruff 3 0 0 0.14s

See detailed report in MegaLinter reports
Set VALIDATE_ALL_CODEBASE: true in mega-linter.yml to validate all sources, not only the diff

MegaLinter is graciously provided by OX Security

Copy link

github-actions bot commented Apr 28, 2025

Test results

   12 files     12 suites   11m 53s ⏱️
2 206 tests 2 206 ✅ 0 💤 0 ❌
6 093 runs  6 093 ✅ 0 💤 0 ❌

Results for commit fafcba0.

♻️ This comment has been updated with latest results.

@stveit stveit force-pushed the oui-api-endpoint branch from ff172ed to 5535411 Compare April 29, 2025 07:19
@stveit stveit marked this pull request as ready for review April 29, 2025 07:19
@stveit stveit force-pushed the oui-api-endpoint branch from 5535411 to 363a643 Compare April 29, 2025 07:21
@stveit stveit force-pushed the oui-api-endpoint branch from 363a643 to a34ad52 Compare April 29, 2025 07:25
@johannaengland johannaengland requested a review from a team April 30, 2025 07:54
Copy link
Contributor

@johannaengland johannaengland left a comment

Choose a reason for hiding this comment

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

Comment on lines 1192 to 1193
if not mac:
return Response("Missing MAC address", status=status.HTTP_400_BAD_REQUEST)
Copy link
Contributor

Choose a reason for hiding this comment

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

This leads to already getting a 404 response when just initially accessing the endpoint, if you're using a browser to access the API

Copy link
Contributor Author

@stveit stveit May 7, 2025

Choose a reason for hiding this comment

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

Not sure how to fix this issue honestly ..

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe just a 200 with the message "No mac address selected"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Made it so it just returns an empty dict like the POST endpoint does if you give it an empty list

@staticmethod
def post(request):
try:
mac_addresses = json.loads(request.body.decode('utf-8'))
Copy link
Contributor

Choose a reason for hiding this comment

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

If I use a browser to access the API and try to post to this endpoint I always get "Invalid JSON"

request.body looks like this in this case: _content_type=application%2Fjson&_content=%5B%22aa%3Abb%3Acc%3Add%3Aee%3Aff%22%2C+%2211%3A22%3A33%3A44%3A55%3A66%22%5D

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe request.data should be used instead here? Or does that only work if the JSON is a dict?

Copy link
Contributor Author

@stveit stveit May 7, 2025

Choose a reason for hiding this comment

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

request.data works, but behaves differently based on what made the request. Via curl it is automatically converted from JSON to a list (including error messages if the JSON is invalid). So if you send a curl post with a JSON array, request.data will give you a list directly. But with the browseable API request.data is a QueryDict where the json string is accessible via the _content key.

Would be nice if the browseable API requsts also got automatically parsed. I imagine ModelViews has some builtin stuff that handles this, so would be nice if that can be hooked into somehow.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Made a fix specifically so the browseable API works, but its not all that pretty.. basically extra parsing to support this specific case

try:
results = get_vendor_names(mac_addresses)
except ValueError:
return Response("Invalid MAC address", status=status.HTTP_400_BAD_REQUEST)
Copy link
Contributor

Choose a reason for hiding this comment

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

Would be nice to here return which one(s) is/are invalid

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 has been added

try:
results = get_vendor_names([mac])
except ValueError:
return Response("Invalid MAC address", status=status.HTTP_400_BAD_REQUEST)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
return Response("Invalid MAC address", status=status.HTTP_400_BAD_REQUEST)
return Response(f"Invalid MAC address '{mac}'", status=status.HTTP_400_BAD_REQUEST)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done this

@@ -346,6 +350,106 @@ def test_interface_with_last_used_should_be_listable(
assert response.status_code == 200


class TestVendorLookupGet:
Copy link
Contributor

Choose a reason for hiding this comment

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

The lines

endpoint = 'vendor'
create_token_endpoint(token, endpoint)

feel like something we can set up here for the whole class instead of repeating it in every test

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed it so it now uses a fixture

response = api_client.get(ENDPOINTS[endpoint])
assert response.status_code == 400


Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment as above

Copy link
Member

Choose a reason for hiding this comment

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

A vendor-endpoint specific fixture would do, I guess :)

@johannaengland
Copy link
Contributor

The SQL part looks perfect for @lunkwill42 to have a look at 😁

@@ -73,4 +73,5 @@
name="prefix-usage-detail",
),
re_path(r'^', include(router.urls)),
re_path(r'^vendor/?$', views.VendorLookup.as_view(), name='vendor'),
Copy link
Member

Choose a reason for hiding this comment

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

My DRF-fu is rusty (as mentioned in another PR review today). I assume the primary reason the url pattern is added explicitly, rather than through DRF's router-thingy is that this view isn't actually a model view with built-in CRUD-magic?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes, using the router stuff did not seem to work very well with APIView

Comment on lines 1230 to 1236
cursor.execute(
"""
CREATE TEMPORARY TABLE temp_macaddrs (
mac macaddr PRIMARY KEY
)
"""
)
Copy link
Member

Choose a reason for hiding this comment

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

This fails spectacularly when I issue two queries in a row. Why?

Because the temporary table is temporary within the current database session. The web server process may re-use the same session until something forces it to be closed/reset. Which means that the next call to this API endpoint may be re-using the same session as the previous call to it - and the temporary table is still defined there.

Traceback (most recent call last):
  File "/home/vscode/.venv/lib/python3.9/site-packages/django/db/backends/utils.py", line 87, in _execute
    return self.cursor.execute(sql)

The above exception (relation "temp_macaddrs" already exists
) was the direct cause of the following exception:
  File "/home/vscode/.venv/lib/python3.9/site-packages/django/core/handlers/exception.py", line 55, in inner
    response = get_response(request)
  File "/home/vscode/.venv/lib/python3.9/site-packages/django/core/handlers/base.py", line 197, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/home/vscode/.venv/lib/python3.9/site-packages/django/views/decorators/csrf.py", line 56, in wrapper_view
    return view_func(*args, **kwargs)
  File "/home/vscode/.venv/lib/python3.9/site-packages/django/views/generic/base.py", line 104, in view
    return self.dispatch(request, *args, **kwargs)
  File "/home/vscode/.venv/lib/python3.9/site-packages/rest_framework/views.py", line 515, in dispatch
    response = self.handle_exception(exc)
  File "/home/vscode/.venv/lib/python3.9/site-packages/rest_framework/views.py", line 475, in handle_exception
    self.raise_uncaught_exception(exc)
  File "/home/vscode/.venv/lib/python3.9/site-packages/rest_framework/views.py", line 486, in raise_uncaught_exception
    raise exc
  File "/home/vscode/.venv/lib/python3.9/site-packages/rest_framework/views.py", line 512, in dispatch
    response = handler(request, *args, **kwargs)
  File "/workspaces/nav/python/nav/web/api/v1/views.py", line 1195, in get
    results = get_vendor_names([mac])
  File "/usr/local/lib/python3.9/contextlib.py", line 79, in inner
    return func(*args, **kwds)
  File "/workspaces/nav/python/nav/web/api/v1/views.py", line 1230, in get_vendor_names
    cursor.execute(
  File "/home/vscode/.venv/lib/python3.9/site-packages/django/db/backends/utils.py", line 102, in execute
    return super().execute(sql, params)
  File "/home/vscode/.venv/lib/python3.9/site-packages/django/db/backends/utils.py", line 67, in execute
    return self._execute_with_wrappers(
  File "/home/vscode/.venv/lib/python3.9/site-packages/django/db/backends/utils.py", line 80, in _execute_with_wrappers
    return executor(sql, params, many, context)
  File "/home/vscode/.venv/lib/python3.9/site-packages/django/db/backends/utils.py", line 89, in _execute
    return self.cursor.execute(sql, params)
  File "/home/vscode/.venv/lib/python3.9/site-packages/django/db/utils.py", line 91, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "/home/vscode/.venv/lib/python3.9/site-packages/django/db/backends/utils.py", line 87, in _execute
    return self.cursor.execute(sql)

Exception Type: ProgrammingError at /api/1/vendor
Exception Value: relation "temp_macaddrs" already exists

The temporary table should be explicitly deleted once the query is complete.

Since we're not using an async web server or Django's async stuff, I'm going to assume no other code is using the same session at the same time (otherwise, this might lead to conflicts even when two simultaneous API requests are processed at the same time). You may still want to consider adding a suffix to the temporary table that makes it unique to this specific API request.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Damn, I thought the session was closed and any temporary tables deleted when the function tagged with @django.db.transaction.atomic was completed 😞

Comment on lines 1253 to 1257
cursor.execute(
"""
SELECT mac, vendor FROM temp_macaddrs INNER JOIN oui ON trunc(mac)=oui
"""
)
Copy link
Member

Choose a reason for hiding this comment

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

A potential different way of approaching this is to avoid any use of a temporary table, and instead materialize an in-query/in-memory table to join with:

SELECT mac, vendor
FROM (
    VALUES
        ('aa:bb:cc:dd:ee:ff'::macaddr),
        ('11:22:33:44:55:66'::macaddr),
        ('77:88:99:aa:bb:cc'::macaddr)
) AS temp_macaddrs(mac)
INNER JOIN oui ON trunc(temp_macaddrs.mac) = oui.oui;

But this of course implies that you must validate all the incoming MAC addresses in Python before attempting to build a SQL query from them (no SQL injections, please).

Copy link
Contributor Author

@stveit stveit May 5, 2025

Choose a reason for hiding this comment

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

yeah I would rather let postgres deal with validation of mac addresses, but maybe theres a way to do this with parametrization so you cant sql inject.

Using one query per mac address will prob make this easier I think, but I imagine it will be quite a lot slower when feeding it with a lot of mac addresses

Copy link
Member

Choose a reason for hiding this comment

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

Coercing every incoming mac address to nav.macaddress.MacAddress before using that as input to PostgreSQL shouldn't be too difficult...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

that seems reasonable, ill give it a try

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Implemented these suggestions

response = api_client.get(ENDPOINTS[endpoint])
assert response.status_code == 400


Copy link
Member

Choose a reason for hiding this comment

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

A vendor-endpoint specific fixture would do, I guess :)

stveit added 8 commits May 7, 2025 14:45
request.data seems to be automatically conferted to a list
when sending requests via cURL (and in tests), but when you
use the browseable API request.data is instead a QueryDict
that containst the JSON string.

This adds support for both types, but its abit messy.. I assume
ModelView has something built in that handles this stuff, so maybe
Django has a function that can do this for us? Perhaps a Mixin or
something like that
Copy link

sonarqubecloud bot commented May 9, 2025

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

Successfully merging this pull request may close these issues.

Add new API endpoint to look up OUI vendor from a MAC address
3 participants