Skip to content
Merged
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ async with AsyncOFSC(clientID="...", secret="...", companyName="...") as client:
- **Context Manager**: Must be used as an async context manager to properly manage HTTP client lifecycle
- **Simplified API**: Async methods always return Pydantic models (no `response_type` parameter)
- **Request/Response Logging**: Optional httpx event hooks for automatic API call tracing
- **Async Generators**: Lazy pagination helpers that yield individual items across all pages (e.g. `get_all_workzones`, `get_all_resources`) `[Async]`

### Enabling Request/Response Logging

Expand Down
2 changes: 1 addition & 1 deletion docs/ENDPOINTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OFSC API Endpoints Reference

**Version:** 2.24.3
**Version:** 2.25.0
**Last Updated:** 2026-03-05

This document provides a comprehensive reference of all Oracle Field Service Cloud (OFSC) API endpoints and their implementation status in pyOFSC.
Expand Down
48 changes: 48 additions & 0 deletions examples/async_get_all_workzones.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Example: iterate all workzones lazily with get_all_workzones.

This example demonstrates:
- Using the async generator get_all_workzones()
- Lazy pagination — pages are fetched on demand as you iterate
- Collecting results vs. processing one-by-one
"""

import asyncio
from pathlib import Path

from dotenv import load_dotenv

# Load environment variables from .env file
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(dotenv_path=env_path)

from config import Config

from ofsc.async_client import AsyncOFSC


async def main():
async with AsyncOFSC(
clientID=Config.OFSC_CLIENT_ID,
secret=Config.OFSC_CLIENT_SECRET,
companyName=Config.OFSC_COMPANY,
root=Config.OFSC_ROOT,
) as client:
print("Iterating workzones one by one (lazy):")
count = 0
async for workzone in client.metadata.get_all_workzones(limit=10):
print(f" [{count + 1}] {workzone.workZoneLabel}: {workzone.workZoneName}")
count += 1

print(f"\nTotal workzones yielded: {count}")

# You can also collect into a list with an async comprehension
all_labels = [
wz.workZoneLabel
async for wz in client.metadata.get_all_workzones(limit=10)
]
print(f"Labels collected: {all_labels}")


if __name__ == "__main__":
asyncio.run(main())
19 changes: 17 additions & 2 deletions ofsc/async_client/_base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Shared base class for all async OFSC API modules."""

from typing import Type, TypeVar
from typing import Type, TypeVar, Union
from urllib.parse import quote_plus, urljoin

import httpx
Expand All @@ -17,7 +17,7 @@
OFSCServerError,
OFSCValidationError,
)
from ..models import OFSConfig
from ..models import CsvList, OFSConfig

T = TypeVar("T")

Expand Down Expand Up @@ -152,6 +152,21 @@ def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> Non

# region Generic HTTP helpers

@staticmethod
def _to_csv_param(value: Union[CsvList, list[str], str]) -> str:
"""Convert a list, CsvList, or plain string to a CSV string for API query params.

:param value: A CsvList model, a list of strings, or a plain string
:type value: Union[CsvList, list[str], str]
:return: Comma-separated string suitable for an API query parameter
:rtype: str
"""
if isinstance(value, CsvList):
return value.value
if isinstance(value, list):
return ",".join(value)
return value

def _clean_response(self, data: dict) -> dict:
"""Remove the 'links' key from an API response dict.

Expand Down
121 changes: 63 additions & 58 deletions ofsc/async_client/capacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
ShowBookingGridRequest,
ShowBookingGridResponse,
)
from ..capacity import _convert_model_to_api_params


class AsyncOFSCapacity(AsyncClientBase):
Expand Down Expand Up @@ -71,7 +70,20 @@ async def get_available_capacity(
calendarTimeIntervals=calendarTimeIntervals,
fields=fields,
)
params = _convert_model_to_api_params(capacity_request)
params: dict = {
"dates": self._to_csv_param(capacity_request.dates),
"availableTimeIntervals": capacity_request.availableTimeIntervals,
"calendarTimeIntervals": capacity_request.calendarTimeIntervals,
}
if capacity_request.areas is not None:
params["areas"] = self._to_csv_param(capacity_request.areas)
if capacity_request.categories is not None:
params["categories"] = self._to_csv_param(capacity_request.categories)
if capacity_request.aggregateResults is not None:
params["aggregateResults"] = str(capacity_request.aggregateResults).lower()
if capacity_request.fields is not None:
params["fields"] = self._to_csv_param(capacity_request.fields)

url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/capacity")
try:
response = await self._client.get(url, headers=self.headers, params=params)
Expand Down Expand Up @@ -132,7 +144,22 @@ async def get_quota(
returnStatuses=returnStatuses,
timeSlotLevel=timeSlotLevel,
)
params = _convert_model_to_api_params(quota_request)
params: dict = {"dates": self._to_csv_param(quota_request.dates)}
if quota_request.areas is not None:
params["areas"] = self._to_csv_param(quota_request.areas)
if quota_request.categories is not None:
params["categories"] = self._to_csv_param(quota_request.categories)
if quota_request.aggregateResults is not None:
params["aggregateResults"] = str(quota_request.aggregateResults).lower()
if quota_request.categoryLevel is not None:
params["categoryLevel"] = str(quota_request.categoryLevel).lower()
if quota_request.intervalLevel is not None:
params["intervalLevel"] = str(quota_request.intervalLevel).lower()
if quota_request.returnStatuses is not None:
params["returnStatuses"] = str(quota_request.returnStatuses).lower()
if quota_request.timeSlotLevel is not None:
params["timeSlotLevel"] = str(quota_request.timeSlotLevel).lower()

url = urljoin(self.baseUrl, "/rest/ofscCapacity/v2/quota")
try:
response = await self._client.get(url, headers=self.headers, params=params)
Expand Down Expand Up @@ -165,16 +192,12 @@ async def update_quota(
"""
if isinstance(data, dict):
data = QuotaUpdateRequest.model_validate(data)
url = urljoin(self.baseUrl, "/rest/ofscCapacity/v2/quota")
try:
response = await self._client.patch(url, headers=self.headers, json=data.model_dump(exclude_none=True))
response.raise_for_status()
return QuotaUpdateResponse.model_validate(response.json())
except httpx.HTTPStatusError as e:
self._handle_http_error(e, "Failed to update quota")
raise
except httpx.TransportError as e:
raise OFSCNetworkError(f"Network error: {str(e)}") from e
return await self._patch_item(
"/rest/ofscCapacity/v2/quota",
data,
QuotaUpdateResponse,
"Failed to update quota",
)

# Deprecated camelCase alias
getQuota = get_quota
Expand Down Expand Up @@ -232,23 +255,19 @@ async def get_activity_booking_options(
OFSCApiError: For other API errors
OFSCNetworkError: For network/transport errors
"""
params: dict = {}
if isinstance(dates, list):
params["dates"] = ",".join(dates)
else:
params["dates"] = dates
params: dict = {"dates": self._to_csv_param(dates)}
if areas is not None:
params["areas"] = ",".join(areas) if isinstance(areas, list) else areas
params["areas"] = self._to_csv_param(areas)
if activityType is not None:
params["activityType"] = activityType
if duration is not None:
params["duration"] = duration
if workSkills is not None:
params["workSkills"] = ",".join(workSkills) if isinstance(workSkills, list) else workSkills
params["workSkills"] = self._to_csv_param(workSkills)
if timeSlots is not None:
params["timeSlots"] = ",".join(timeSlots) if isinstance(timeSlots, list) else timeSlots
params["timeSlots"] = self._to_csv_param(timeSlots)
if categories is not None:
params["categories"] = ",".join(categories) if isinstance(categories, list) else categories
params["categories"] = self._to_csv_param(categories)
if languageCode is not None:
params["languageCode"] = languageCode
if timeZone is not None:
Expand Down Expand Up @@ -302,8 +321,7 @@ async def get_booking_closing_schedule(
OFSCApiError: For other API errors
OFSCNetworkError: For network/transport errors
"""
params: dict = {}
params["areas"] = ",".join(areas) if isinstance(areas, list) else areas
params: dict = {"areas": self._to_csv_param(areas)}

url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/bookingClosingSchedule")
try:
Expand Down Expand Up @@ -337,16 +355,12 @@ async def update_booking_closing_schedule(
"""
if isinstance(data, dict):
data = BookingClosingScheduleUpdateRequest.model_validate(data)
url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/bookingClosingSchedule")
try:
response = await self._client.patch(url, headers=self.headers, json=data.model_dump(exclude_none=True))
response.raise_for_status()
return BookingClosingScheduleResponse.model_validate(response.json())
except httpx.HTTPStatusError as e:
self._handle_http_error(e, "Failed to update booking closing schedule")
raise
except httpx.TransportError as e:
raise OFSCNetworkError(f"Network error: {str(e)}") from e
return await self._patch_item(
"/rest/ofscCapacity/v1/bookingClosingSchedule",
data,
BookingClosingScheduleResponse,
"Failed to update booking closing schedule",
)

# endregion

Expand All @@ -372,10 +386,9 @@ async def get_booking_statuses(
OFSCApiError: For other API errors
OFSCNetworkError: For network/transport errors
"""
params: dict = {}
params["dates"] = ",".join(dates) if isinstance(dates, list) else dates
params: dict = {"dates": self._to_csv_param(dates)}
if areas is not None:
params["areas"] = ",".join(areas) if isinstance(areas, list) else areas
params["areas"] = self._to_csv_param(areas)

url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/bookingStatuses")
try:
Expand Down Expand Up @@ -409,16 +422,12 @@ async def update_booking_statuses(
"""
if isinstance(data, dict):
data = BookingStatusesUpdateRequest.model_validate(data)
url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/bookingStatuses")
try:
response = await self._client.patch(url, headers=self.headers, json=data.model_dump(exclude_none=True))
response.raise_for_status()
return BookingStatusesResponse.model_validate(response.json())
except httpx.HTTPStatusError as e:
self._handle_http_error(e, "Failed to update booking statuses")
raise
except httpx.TransportError as e:
raise OFSCNetworkError(f"Network error: {str(e)}") from e
return await self._patch_item(
"/rest/ofscCapacity/v1/bookingStatuses",
data,
BookingStatusesResponse,
"Failed to update booking statuses",
)

# endregion

Expand All @@ -445,16 +454,12 @@ async def show_booking_grid(
"""
if isinstance(data, dict):
data = ShowBookingGridRequest.model_validate(data)
url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/showBookingGrid")
try:
response = await self._client.post(url, headers=self.headers, json=data.model_dump(exclude_none=True))
response.raise_for_status()
return ShowBookingGridResponse.model_validate(response.json())
except httpx.HTTPStatusError as e:
self._handle_http_error(e, "Failed to show booking grid")
raise
except httpx.TransportError as e:
raise OFSCNetworkError(f"Network error: {str(e)}") from e
return await self._post_item(
"/rest/ofscCapacity/v1/showBookingGrid",
data,
ShowBookingGridResponse,
"Failed to show booking grid",
)

# endregion

Expand All @@ -480,7 +485,7 @@ async def get_booking_fields_dependencies(
"""
params: dict = {}
if areas is not None:
params["areas"] = ",".join(areas) if isinstance(areas, list) else areas
params["areas"] = self._to_csv_param(areas)

url = urljoin(self.baseUrl, "/rest/ofscCapacity/v1/bookingFieldsDependencies")
try:
Expand Down
51 changes: 51 additions & 0 deletions ofsc/async_client/core/resources.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Async resource methods mixin for OFSCore API."""

from collections.abc import AsyncGenerator
from datetime import date
from typing import Any, Protocol
from urllib.parse import quote_plus, urljoin
Expand Down Expand Up @@ -553,6 +554,56 @@ async def get_resources(
except httpx.TransportError as e:
raise OFSCNetworkError(f"Network error: {str(e)}") from e

async def get_all_resources(
self: _CoreBaseProtocol,
limit: int = 100,
fields: list[str] | None = None,
expand_inventories: bool = False,
expand_workskills: bool = False,
expand_workzones: bool = False,
expand_workschedules: bool = False,
) -> AsyncGenerator[Resource, None]:
"""Async generator that yields all resources one by one, fetching pages on demand.

:param limit: Maximum number of resources to fetch per page (default 100)
:type limit: int
:param fields: List of fields to return
:type fields: list[str] | None
:param expand_inventories: Include resource inventories
:type expand_inventories: bool
:param expand_workskills: Include resource workskills
:type expand_workskills: bool
:param expand_workzones: Include resource workzones
:type expand_workzones: bool
:param expand_workschedules: Include resource workschedules
:type expand_workschedules: bool
:return: Async generator yielding individual Resource objects
:rtype: AsyncGenerator[Resource, None]
:raises OFSCAuthenticationError: If authentication fails (401)
:raises OFSCAuthorizationError: If authorization fails (403)
:raises OFSCApiError: For other API errors
:raises OFSCNetworkError: For network/transport errors
"""
offset = 0
has_more = True

while has_more:
response = await self.get_resources(
offset=offset,
limit=limit,
fields=fields,
expand_inventories=expand_inventories,
expand_workskills=expand_workskills,
expand_workzones=expand_workzones,
expand_workschedules=expand_workschedules,
)
for resource in response.items:
yield resource
has_more = response.hasMore or False
offset += len(response.items)
if len(response.items) == 0:
break

# region Write / Delete Operations

async def create_resource(
Expand Down
Loading