From 353a2c227c0863c86c8bd2412d5be053b87c3766 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 12 Jun 2025 14:42:56 -0700 Subject: [PATCH] Cperish/add id retrieval on create (#1) * Added new REST create_record method which returns the id of the newly created record * Changelog update --- CHANGELOG.md | 1 + netsuite/rest_api.py | 28 ++++++++- netsuite/rest_api_base.py | 15 +++++ tests/test_rest_api.py | 123 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb21c55..d059224 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * **odbc:** add username and password auth ([e01f601](https://github.com/jacobsvante/netsuite/commit/e01f60145df0a168841fbafa1ccb9881d3184fa2)) * **odbc:** adding odbc data source to config ([b5a6a38](https://github.com/jacobsvante/netsuite/commit/b5a6a38c0c7b7a7417608dfaca2a7ce75894f406)) * source username and password from env ([38b1788](https://github.com/jacobsvante/netsuite/commit/38b178856be2bf7bdc3cd1d6de430d6227e44470)) +* add create_record method which returns new record ID ([08aeca4](https://github.com/jacobsvante/netsuite/pull/118/commits/08aeca42ac765a6164421728f2fe78c07147959d)) ### Bug Fixes diff --git a/netsuite/rest_api.py b/netsuite/rest_api.py index 10cde84..b545bc1 100644 --- a/netsuite/rest_api.py +++ b/netsuite/rest_api.py @@ -1,6 +1,6 @@ import logging from functools import cached_property -from typing import Sequence +from typing import Sequence, Dict, Any, Union from . import rest_api_base from .config import Config @@ -50,6 +50,32 @@ async def patch(self, subpath: str, **request_kw): async def delete(self, subpath: str, **request_kw): return await self._request("DELETE", subpath, **request_kw) + async def create_record(self, record_type: str, record_data: Dict[str, Any], **request_kw) -> Union[int, str]: + """ + Create a new record in NetSuite and return its ID. + + Args: + record_type: The type of record to create (e.g., 'customer', 'salesOrder', 'invoice') + record_data: Dictionary containing the record data/fields + **request_kw: Additional keyword arguments to pass to the request + + Returns: + The ID of the newly created record (as int if numeric, str if external ID) + + Example: + >>> customer_data = { + ... "entityid": "New Customer", + ... "companyname": "My Company", + ... "subsidiary": {"id": "1"} + ... } + >>> customer_id = await rest_api.create_record("customer", customer_data) + >>> print(f"Created customer with ID: {customer_id}") + + Documentation: + https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1545141395.html + """ + return await self.post(f"/record/v1/{record_type}", json=record_data, **request_kw) + # TODO maybe break out params vs poping? async def suiteql(self, q: str, limit: int = 10, offset: int = 0, **request_kw): """ diff --git a/netsuite/rest_api_base.py b/netsuite/rest_api_base.py index 23dc43f..bafc69e 100644 --- a/netsuite/rest_api_base.py +++ b/netsuite/rest_api_base.py @@ -1,6 +1,7 @@ import asyncio import logging from functools import cached_property +import re import httpx from authlib.integrations.httpx_client import OAuth1Auth @@ -45,6 +46,20 @@ async def _request(self, method: str, subpath: str, **request_kw): raise NetsuiteAPIRequestError(resp.status_code, resp.text) if resp.status_code == 204: + # For POST requests that create records, NetSuite returns 204 with Location header + # containing the URL of the newly created record. Extract the ID from it. + if method.upper() == "POST" and "location" in resp.headers: + location = resp.headers["location"] + # Extract ID from URL like: https://demo123.suitetalk.api.netsuite.com/services/rest/record/v1/customer/647 + match = re.search(r'/([^/]+)$', location) + if match: + record_id = match.group(1) + try: + # Try to convert to int if it's a numeric ID + return int(record_id) + except ValueError: + # Return as string if it's not numeric (e.g., external ID) + return record_id return None else: try: diff --git a/tests/test_rest_api.py b/tests/test_rest_api.py index 39f1bd6..5bc3869 100644 --- a/tests/test_rest_api.py +++ b/tests/test_rest_api.py @@ -1,6 +1,129 @@ +import pytest +from unittest.mock import AsyncMock, Mock +import httpx + from netsuite import NetSuiteRestApi def test_expected_hostname(dummy_config): rest_api = NetSuiteRestApi(dummy_config) assert rest_api.hostname == "123456-sb1.suitetalk.api.netsuite.com" + + +@pytest.mark.asyncio +async def test_post_returns_record_id_from_location_header(dummy_config): + """Test that POST requests return the record ID extracted from Location header""" + rest_api = NetSuiteRestApi(dummy_config) + + # Mock the response with 204 status and Location header + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 204 + mock_response.headers = { + "location": "https://123456-sb1.suitetalk.api.netsuite.com/services/rest/record/v1/customer/647" + } + mock_response.text = "" + + # Mock the _request_impl method to return our mock response + rest_api._request_impl = AsyncMock(return_value=mock_response) + + # Test the post method + result = await rest_api.post("/record/v1/customer", json={"entityid": "Test Customer"}) + + # Should return the extracted ID as an integer + assert result == 647 + assert isinstance(result, int) + + +@pytest.mark.asyncio +async def test_post_returns_external_id_from_location_header(dummy_config): + """Test that POST requests return external IDs as strings when they're not numeric""" + rest_api = NetSuiteRestApi(dummy_config) + + # Mock the response with 204 status and Location header containing external ID + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 204 + mock_response.headers = { + "location": "https://123456-sb1.suitetalk.api.netsuite.com/services/rest/record/v1/customer/eid:CUST001" + } + mock_response.text = "" + + rest_api._request_impl = AsyncMock(return_value=mock_response) + + result = await rest_api.post("/record/v1/customer", json={"entityid": "Test Customer"}) + + # Should return the external ID as a string + assert result == "eid:CUST001" + assert isinstance(result, str) + + +@pytest.mark.asyncio +async def test_create_record_convenience_method(dummy_config): + """Test the create_record convenience method""" + rest_api = NetSuiteRestApi(dummy_config) + + # Mock the response + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 204 + mock_response.headers = { + "location": "https://123456-sb1.suitetalk.api.netsuite.com/services/rest/record/v1/customer/123" + } + mock_response.text = "" + + rest_api._request_impl = AsyncMock(return_value=mock_response) + + customer_data = { + "entityid": "New Customer", + "companyname": "My Company", + "subsidiary": {"id": "1"} + } + + result = await rest_api.create_record("customer", customer_data) + + # Should return the extracted ID + assert result == 123 + + # Verify the correct endpoint was called + rest_api._request_impl.assert_called_once() + call_args = rest_api._request_impl.call_args + assert call_args[0][0] == "POST" # method + assert call_args[0][1] == "/record/v1/customer" # subpath + + +@pytest.mark.asyncio +async def test_post_without_location_header_returns_none(dummy_config): + """Test that POST requests return None when there's no Location header""" + rest_api = NetSuiteRestApi(dummy_config) + + # Mock the response with 204 status but no Location header + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 204 + mock_response.headers = {} + mock_response.text = "" + + rest_api._request_impl = AsyncMock(return_value=mock_response) + + result = await rest_api.post("/some/other/endpoint", json={"data": "test"}) + + # Should return None when no Location header is present + assert result is None + + +@pytest.mark.asyncio +async def test_get_request_unaffected_by_changes(dummy_config): + """Test that non-POST requests are unaffected by the Location header logic""" + rest_api = NetSuiteRestApi(dummy_config) + + # Mock the response with 200 status and JSON content + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.text = '{"id": 123, "entityid": "Test Customer"}' + mock_response.headers = { + "location": "https://123456-sb1.suitetalk.api.netsuite.com/services/rest/record/v1/customer/647" + } + + rest_api._request_impl = AsyncMock(return_value=mock_response) + + result = await rest_api.get("/record/v1/customer/123") + + # Should return the parsed JSON, not the location header + assert result == {"id": 123, "entityid": "Test Customer"}