Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion netsuite/rest_api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
15 changes: 15 additions & 0 deletions netsuite/rest_api_base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import logging
from functools import cached_property
import re

import httpx
from authlib.integrations.httpx_client import OAuth1Auth
Expand Down Expand Up @@ -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:
Expand Down
123 changes: 123 additions & 0 deletions tests/test_rest_api.py
Original file line number Diff line number Diff line change
@@ -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"}