Repository: planetlabs/planet-client-python
Pull request #290 (2021-06-22 23:33:01)

V2: change order_details objects from classes to functions, add support for specific tools
## Summary
1. Changes `order_details` objects from classes to functions that return dicts. This greatly decreases the amount of code required to achieve building up a request for creating an order.


2. Further, no longer imports the objects at the top level. This is to avoid naming conflicts with the new functions (this was less of a concern when the objects were classes)

e.g. naming conflict: avoid 
```python
from planet import product

product = product(['20170614_113217_3163208_RapidEye-5'], 'analytic', 'REOrthoTile') # uh oh!
```

less chance of a naming conflict: better?
```python
from planet import order_details

product = order_details.product(['20170614_113217_3163208_RapidEye-5'], 'analytic', 'REOrthoTile') # we are good!
```

3. Also adds support for all of the tools supported in the Orders API (e.g. `toar_tool()`, which creates the description for a TOAR tool from arguments)

## Usability impact:

Context - use of `order_details` in `OrdersClient`:
```python
>>> async def main():
...     async with Session() as sess:
...         cl = OrdersClient(sess)
...         order_id = await cl.create_order(order_detail)
...
>>> asyncio.run(main())

```

before - 
```python
>>> from planet import OrderDetails, Product, Tool
>>>
>>> image_ids = ['20170614_113217_3163208_RapidEye-5']
>>> order_detail = OrderDetails(
...     'test_order',
...     [Product(image_ids, 'analytic', 'REOrthoTile')],
...     [Tool('toar', {'scale_factor': 10000}),
...      Tool('reproject', {'projection': 'WGS84', kernel: 'cubic'}),
...      Tool('tile', {'origin_x: -180, 'origin_y: -90, 'pixel_size': 0.000027056277056,
...                         'name_template': 'C1232_30_30_{tilex:04d}_{tiley:04d}'})
... )
...
```

after - 
```python
>>> from planet.api.order_details import (
...     build_request, product, toar_tool, reproject_tool, tile_tool)
...
>>> products = [
...     product(['20170614_113217_3163208_RapidEye-5'], 'analytic', 'REOrthoTile')])
... ]
...
>>> tools = [
...     toar_tool(scale_factor=10000),
...     reproject_tool(projection='WSG84', kernel='cubic'),
...     tile_tool(1232, origin_x=-180, origin_y=-90,
...               pixel_size=0.000027056277056,
...               name_template='C1232_30_30_{tilex:04d}_{tiley:04d}')
... ]
...
>>> order_request = build_request(
...     'test_order', products, tools)
...

```
Addresses #254 


Patch:
diff --git a/README.md b/README.md
index 40c96c6..1f06238 100644
--- a/README.md
+++ b/README.md
@@ -35,18 +35,25 @@ Let's start with creating an order with the Orders API:
 >>> import os
 >>> import planet
 >>>
->>> image_ids = ['3949357_1454705_2020-12-01_241c']
->>> order_details = planet.OrderDetails(
-...     'test_order',
-...     [planet.Product(image_ids, 'analytic', 'psorthotile')]
-... )
->>>
->>> async def create_order(order_details):
+>>> request = {
+...   "name": "test_order",
+...   "products": [
+...     {
+...       "item_ids": [
+...         "3949357_1454705_2020-12-01_241c"
+...       ],
+...       "item_type": "PSOrthoTile",
+...       "product_bundle": "analytic"
+...     }
+...   ]
+... }
+...
+>>> async def create_order(request):
 ...     async with planet.Session() as ps:
 ...         client = planet.OrdersClient(ps)
-...         return await client.create_order(order_details)
->>>
->>> oid = asyncio.run(create_order(order_details))
+...         return await client.create_order(request)
+...
+>>> oid = asyncio.run(create_order(request))
 
 ```
 
diff --git a/docs/api.md b/docs/api.md
index a78c92b..33795d4 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -12,24 +12,8 @@
     rendering:
       show_root_full_path: false
 
-## order_details 
-
-### ::: planet.api.order_details.OrderDetails
-
-### ::: planet.api.order_details.Product
-
-### ::: planet.api.order_details.Notifications
-
-### ::: planet.api.order_details.Delivery
-
-### ::: planet.api.order_details.AmazonS3Delivery
-
-### ::: planet.api.order_details.AzureBlobStorageDelivery
-
-### ::: planet.api.order_details.GoogleCloudStorageDelivery
-
-### ::: planet.api.order_details.GoogleEarthEngineDelivery
-
-### ::: planet.api.order_details.Tool
+## ::: planet.order_details
+    rendering:
+      show_root_full_path: false
 
 
diff --git a/docs/guide.md b/docs/guide.md
index e660dce..90421b2 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -109,36 +109,69 @@ order is completed and to download an entire order.
 
 #### Creating an Order
 
-When creating an order, the order details must be provided to the API. There
-are two ways to specify the order details, a `JSON` blob and an `OrderDetails`
-instance.
+When creating an order, the order details must be provided to the API as a JSON
+blob. This JSON blob can be built up manually or by using the `build_request`
+function.
 
-An `OrderDetails` instance is built up from instances of `Product`, `Tool`, 
-`Delivery`, and `Notification` alongside other, simple parameters.
-
-An example of creating an `OrderDetails` instance:
+An example of creating the request JSON with `build_request`:
 
 ```python
->>> from planet.api.order_details import OrderDetails, Product
->>>
->>> image_ids = ['3949357_1454705_2020-12-01_241c']
->>> order_detail = OrderDetails(
-...     'test_order',
-...     [Product(image_ids, 'analytic', 'psorthotile')]
-... )
+>>> from planet import order_details 
+>>> products = [
+...     order_details.product(['20170614_113217_3163208_RapidEye-5'],
+...                           'analytic', 'REOrthoTile')
+... ]
+...
+>>> tools = [
+...     order_details.toar_tool(scale_factor=10000),
+...     order_details.reproject_tool(projection='WSG84', kernel='cubic'),
+...     order_details.tile_tool(1232, origin_x=-180, origin_y=-90,
+...               pixel_size=0.000027056277056,
+...               name_template='C1232_30_30_{tilex:04d}_{tiley:04d}')
+... ]
+...
+>>> order_request = order_details.build_request(
+...     'test_order', products, tools)
 ...
 
 ```
 
-The same thing, expressed as a `JSON` blob (pro tip: this can be obtained
-with `order_detail.json`):
+The same thing, expressed as a `JSON` blob:
 
 ```python
 >>> order_detail = {
-...     'name': 'test_order',
-...     'products': [{'item_ids': ['3949357_1454705_2020-12-01_241c'],
-...                   'item_type': 'PSOrthoTile',
-...                   'product_bundle': 'analytic'}],
+...   "name": "test_order",
+...   "products": [
+...     {
+...       "item_ids": [
+...         "20170614_113217_3163208_RapidEye-5"
+...       ],
+...       "item_type": "REOrthoTile",
+...       "product_bundle": "analytic"
+...     }
+...   ],
+...   "tools": [
+...     {
+...       "toar": {
+...         "scale_factor": 10000
+...       }
+...     },
+...     {
+...       "reproject": {
+...         "projection": "WSG84",
+...         "kernel": "cubic"
+...       }
+...     },
+...     {
+...       "tile": {
+...         "tile_size": 1232,
+...         "origin_x": -180,
+...         "origin_y": -90,
+...         "pixel_size": 2.7056277056e-05,
+...         "name_template": "C1232_30_30_{tilex:04d}_{tiley:04d}"
+...       }
+...     }
+...   ]
 ... }
 
 ```
diff --git a/docs/index.md b/docs/index.md
index 4d35af8..cc16170 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -29,18 +29,25 @@ Let's start with creating an order with the Orders API:
 >>> import os
 >>> import planet
 >>>
->>> image_ids = ['3949357_1454705_2020-12-01_241c']
->>> order_details = planet.OrderDetails(
-...     'test_order',
-...     [planet.Product(image_ids, 'analytic', 'psorthotile')]
-... )
->>>
->>> async def create_order(order_details):
+>>> request = {
+...   "name": "test_order",
+...   "products": [
+...     {
+...       "item_ids": [
+...         "3949357_1454705_2020-12-01_241c"
+...       ],
+...       "item_type": "PSOrthoTile",
+...       "product_bundle": "analytic"
+...     }
+...   ]
+... }
+...
+>>> async def create_order(request):
 ...     async with planet.Session() as ps:
 ...         client = planet.OrdersClient(ps)
-...         return await client.create_order(order_details)
->>>
->>> oid = asyncio.run(create_order(order_details))
+...         return await client.create_order(request)
+...
+>>> oid = asyncio.run(create_order(request))
 
 ```
 
diff --git a/planet/__init__.py b/planet/__init__.py
index f770f14..4cfdb9d 100644
--- a/planet/__init__.py
+++ b/planet/__init__.py
@@ -14,10 +14,7 @@
 from .api.http import Session
 from .api.models import Order
 from .api.orders import OrdersClient
-from .api.order_details import (
-    OrderDetails, Product, Notifications, Delivery, AmazonS3Delivery,
-    AzureBlobStorageDelivery, GoogleCloudStorageDelivery,
-    GoogleEarthEngineDelivery, Tool, ClipTool)
+from .api import order_details
 from .api.__version__ import __version__  # NOQA
 from .auth import Auth
 
@@ -26,15 +23,6 @@ __all__ = [
     Session,
     OrdersClient,
     Order,
-    OrderDetails,
-    Product,
-    Notifications,
-    Delivery,
-    AmazonS3Delivery,
-    AzureBlobStorageDelivery,
-    GoogleCloudStorageDelivery,
-    GoogleEarthEngineDelivery,
-    ClipTool,
-    Tool,
+    order_details,
     Auth,
 ]
diff --git a/planet/api/order_details.py b/planet/api/order_details.py
index a9aa919..a08f517 100644
--- a/planet/api/order_details.py
+++ b/planet/api/order_details.py
@@ -13,722 +13,445 @@
 # the License.
 """Functionality for preparing order details for use in creating an order"""
 from __future__ import annotations  # https://stackoverflow.com/a/33533514
-import copy
-import json
 import logging
-from typing import List, Union
+from typing import List
 
 from .. import geojson, specs
 
 LOGGER = logging.getLogger(__name__)
 
 
-class OrderDetailsException(Exception):
-    """Exceptions thrown by OrderDetails"""
-    pass
-
-
-class OrderDetails():
-    '''Validating and preparing order details for submission.
-
-    Can be built up from order detail parts:
+def build_request(
+        name: str,
+        products: List[dict],
+        subscription_id: int = 0,
+        delivery: dict = None,
+        notifications: dict = None,
+        order_type: str = None,
+        tools: List[dict] = None
+) -> dict:
+    '''Prepare an order request.
 
     ```python
-    >>> from planet.api.order_details import OrderDetails, Product
-    >>>
-    >>> image_ids = ['3949357_1454705_2020-12-01_241c']
-    >>> order_detail = OrderDetails(
-    ...     'test_order',
-    ...     [Product(image_ids, 'analytic', 'psorthotile')]
-    ... )
+    >>> from planet.api.order_details import (
+    ...     build_request, product, toar_tool, reproject_tool, tile_tool)
+    ...
+    >>> products = [
+    ...     product(['20170614_113217_3163208_RapidEye-5'],
+    ...             'analytic', 'REOrthoTile')])
+    ... ]
+    ...
+    >>> tools = [
+    ...     toar_tool(scale_factor=10000),
+    ...     reproject_tool(projection='WSG84', kernel='cubic'),
+    ...     tile_tool(1232, origin_x=-180, origin_y=-90,
+    ...               pixel_size=0.000027056277056,
+    ...               name_template='C1232_30_30_{tilex:04d}_{tiley:04d}')
+    ... ]
+    ...
+    >>> order_request = build_request(
+    ...     'test_order', products, tools)
     ...
 
     ```
 
-    or from a dict describing the order detail:
+    Parameters:
+        name: Name of the order.
+        products: Product(s) from the Data API to order.
+        subscription_id: Apply this orders against this quota subscription.
+        delivery: Specify custom delivery handling.
+        notifications: Specify custom notifications handling.
+        order_type: Accept a partial order, indicated by 'partial'.
+        tools: Tools to apply to the products. Order defines
+            the toolchain order of operatations.
+
+    Raises:
+        planet.specs.SpecificationException: If order_type is not a valid
+            order type.
+    '''
+    details = {
+        'name': name,
+        'products': products
+    }
+
+    if subscription_id:
+        details['subscription_id'] = subscription_id
+
+    if delivery:
+        details['delivery'] = delivery
+
+    if notifications:
+        details['notifications'] = notifications
+
+    if order_type:
+        order_type = specs.validate_order_type(order_type)
+        details['order_type'] = order_type
+
+    if tools:
+        details['tools'] = tools
+
+    return details
+
+
+def product(
+    item_ids: List[str],
+    product_bundle: str,
+    item_type: str,
+    fallback_bundle: str = None
+) -> dict:
+    '''Product description for an order detail.
+
+    Parameters:
+        item_ids: IDs of the catalog items to include in the order.
+        product_bundle: Set of asset types for the catalog items.
+        item_type: The class of spacecraft and processing characteristics
+            for the catalog items.
+        fallback_bundle: In case product_bundle not having
+            all asset types available, which would result in failed
+            delivery, try a fallback bundle
+
+    Raises:
+        planet.specs.SpecificationException: If bundle or fallback bundle
+            are not valid bundles or if item_type is not valid for the given
+            bundle or fallback bundle.
+    '''
+    product_bundle = specs.validate_bundle(product_bundle)
+    item_type = specs.validate_item_type(item_type, product_bundle)
+
+    if fallback_bundle is not None:
+        fallback_bundle = specs.validate_bundle(fallback_bundle)
+        specs.validate_item_type(item_type, fallback_bundle)
+        product_bundle = ','.join([product_bundle, fallback_bundle])
+
+    product_dict = {
+        'item_ids': item_ids,
+        'item_type': item_type,
+        'product_bundle': product_bundle
+    }
+    return product_dict
+
+
+def notifications(
+    email: bool = None,
+    webhook_url: str = None,
+    webhook_per_order: bool = None
+) -> dict:
+    '''Notifications description for an order detail.
+
+    Parameters:
+        email: Enable email notifications for an order.
+        webhook_url: URL for notification when the order is ready.
+        webhook_per_order: Request a single webhook call per order instead
+            of one call per each delivered item.
+    '''
+    return dict((k, v) for k, v in locals().items() if v)
+
+
+def delivery(
+    archive_type: str = None,
+    single_archive: bool = False,
+    archive_filename: str = None,
+    cloud_config: dict = None
+) -> dict:
+    '''Order delivery configuration.
+
+    Example:
+        ```python
+        amazon_s3_config = amazon_s3(
+            'access_key',
+            'secret_access_key',
+            'bucket_name',
+            'us-east-2',
+            'folder1/prefix/'
+        )
+        delivery_config = delivery(
+            archive_type='zip',
+            single_archive=True,
+            archive_filename='{{order_id}}.zip'
+            cloud_config=amazon_s3_config
+        )
+        ```
+
+    Parameters:
+        archive_type: Archive order files. Only supports 'zip'.
+        single_archive: Archive all bundles together in a single file.
+        archive_filename: Custom naming convention to use to name the
+            archive file that is received. Uses the template variables
+            {{name}} and {{order_id}}. e.g. "{{name}}_{{order_id}}.zip".
+        cloud_config: Cloud delivery configuration.
+
+    Raises:
+        planet.specs.SpecificationException: If archive_type is not valid.
+    '''
+    if archive_type:
+        archive_type = specs.validate_archive_type(archive_type)
+
+    fields = ['archive_type', 'single_archive', 'archive_filename']
+    values = [archive_type, single_archive, archive_filename]
+
+    config = dict((k, v) for k, v in zip(fields, values) if v)
+
+    if cloud_config:
+        config.update(cloud_config)
+    return config
+
+
+def amazon_s3(
+    aws_access_key_id: str,
+    aws_secret_access_key: str,
+    bucket: str,
+    aws_region: str,
+    path_prefix: str = None
+) -> dict:
+    '''Amazon S3 Cloud configuration.
+
+    Parameters:
+        aws_access_key_id: S3 account access key.
+        aws_secret_access_key: S3 account secret key.
+        bucket: The name of the bucket that will receive the order output.
+        aws_region: The region where the bucket lives in AWS.
+        path_prefix: Custom string to prepend to the files delivered to the
+            bucket. A slash (/) character will be treated as a "folder".
+            Any other characters will be added as a prefix to the files.
+    '''
+    cloud_details = {
+        'aws_access_key_id': aws_access_key_id,
+        'aws_secret_access_key': aws_secret_access_key,
+        'bucket': bucket,
+        'aws_region': aws_region,
+    }
+
+    if path_prefix:
+        cloud_details['path_prefix'] = path_prefix
+
+    return {'amazon_s3': cloud_details}
+
+
+def azure_blob_storage(
+    account: str,
+    container: str,
+    sas_token: str,
+    storage_endpoint_suffix: str = None,
+    path_prefix: str = None
+) -> dict:
+    '''Azure Blob Storage configuration.
+
+    Parameters:
+        account: Azure account.
+        container: ABS container name.
+        sas_token: Shared-Access Signature token. Token should be specified
+            without a leading '?'.
+        storage_endpoint_suffix: Deliver order to a sovereign cloud.
+        path_prefix: Custom string to prepend to the files delivered to the
+            bucket. A slash (/) character will be treated as a "folder".
+            Any other characters will be added as a prefix to the files.
+    '''
+    cloud_details = {
+        'account': account,
+        'container': container,
+        'sas_token': sas_token,
+    }
+
+    if storage_endpoint_suffix:
+        cloud_details['storage_endpoint_suffix'] = storage_endpoint_suffix
+
+    if path_prefix:
+        cloud_details['path_prefix'] = path_prefix
+
+    return {'azure_blob_storage': cloud_details}
+
+
+def google_cloud_storage(
+    bucket: str,
+    credentials: str,
+    path_prefix: str = None
+) -> dict:
+    '''Google Cloud Storage configuration.
+
+    Parameters:
+        bucket: GCS bucket name.
+        credentials: JSON-string of service account for bucket.
+        path_prefix: Custom string to prepend to the files delivered to the
+            bucket. A slash (/) character will be treated as a "folder".
+            Any other characters will be added as a prefix to the files.
+    '''
+    cloud_details = {
+        'bucket': bucket,
+        'credentials': credentials,
+    }
 
-    ```python
-    >>> order_detail_json = {
-    ...     'name': 'test_order',
-    ...     'products': [{'item_ids': ['3949357_1454705_2020-12-01_241c'],
-    ...                   'item_type': 'PSOrthoTile',
-    ...                   'product_bundle': 'analytic'}],
-    ... }
-    ...
-    >>> order_detail = OrderDetails.from_dict(order_detail_json)
+    if path_prefix:
+        cloud_details['path_prefix'] = path_prefix
 
-    ```
+    return {'google_cloud_storage': cloud_details}
+
+
+def google_earth_engine(
+    project: str,
+    collection: str
+) -> dict:
+    '''Google Earth Engine configuration.
+
+    Parameters:
+        project: GEE project name.
+        collection: GEE Image Collection name.
     '''
-    def __init__(
-        self,
-        name: str,
-        products: List[Product],
-        subscription_id: int = 0,
-        delivery: Delivery = None,
-        notifications: Notifications = None,
-        order_type: str = None,
-        tools: List[Tool] = None
-    ):
-        """
-        Parameters:
-            name: Name of the order.
-            products: Product(s) from the Data API to order.
-            subscription_id: Apply this orders against this quota subscription.
-            delivery: Specify custom delivery handling.
-            notifications: Specify custom notifications handling.
-            order_type: Accept a partial order, indicated by 'partial'.
-            tools: Tools to apply to the products. Order defines
-                the toolchain order of operatations.
-        """
-        self.name = name
-        self.products = products
-        self.subscription_id = subscription_id
-        self.delivery = delivery
-        self.notifications = notifications
-        self.order_type = order_type
-        self.tools = tools
-
-        if self.order_type is not None:
-            self.order_type = specs.validate_order_type(order_type)
-
-    def __eq__(self, other):
-        return self.to_dict() == other.to_dict()
-
-    def __repr__(self):
-        return str(self.to_dict())
-
-    def __str__(self):
-        return json.dumps(self.to_dict(), indent=4, sort_keys=True)
-
-    @classmethod
-    def from_dict(cls, details: dict) -> OrderDetails:
-        """Create OrderDetails instance from Orders API spec representation.
-
-        Parameters:
-            details: API spec representation of OrderDetails.
-
-        Returns:
-            OrderDetails instance
-        """
-        name = details['name']
-        products = [Product.from_dict(p) for p in details['products']]
-
-        subscription_id = details.get('subscription_id', None)
-
-        delivery = details.get('delivery', None)
-        if delivery:
-            delivery = Delivery.from_dict(delivery)
-
-        notifications = details.get('notifications', None)
-        if notifications:
-            notifications = Notifications.from_dict(notifications)
-
-        order_type = details.get('order_type', None)
-        tools = [Tool.from_dict(t) for t in details.get('tools', [])]
-
-        return cls(name,
-                   products,
-                   subscription_id,
-                   delivery,
-                   notifications,
-                   order_type,
-                   tools)
-
-    def to_dict(self) -> dict:
-        """Get Orders API spec representation.
-
-        Returns:
-            API spec representation of OrderDetails.
-        """
-        details = {
-            'name': self.name,
-            'products': [p.to_dict() for p in self.products]
-        }
-
-        if self.subscription_id is not None:
-            details['subscription_id'] = self.subscription_id
-
-        if self.delivery is not None:
-            details['delivery'] = self.delivery.to_dict()
-
-        if self.notifications is not None:
-            details['notifications'] = self.notifications.to_dict()
-
-        if self.order_type is not None:
-            details['order_type'] = self.order_type
-
-        if self.tools is not None:
-            details['tools'] = [t.to_dict() for t in self.tools]
-
-        return details
-
-    @property
-    def json(self) -> str:
-        '''Order details as a string representing json.'''
-        return json.dumps(self.to_dict())
-
-
-class Product():
-    '''Product description for an order detail.'''
-
-    def __init__(
-        self,
-        item_ids: List[str],
-        product_bundle: str,
-        item_type: str,
-        fallback_bundle: str = None
-    ):
-        """
-        Parameters:
-            item_ids: IDs of the catalog items to include in the order.
-            product_bundle: Set of asset types for the catalog items.
-            item_type: The class of spacecraft and processing characteristics
-                for the catalog items.
-            fallback_bundle: In case product_bundle not having
-                all asset types available, which would result in failed
-                delivery, try a fallback bundle
-        """
-        self.item_ids = item_ids
-        self.product_bundle = specs.validate_bundle(product_bundle)
-
-        if fallback_bundle is not None:
-            self.fallback_bundle = specs.validate_bundle(fallback_bundle)
-        else:
-            self.fallback_bundle = None
-
-        self.item_type = specs.validate_item_type(item_type, product_bundle)
-        if fallback_bundle is not None:
-            specs.validate_item_type(item_type, fallback_bundle)
-
-    @classmethod
-    def from_dict(cls, details: dict) -> Product:
-        """Create Product instance from Orders API spec representation.
-
-        Parameters:
-            details: API spec representation of product.
-
-        Returns:
-            Product instance
-        """
-        bundles = details['product_bundle'].split(',')
-        product_bundle = bundles[0]
-        try:
-            fallback_bundle = bundles[1]
-        except IndexError:
-            fallback_bundle = None
-
-        return cls(details['item_ids'],
-                   product_bundle,
-                   details['item_type'],
-                   fallback_bundle)
-
-    def to_dict(self) -> dict:
-        """Get Orders API spec representation.
-
-        Returns:
-            API spec representation of product.
-        """
-        product_bundle = self.product_bundle
-        if self.fallback_bundle is not None:
-            product_bundle = ','.join([product_bundle, self.fallback_bundle])
-        product_dict = {
-            'item_ids': self.item_ids,
-            'item_type': self.item_type,
-            'product_bundle': product_bundle
-        }
-        return product_dict
-
-
-class Notifications():
-    '''Notifications description for an order detail.'''
-    def __init__(
-        self,
-        email: bool = False,
-        webhook_url: str = None,
-        webhook_per_order: bool = False
-    ):
-        """
-        Parameters:
-            email: Enable email notifications for an order.
-            webhook_url: URL for notification when the order is ready.
-            webhook_per_order: Request a single webhook call per order instead
-                of one call per each delivered item.
-        """
-        self.email = email
-        self.webhook_url = webhook_url
-        self.webhook_per_order = webhook_per_order
-
-    @classmethod
-    def from_dict(cls, details: dict) -> Notifications:
-        """Create Notifications instance from Orders API spec representation.
-
-        Parameters:
-            details: API spec representation of notifications.
-
-        Returns:
-            Notifications instance
-        """
-        return cls(**details)
-
-    def to_dict(self) -> dict:
-        """Get Orders API spec representation.
-
-        Returns:
-            API spec representation of notifications.
-        """
-        details = {}
-
-        if self.email:
-            details['email'] = self.email
-
-        if self.webhook_url is not None:
-            details['webhook_url'] = self.webhook_url
-
-        if self.webhook_per_order:
-            details['webhook_per_order'] = True
-
-        return details
-
-
-class Delivery():
-    '''Manages order detail delivery description.'''
-    def __init__(
-        self,
-        archive_type: str = None,
-        single_archive: bool = False,
-        archive_filename: str = None
-    ):
-        """
-        Parameters:
-            archive_type: Archive order files. Only supports 'zip'.
-            single_archive: Archive all bundles together in a single file.
-            archive_filename: Custom naming convention to use to name the
-                archive file that is received. Uses the template variables
-                {{name}} and {{order_id}}. e.g. "{{name}}_{{order_id}}.zip".
-        """
-        if archive_type:
-            self.archive_type = specs.validate_archive_type(archive_type)
-        else:
-            self.archive_type = archive_type
-
-        self.single_archive = single_archive
-        self.archive_filename = archive_filename
-
-    @classmethod
-    def from_dict(
-        cls,
-        details: dict,
-        subclass: bool = True
-        ) -> Union[
-                Delivery,
-                AmazonS3Delivery,
-                AzureBlobStorageDelivery,
-                GoogleCloudStorageDelivery,
-                GoogleEarthEngineDelivery]:
-        """Create Delivery instance from Orders API spec representation.
-
-        Parameters:
-            details: API spec representation of delivery.
-            subclass: Create a subclass of Delivery if the necessary
-                information is provided.
-
-        Returns:
-            Delivery or Delivery subclass instance
-        """
-        def _create_subclass(details):
-            subclasses = [
-                AmazonS3Delivery,
-                AzureBlobStorageDelivery,
-                GoogleCloudStorageDelivery,
-                GoogleEarthEngineDelivery
-                ]
-            created = False
-            for cls in subclasses:
-                try:
-                    created = cls.from_dict(details)
-                except KeyError:
-                    pass
-                else:
-                    break
-            return created
-
-        created = _create_subclass(details) or cls._from_dict(details)
-        return created
-
-    @classmethod
-    def _from_dict(cls, details: dict) -> Delivery:
-        """Create Delivery instance from Orders API spec representation.
-
-        Parameters:
-            details: API spec representation of delivery.
-
-        Returns:
-            Delivery instance
-        """
-        return cls(**details)
-
-    def to_dict(self) -> dict:
-        """Get Orders API spec representation.
-
-        Returns:
-            API spec representation of delivery.
-        """
-        details = {}
-
-        if self.archive_type:
-            details['archive_type'] = self.archive_type
-
-        if self.single_archive:
-            details['single_archive'] = self.single_archive
-
-        if self.archive_filename:
-            details['archive_filename'] = self.archive_filename
-
-        return details
-
-
-def _get_cloud_details(details, cloud_key):
-    details = copy.deepcopy(details)
-    cloud_details = details.pop(cloud_key)
-    cloud_details.update(details)
-    LOGGER.debug(f'cloud section of details: {cloud_details}')
-    return cloud_details
-
-
-class AmazonS3Delivery(Delivery):
-    '''Amazon S3 delivery description for an order detail.'''
-
-    cloud_key = 'amazon_s3'
-
-    def __init__(
-        self,
-        aws_access_key_id: str,
-        aws_secret_access_key: str,
-        bucket: str,
-        aws_region: str,
-        path_prefix: str = None,
-        archive_type: str = False,
-        single_archive: bool = False,
-        archive_filename: str = None
-    ):
-        """
-        Parameters:
-            aws_access_key_id: S3 account access key.
-            aws_secret_access_key: S3 account secret key.
-            bucket: The name of the bucket that will receive the order output.
-            aws_region: The region where the bucket lives in AWS.
-            path_prefix: Custom string to prepend to the files delivered to the
-                bucket. A slash (/) character will be treated as a "folder".
-                Any other characters will be added as a prefix to the files.
-            archive_type: Archive order files. Only supports 'zip'.
-            single_archive: Archive all bundles together in a single file.
-            archive_filename: Custom naming convention to use to name the
-                archive file that is received. Uses the template variables
-                {{name}} and {{order_id}}. e.g. "{{name}}_{{order_id}}.zip".
-        """
-        self.aws_access_key_id = aws_access_key_id
-        self.aws_secret_access_key = aws_secret_access_key
-        self.aws_region = aws_region
-        self.bucket = bucket
-        self.path_prefix = path_prefix
-
-        super().__init__(archive_type, single_archive, archive_filename)
-
-    @classmethod
-    def from_dict(cls, details: dict) -> AmazonS3Delivery:
-        """Create AmazonS3Delivery instance from Orders API spec representation.
-
-        Parameters:
-            details: API spec representation of delivery.
-
-        Returns:
-            AmazonS3Delivery instance
-        """
-        cloud_details = _get_cloud_details(details, cls.cloud_key)
-        return cls(**cloud_details)
-
-    def to_dict(self) -> dict:
-        """Get Orders API spec representation.
-
-        Returns:
-            API spec representation of AmazonS3Delivery.
-        """
-        cloud_details = {
-            'aws_access_key_id': self.aws_access_key_id,
-            'aws_secret_access_key': self.aws_secret_access_key,
-            'bucket': self.bucket,
-            'aws_region': self.aws_region,
-        }
-
-        if self.path_prefix:
-            cloud_details['path_prefix'] = self.path_prefix
-
-        details = super().to_dict()
-        details[self.cloud_key] = cloud_details
-        return details
-
-
-class AzureBlobStorageDelivery(Delivery):
-    '''Azure Blob Storage delivery description for an order detail.'''
-
-    cloud_key = 'azure_blob_storage'
-
-    def __init__(
-        self,
-        account: str,
-        container: str,
-        sas_token: str,
-        storage_endpoint_suffix: str = None,
-        path_prefix: str = None,
-        archive_type: str = False,
-        single_archive: bool = False,
-        archive_filename: str = None
-    ):
-        """
-        Parameters:
-            account: Azure account.
-            container: ABS container name.
-            sas_token: Shared-Access Signature token. Token should be specified
-                without a leading '?'.
-            storage_endpoint_suffix: Deliver order to a sovereign cloud.
-            path_prefix: Custom string to prepend to the files delivered to the
-                bucket. A slash (/) character will be treated as a "folder".
-                Any other characters will be added as a prefix to the files.
-            archive_type: Archive order files. Only supports 'zip'.
-            single_archive: Archive all bundles together in a single file.
-            archive_filename: Custom naming convention to use to name the
-                archive file that is received. Uses the template variables
-                {{name}} and {{order_id}}. e.g. "{{name}}_{{order_id}}.zip".
-        """
-        self.account = account
-        self.container = container
-        self.sas_token = sas_token
-        self.storage_endpoint_suffix = storage_endpoint_suffix
-        self.path_prefix = path_prefix
-
-        super().__init__(archive_type, single_archive, archive_filename)
-
-    @classmethod
-    def from_dict(cls, details: dict) -> AzureBlobStorageDelivery:
-        """Create AzureBlobStorageDelivery instance from Orders API spec
-        representation.
-
-        Parameters:
-            details: API spec representation of delivery.
-
-        Returns:
-            AzureBlobStorageDelivery instance
-        """
-        cloud_details = _get_cloud_details(details, cls.cloud_key)
-        return cls(**cloud_details)
-
-    def to_dict(self) -> dict:
-        """Get Orders API spec representation.
-
-        Returns:
-            API spec representation of AzureBlobStorageDelivery.
-        """
-        cloud_details = {
-            'account': self.account,
-            'container': self.container,
-            'sas_token': self.sas_token,
-        }
-
-        if self.storage_endpoint_suffix:
-            cloud_details['storage_endpoint_suffix'] = \
-                self.storage_endpoint_suffix
-
-        if self.path_prefix:
-            cloud_details['path_prefix'] = self.path_prefix
-
-        details = super().to_dict()
-        details[self.cloud_key] = cloud_details
-        return details
-
-
-class GoogleCloudStorageDelivery(Delivery):
-    '''Google Cloud Storage delivery description for an order detail.'''
-    cloud_key = 'google_cloud_storage'
-
-    def __init__(
-        self,
-        bucket: str,
-        credentials: str,
-        path_prefix: str = None,
-        archive_type: str = False,
-        single_archive: bool = False,
-        archive_filename: str = None
-    ):
-        """
-        Parameters:
-            bucket: GCS bucket name.
-            credentials: JSON-string of service account for bucket.
-            path_prefix: Custom string to prepend to the files delivered to the
-                bucket. A slash (/) character will be treated as a "folder".
-                Any other characters will be added as a prefix to the files.
-            archive_type: Archive order files. Only supports 'zip'.
-            single_archive: Archive all bundles together in a single file.
-            archive_filename: Custom naming convention to use to name the
-                archive file that is received. Uses the template variables
-                {{name}} and {{order_id}}. e.g. "{{name}}_{{order_id}}.zip".
-        """
-        self.bucket = bucket
-        self.credentials = credentials
-        self.path_prefix = path_prefix
-        super().__init__(archive_type, single_archive, archive_filename)
-
-    @classmethod
-    def from_dict(cls, details: dict) -> GoogleCloudStorageDelivery:
-        """Create GoogleCloudStorageDelivery instance from Orders API spec
-        representation.
-
-        Parameters:
-            details: API spec representation of delivery.
-
-        Returns:
-            GoogleCloudStorageDelivery instance
-        """
-        cloud_details = _get_cloud_details(details, cls.cloud_key)
-        return cls(**cloud_details)
-
-    def to_dict(self) -> dict:
-        """Get Orders API spec representation.
-
-        Returns:
-            API spec representation of GoogleCloudStorageDelivery.
-        """
-        cloud_details = {
-            'bucket': self.bucket,
-            'credentials': self.credentials,
-        }
-
-        if self.path_prefix:
-            cloud_details['path_prefix'] = self.path_prefix
-
-        details = super().to_dict()
-        details[self.cloud_key] = cloud_details
-        return details
-
-
-class GoogleEarthEngineDelivery(Delivery):
-    '''Google Earth Engine delivery description for an order detail.'''
-    cloud_key = 'google_earth_engine'
-
-    def __init__(
-        self,
-        project: str,
-        collection: str,
-        archive_type: str = False,
-        single_archive: bool = False,
-        archive_filename: str = None
-    ):
-        """
-        Parameters:
-            project: GEE project name.
-            collection: GEE Image Collection name.
-            archive_type: Archive order files. Only supports 'zip'.
-            single_archive: Archive all bundles together in a single file.
-            archive_filename: Custom naming convention to use to name the
-                archive file that is received. Uses the template variables
-                {{name}} and {{order_id}}. e.g. "{{name}}_{{order_id}}.zip".
-        """
-        self.project = project
-        self.collection = collection
-        super().__init__(archive_type, single_archive, archive_filename)
-
-    @classmethod
-    def from_dict(cls, details: dict) -> GoogleEarthEngineDelivery:
-        """Create GoogleEarthEngineDelivery instance from Orders API spec
-        representation.
-
-        Parameters:
-            details: API spec representation of delivery.
-
-        Returns:
-            GoogleEarthEngineDelivery instance
-        """
-        cloud_details = _get_cloud_details(details, cls.cloud_key)
-        return cls(**cloud_details)
-
-    def to_dict(self) -> dict:
-        """Get Orders API spec representation.
-
-        Returns:
-            API spec representation of GoogleEarthEngineDelivery.
-        """
-        cloud_details = {
-            'project': self.project,
-            'collection': self.collection,
-        }
-
-        details = super().to_dict()
-        details[self.cloud_key] = cloud_details
-        return details
-
-
-class ToolException(Exception):
-    '''Exceptions thrown by Tool'''
-    pass
-
-
-class Tool():
-    '''Tool description for an order detail.
+    cloud_details = {
+        'project': project,
+        'collection': collection,
+    }
+    return {'google_earth_engine': cloud_details}
+
+
+def _tool(name: str, parameters: dict) -> dict:
+    '''Create the API spec representation of a tool.
 
     See [Tools and Toolchains](
     https://developers.planet.com/docs/orders/tools-toolchains/)
     for more information on available tools and tool parameters.
+
+    Parameters:
+        name: Tool name.
+        parameters: Tool parameters.
+
+    Raises:
+        planet.specs.SpecificationException: If name is not the name of a valid
+            Orders API tool.
     '''
-    def __init__(
-        self,
-        name: str,
-        parameters: dict
-    ):
-        """
-        Parameters:
-            name: Tool name.
-            parameters: Tool parameters.
-        """
-        self.name = specs.validate_tool(name)
-        self.parameters = parameters
-
-    def __eq__(self, other):
-        return (self.name == other.name and
-                self.parameters == other.parameters)
-
-    @classmethod
-    def from_dict(cls, details: dict) -> Tool:
-        """Create Tool instance from Orders API spec representation.
-
-        Parameters:
-            details: API spec representation of Tool.
-
-        Returns:
-            Tool instance
-        """
-        if len(details) != 1:
-            raise ToolException(
-                'Tool description must have only one item, name: parameters')
-        name, parameters = details.popitem()
-        return cls(name, parameters)
-
-    def to_dict(self) -> dict:
-        """Get Orders API spec representation.
-
-        Returns:
-            API spec representation of Tool.
-        """
-        return {self.name: self.parameters}
-
-
-class ClipTool(Tool):
-    '''Clip tool description for a given clip region.'''
-    def __init__(
-        self,
-        aoi: Union[dict, geojson.Polygon]
-    ):
-        """
-        Parameters:
-            aoi: clip GeoJSON.
-        """
-        parameters = {'aoi': geojson.as_polygon(aoi)}
-        super().__init__('clip', parameters)
+    name = specs.validate_tool(name)
+    return {name: parameters}
+
+
+def clip_tool(aoi: dict) -> dict:
+    '''Create the API spec representation of a clip tool.
+
+    Example:
+        ```python
+        aoi = {
+            "type": "Polygon",
+            "coordinates": [[
+                [37.791595458984375, 14.84923123791421],
+                [37.90214538574219, 14.84923123791421],
+                [37.90214538574219, 14.945448293647944],
+                [37.791595458984375, 14.945448293647944],
+                [37.791595458984375, 14.84923123791421]
+            ]]
+          }
+        tool = clip_tool(aoi)
+        ```
+
+    Parameters:
+        aoi: clip GeoJSON.
+
+    Raises:
+        geojson.GeoJSONException: If GeoJSON is not a valid polygon.
+    '''
+    parameters = {'aoi': geojson.as_polygon(aoi)}
+    return _tool('clip', parameters)
+
+
+def composite_tool() -> dict:
+    '''Create the API spec representation of a composite tool.
+    '''
+    return _tool('composite', {})
+
+
+def coregister_tool(anchor_item: str) -> dict:
+    '''Create the API spec representation of a coregister tool.
+
+    Parameters:
+        anchor_item: The item_id of the item to which all other items should be
+            coregistered.
+    '''
+    return _tool('coregister', {'anchor_item': anchor_item})
+
+
+def file_format_tool(file_format: str) -> dict:
+    '''Create the API spec representation of a file format tool.
+
+    Parameters:
+        file_format: The format of the tool output. Either 'COG' or 'PL_NITF'.
+
+    Raises:
+        planet.specs.SpecificationException: If file_format is not one of
+            'COG' or 'PL_NITF'
+    '''
+    file_format = specs.validate_file_format(file_format)
+    return _tool('file_format', {'format': file_format})
+
+
+def reproject_tool(
+    projection: str,
+    resolution: float = None,
+    kernel: str = None
+) -> dict:
+    '''Create the API spec representation of a reproject tool.
+
+    Parameters:
+        projection: A coordinate system in the form EPSG:n. (ex. EPSG:4326 for
+            WGS84, EPSG:32611 for UTM 11 North (WGS84), or EPSG:3857 for Web
+            Mercator).
+        resolution: The pixel width and height in the output file. The API
+            default is the resolution of the input item. This value will be in
+            meters unless the coordinate system is geographic (like EPSG:4326),
+            then it will be a pixel size in decimal degrees.
+        kernel: The resampling kernel used. The API default is "near". This
+            parameter also supports "bilinear", "cubic", "cubicspline",
+            "lanczos", "average" and "mode".
+    '''
+    parameters = dict((k, v) for k, v in locals().items() if v)
+    return _tool('reproject', parameters)
+
+
+def tile_tool(
+    tile_size: int,
+    origin_x: float = None,
+    origin_y: float = None,
+    pixel_size: float = None,
+    name_template: str = None,
+    conformal_x_scaling: bool = None
+) -> dict:
+    '''Create the API spec representation of a reproject tool.
+
+    Parameters:
+        tile_size: Height and width of output tiles in pixels and lines
+            (always square).
+        origin_x: Tiling system x origin in projected coordinates. The API
+            default is zero.
+        origin_y: Tiling system y origin in projected coordinates. The API
+            default is zero.
+        pixel_size: Tiling system pixel size in projected coordinates. The API
+            default is the pixel_size of input raster.
+        name_template: A naming template for creating output tile filenames.
+            The API default is "{tilex}_{tiley}.tif" resulting in filenames
+            like 128_200.tif. The {tilex} and {tiley} parameters can be of the
+            form {tilex:06d} to produce a fixed width field with leading zeros.
+    '''
+    parameters = dict((k, v) for k, v in locals().items() if v)
+    return _tool('tile', parameters)
+
+
+def toar_tool(
+    scale_factor: int = None,
+) -> dict:
+    '''Create the API spec representation of a TOAR tool.
+
+    Parameters:
+        scale_factor: Scale factor applied to convert 0.0 to 1.0 reflectance
+            floating point values to a value that fits in 16bit integer pixels.
+            The API default is 10000. Values over 65535 could result in high
+            reflectances not fitting in 16bit integers.
+    '''
+    parameters = {}
+    if scale_factor:
+        parameters['scale_factor'] = scale_factor
+    return _tool('toar', parameters)
+
+
+def harmonize_tool() -> dict:
+    '''Create the API spec representation of a harmonize tool.
+
+    Currently, only "PS2" (Dove Classic) is supported as a target sensor, and
+    it will transform only items captured by “PS2.SD” (Dove-R).
+    '''
+    return _tool('harmonize', {'target_sensor': 'PS2'})
diff --git a/planet/api/orders.py b/planet/api/orders.py
index aa33bfb..319653a 100644
--- a/planet/api/orders.py
+++ b/planet/api/orders.py
@@ -22,7 +22,6 @@ import typing
 from .. import constants
 from .http import Session
 from .models import Order, Orders, Request, Response, StreamingBody
-from .order_details import OrderDetails
 
 
 BASE_URL = constants.PLANET_BASE_URL + 'compute/ops/'
@@ -113,7 +112,7 @@ class OrdersClient():
 
     async def create_order(
         self,
-        order_details: typing.Union[dict, OrderDetails]
+        request: dict
     ) -> str:
         '''Create an order request.
 
@@ -122,18 +121,17 @@ class OrdersClient():
         ```python
         >>> import asyncio
         >>> from planet import Session, OrdersClient
-        >>> from planet.api.order_details import OrderDetails, Product
+        >>> from planet.api.order_details import build_request, product
         >>>
         >>> async def main():
-        ...     auth = ('example_api_key', '')
         ...     image_ids = ['3949357_1454705_2020-12-01_241c']
-        ...     order_detail = OrderDetails(
+        ...     request = build_request(
         ...         'test_order',
-        ...         [Product(image_ids, 'analytic', 'psorthotile')]
+        ...         [product(image_ids, 'analytic', 'psorthotile')]
         ...     )
-        ...     async with Session(auth=auth) as sess:
+        ...     async with Session() as sess:
         ...         cl = OrdersClient(sess)
-        ...         order_id = await cl.create_order(order_detail)
+        ...         order_id = await cl.create_order(request)
         ...
         >>> asyncio.run(main())
 
@@ -148,13 +146,8 @@ class OrdersClient():
         Raises:
             planet.api.exceptions.APIException: On API error.
         '''
-        if not isinstance(order_details, OrderDetails):
-            order_details = OrderDetails.from_dict(order_details)
-
-        data = json.dumps(order_details.to_dict())
-
         url = self._orders_url()
-        req = self._request(url, method='POST', data=data)
+        req = self._request(url, method='POST', data=json.dumps(request))
         resp = await self._do_request(req)
 
         order = Order(resp.json())
diff --git a/planet/geojson.py b/planet/geojson.py
index d99985e..752df7f 100644
--- a/planet/geojson.py
+++ b/planet/geojson.py
@@ -30,9 +30,8 @@ class WrongTypeException(GeoJSONException):
     pass
 
 
-class MultipleFeaturesException(GeoJSONException):
-    '''multiple features when only one is acceptable'''
-    pass
+class DataLossWarning(UserWarning):
+    """Warn that data will be lost."""
 
 
 def as_geom(data: dict) -> dict:
@@ -81,7 +80,7 @@ def geom_from_geojson(data: dict) -> dict:
     else:
         try:
             # feature
-            ret = as_geom(data['geometry'])
+            ret = as_geom(data["geometry"])
         except KeyError:
             try:
                 # featureclass
@@ -90,9 +89,9 @@ def geom_from_geojson(data: dict) -> dict:
                 raise GeoJSONException('Invalid GeoJSON: {data}')
 
             if len(features) > 1:
-                raise MultipleFeaturesException(
-                    'FeatureClass has multiple features. Only one feature '
-                    'can be used to get geometry.')
+                LOGGER.warning(
+                    'FeatureClass has more than one Feature, using only first'
+                    ' feature.')
 
             ret = as_geom(features[0])
     return ret
diff --git a/planet/scripts/cli.py b/planet/scripts/cli.py
index c3f3703..564aa0f 100644
--- a/planet/scripts/cli.py
+++ b/planet/scripts/cli.py
@@ -286,14 +286,21 @@ async def create(ctx, name, ids, bundle, item_type, email, cloudconfig, clip,
                  tools, pretty):
     '''Create an order.'''
     try:
-        product = planet.Product(ids, bundle, item_type)
+        product = planet.order_details.product(ids, bundle, item_type)
     except planet.specs.SpecificationException as e:
         raise click.BadParameter(e)
 
-    notifications = planet.Notifications(email=email) if email else None
+    if email:
+        notifications = planet.order_details.notifications(email=email)
+    else:
+        notifications = None
 
-    delivery = planet.Delivery.from_dict(cloudconfig) \
-        if cloudconfig else None
+    if cloudconfig:
+        delivery = planet.order_details.delivery(
+            cloud_config=cloudconfig
+        )
+    else:
+        delivery = None
 
     if clip and tools:
         raise click.BadParameter("Specify only one of '--clip' or '--tools'")
@@ -303,13 +310,9 @@ async def create(ctx, name, ids, bundle, item_type, email, cloudconfig, clip,
         except planet.geojson.GeoJSONException as e:
             raise click.BadParameter(e)
 
-        tools = [planet.ClipTool(clip)]
-    elif tools:
-        tools = [planet.Tool.from_dict(t) for t in tools]
-    else:
-        tools = None
+        tools = [planet.order_details.clip_tool(clip)]
 
-    order_details = planet.OrderDetails(
+    request = planet.order_details.build_request(
         name,
         products=[product],
         delivery=delivery,
@@ -317,6 +320,6 @@ async def create(ctx, name, ids, bundle, item_type, email, cloudconfig, clip,
         tools=tools)
 
     async with orders_client(ctx) as cl:
-        order = await cl.create_order(order_details)
+        order = await cl.create_order(request)
 
     json_echo(order.json, pretty)
diff --git a/planet/specs.py b/planet/specs.py
index 55d3bd5..e01725a 100644
--- a/planet/specs.py
+++ b/planet/specs.py
@@ -24,6 +24,7 @@ SUPPORTED_TOOLS = ['band_math', 'clip', 'composite', 'coregister',
                    'file_format', 'reproject', 'tile', 'toar', 'harmonize']
 SUPPORTED_ORDER_TYPES = ['full', 'partial']
 SUPPORTED_ARCHIVE_TYPES = ['zip']
+SUPPORTED_FILE_FORMATS = ['COG', 'PL_NITF']
 
 LOGGER = logging.getLogger(__name__)
 
@@ -58,6 +59,10 @@ def validate_tool(tool):
     return _validate_field(tool, SUPPORTED_TOOLS, 'tool')
 
 
+def validate_file_format(file_format):
+    return _validate_field(file_format, SUPPORTED_FILE_FORMATS, 'file_format')
+
+
 def _validate_field(value, supported, field_name=None):
     try:
         value = get_match(value, supported)


Test patch:
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index eb29b01..eaf56fb 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -11,7 +11,6 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-import json
 import logging
 from unittest.mock import MagicMock, Mock
 
@@ -135,8 +134,8 @@ class AsyncMock(Mock):
 
 
 @pytest.fixture
-def cloudconfig(write_to_tmp_json_file):
-    as3_details = {
+def cloudconfig():
+    return {
         'amazon_s3': {
             'aws_access_key_id': 'aws_access_key_id',
             'aws_secret_access_key': 'aws_secret_access_key',
@@ -145,7 +144,6 @@ def cloudconfig(write_to_tmp_json_file):
             },
         'archive_type': 'zip',
     }
-    return write_to_tmp_json_file(as3_details, 'cloudconfig.json')
 
 
 @pytest.fixture
@@ -210,30 +208,30 @@ def name(order_details):
 def products(order_details, test_id):
     product = order_details['products'][0]
     return [
-        planet.Product([test_id],
-                       product['product_bundle'],
-                       product['item_type'])
+        planet.order_details.product(
+            [test_id],
+            product['product_bundle'],
+            product['item_type'])
     ]
 
 
 def test_cli_orders_create_cloudconfig(
         runner, mock_create_order, create_order_basic_cmds, name, products,
-        cloudconfig,
+        cloudconfig, write_to_tmp_json_file
         ):
+    cc_file = write_to_tmp_json_file(cloudconfig, 'cloudconfig.json')
     basic_result = runner.invoke(
-        cli, create_order_basic_cmds + ['--cloudconfig', cloudconfig]
+        cli, create_order_basic_cmds + ['--cloudconfig', cc_file]
     )
     assert not basic_result.exception
 
     mock_create_order.assert_called_once()
 
-    with open(cloudconfig, 'r') as f:
-        cc = json.load(f)
-
-    expected_details = planet.OrderDetails(
-        name, products,
-        delivery=planet.Delivery.from_dict(cc)
-        )
+    expected_details = {
+        'name': name,
+        'products': products,
+        'delivery': cloudconfig
+    }
     mock_create_order.assert_called_with(expected_details)
 
 
@@ -248,11 +246,11 @@ def test_cli_orders_create_clip(
 
     mock_create_order.assert_called_once()
 
-    expected_details = planet.OrderDetails(
-        name, products,
-        tools=[planet.Tool('clip', {'aoi': geom_geojson})]
-    )
-
+    expected_details = {
+        'name': name,
+        'products': products,
+        'tools': [{'clip': {'aoi': geom_geojson}}]
+    }
     mock_create_order.assert_called_with(expected_details)
 
 
@@ -266,11 +264,11 @@ def test_cli_orders_create_tools(
 
     mock_create_order.assert_called_once()
 
-    expected_details = planet.OrderDetails(
-        name, products,
-        tools=[planet.Tool.from_dict(t) for t in tools_json]
-    )
-
+    expected_details = {
+        'name': name,
+        'products': products,
+        'tools': tools_json
+    }
     mock_create_order.assert_called_with(expected_details)
 
 
diff --git a/tests/unit/test_geojson.py b/tests/unit/test_geojson.py
index 28f9539..6094d5f 100644
--- a/tests/unit/test_geojson.py
+++ b/tests/unit/test_geojson.py
@@ -45,6 +45,10 @@ def test_geom_from_geojson_success(
     fcgeo = geojson.geom_from_geojson(featureclass_geojson)
     assert_geom_equal(fcgeo, geom_geojson)
 
+    featureclass_geojson['features'].append(feature_geojson)
+    ffcgeo = geojson.geom_from_geojson(featureclass_geojson)
+    assert_geom_equal(ffcgeo, geom_geojson)
+
 
 def test_geom_from_geojson_no_geometry(feature_geojson):
     feature_geojson.pop('geometry')
@@ -64,13 +68,6 @@ def test_geom_from_geojson_missing_type(geom_geojson):
         _ = geojson.geom_from_geojson(geom_geojson)
 
 
-def test_geom_from_geojson_multiple_features(featureclass_geojson):
-    # duplicate the feature
-    featureclass_geojson['features'] = 2 * featureclass_geojson['features']
-    with pytest.raises(geojson.MultipleFeaturesException):
-        _ = geojson.geom_from_geojson(featureclass_geojson)
-
-
 def test_validate_geom_invalid_type(geom_geojson):
     geom_geojson['type'] = 'invalid'
     with pytest.raises(geojson.GeoJSONException):
diff --git a/tests/unit/test_order_details.py b/tests/unit/test_order_details.py
index 1bb6194..89015de 100644
--- a/tests/unit/test_order_details.py
+++ b/tests/unit/test_order_details.py
@@ -27,373 +27,267 @@ TEST_ITEM_TYPE = 'PSOrthoTile'
 TEST_ARCHIVE_FILENAME = '{{name}}_b_{order_id}}.zip'
 
 
-def test_OrderDetails():
-    test_product = order_details.Product(
-            [TEST_ID], TEST_PRODUCT_BUNDLE, TEST_ITEM_TYPE)
-    _ = order_details.OrderDetails('test', [test_product])
-
-    _ = order_details.OrderDetails(
-        'test',
-        [test_product],
-        order_details.Delivery(archive_type='zip'),
-        order_details.Notifications(email=True),
-        [order_details.Tool('file_format', 'COG')]
-    )
-
-
-def test_OrderDetails_from_dict():
-    min_details = {
-        'name': 'test',
-        'products': [
-            {
-                'item_ids': [TEST_ID],
-                'item_type': TEST_ITEM_TYPE,
-                'product_bundle': TEST_PRODUCT_BUNDLE
-            }
-        ],
-    }
-    _ = order_details.OrderDetails.from_dict(min_details)
-
-    details = {
-        'name': 'test',
-        'products': [
-            {
-                'item_ids': [TEST_ID],
-                'item_type': TEST_ITEM_TYPE,
-                'product_bundle': TEST_PRODUCT_BUNDLE
+def test_build_request():
+    product = {
+      "item_ids": [TEST_ID],
+      "item_type": TEST_ITEM_TYPE,
+      "product_bundle": f'{TEST_PRODUCT_BUNDLE},{TEST_FALLBACK_BUNDLE}'
+      }
+    subscription_id = 5
+    delivery = {
+      'archive_type': 'zip',
+      'single_archive': True,
+      'archive_filename': TEST_ARCHIVE_FILENAME,
+      'amazon_s3': {
+            'aws_access_key_id': 'aws_access_key_id',
+            'aws_secret_access_key': 'aws_secret_access_key',
+            'bucket': 'bucket',
+            'aws_region': 'aws_region'
             }
-        ],
-        'subscription_id': 1,
-        'delivery': {'archive_type': 'zip'},
-        'notifications': {'email': True},
-        'tools': [
-            {'file_format': 'COG'},
-            {'toar': {'scale_factor': 10000}}
-        ]
-    }
-
-    od = order_details.OrderDetails.from_dict(details)
-    assert od.subscription_id == 1
-    assert od.delivery.archive_type == 'zip'
-    assert od.notifications.email
-    assert od.tools[0].name == 'file_format'
-    assert od.tools[0].parameters == 'COG'
-    assert od.tools[1].name == 'toar'
-    assert od.tools[1].parameters == {'scale_factor': 10000}
-
-
-def test_OrderDetails_to_dict():
-    test_product = order_details.Product(
-            [TEST_ID], TEST_PRODUCT_BUNDLE, TEST_ITEM_TYPE)
-
-    od = order_details.OrderDetails(
-        'test',
-        [test_product],
-        subscription_id=1,
-        delivery=order_details.Delivery(archive_type='zip'),
-        notifications=order_details.Notifications(email=True),
-        tools=[order_details.Tool('file_format', 'COG')]
+      }
+    notifications = {
+      'email': 'email',
+      'webhook_url': 'webhookurl',
+      'webhook_per_order': True
+      }
+    order_type = 'partial'
+    tool = {'band_math': 'jsonstring'}
+
+    request = order_details.build_request(
+        'test_name',
+        [product],
+        subscription_id=subscription_id,
+        delivery=delivery,
+        notifications=notifications,
+        order_type=order_type,
+        tools=[tool]
         )
-
     expected = {
-        'name': 'test',
-        'products': [
-            {
-                'item_ids': [TEST_ID],
-                'item_type': TEST_ITEM_TYPE,
-                'product_bundle': TEST_PRODUCT_BUNDLE
-            }
-        ],
-        'subscription_id': 1,
-        'delivery': {'archive_type': 'zip'},
-        'notifications': {'email': True},
-        'tools': [{'file_format': 'COG'}]
+        'name': 'test_name',
+        'products': [product],
+        'subscription_id': subscription_id,
+        'delivery': delivery,
+        'notifications': notifications,
+        'order_type': order_type,
+        'tools': [tool]
     }
-    assert expected == od.to_dict()
+    assert request == expected
 
+    order_type = 'notsupported'
+    with pytest.raises(specs.SpecificationException):
+        _ = order_details.build_request(
+            'test_name',
+            [product],
+            subscription_id=subscription_id,
+            delivery=delivery,
+            notifications=notifications,
+            order_type=order_type,
+            tools=[tool]
+            )
+
+
+def test_product():
+    product_config = order_details.product(
+        [TEST_ID], TEST_PRODUCT_BUNDLE, TEST_ITEM_TYPE,
+        fallback_bundle=TEST_FALLBACK_BUNDLE)
 
-def test_Product():
-    _ = order_details.Product([TEST_ID], TEST_PRODUCT_BUNDLE, TEST_ITEM_TYPE,
-                              fallback_bundle=TEST_FALLBACK_BUNDLE)
+    expected = {
+      "item_ids": [TEST_ID],
+      "item_type": TEST_ITEM_TYPE,
+      "product_bundle": f'{TEST_PRODUCT_BUNDLE},{TEST_FALLBACK_BUNDLE}'
+      }
+    assert product_config == expected
 
     with pytest.raises(specs.SpecificationException):
-        _ = order_details.Product([TEST_ID],
+        _ = order_details.product([TEST_ID],
                                   'notsupported',
                                   TEST_ITEM_TYPE,
                                   fallback_bundle=TEST_FALLBACK_BUNDLE)
 
-        _ = order_details.Product([TEST_ID],
+    with pytest.raises(specs.SpecificationException):
+        _ = order_details.product([TEST_ID],
                                   TEST_PRODUCT_BUNDLE,
                                   'notsupported',
                                   fallback_bundle=TEST_FALLBACK_BUNDLE)
 
-        _ = order_details.Product([TEST_ID],
+    with pytest.raises(specs.SpecificationException):
+        _ = order_details.product([TEST_ID],
                                   TEST_PRODUCT_BUNDLE,
                                   TEST_ITEM_TYPE,
                                   fallback_bundle='notsupported')
 
 
-def test_Product_from_dict():
-    test_details = {
-      'item_ids': [TEST_ID],
-      'item_type': TEST_ITEM_TYPE,
-      'product_bundle': f'{TEST_PRODUCT_BUNDLE},{TEST_FALLBACK_BUNDLE}'
-      }
-
-    p = order_details.Product.from_dict(test_details)
-    assert p.item_ids == [TEST_ID]
-    assert p.item_type == TEST_ITEM_TYPE
-    assert p.product_bundle == TEST_PRODUCT_BUNDLE
-    assert p.fallback_bundle == TEST_FALLBACK_BUNDLE
-
-
-def test_Product_to_dict():
-    p = order_details.Product([TEST_ID], TEST_PRODUCT_BUNDLE, TEST_ITEM_TYPE,
-                              fallback_bundle=TEST_FALLBACK_BUNDLE)
-    p_dict = p.to_dict()
-
+def test_notifications():
+    notifications_config = order_details.notifications(
+        email='email',
+        webhook_url='webhookurl',
+        webhook_per_order=True
+    )
     expected = {
-      "item_ids": [TEST_ID],
-      "item_type": TEST_ITEM_TYPE,
-      "product_bundle": f'{TEST_PRODUCT_BUNDLE},{TEST_FALLBACK_BUNDLE}'
-      }
-
-    assert p_dict == expected
-
-
-def test_Notifications_from_dict():
-    test_details = {
       'email': 'email',
       'webhook_url': 'webhookurl',
       'webhook_per_order': True
       }
+    assert notifications_config == expected
 
-    n = order_details.Notifications.from_dict(test_details)
-    assert n.email == 'email'
-    assert n.webhook_url == 'webhookurl'
-    assert n.webhook_per_order
-
-
-def test_Notifications_to_dict():
-    n = order_details.Notifications(email='email')
-    assert n.to_dict() == {'email': 'email'}
-
-    n = order_details.Notifications(webhook_url='webhookurl')
-    assert n.to_dict() == {'webhook_url': 'webhookurl'}
-
-    n = order_details.Notifications(webhook_per_order=True)
-    assert n.to_dict() == {'webhook_per_order': True}
-
+    empty_notifications_config = order_details.notifications(
+    )
+    empty_expected = {}
+    assert empty_notifications_config == empty_expected
 
-def test_Delivery():
-    d = order_details.Delivery(archive_type='Zip')
-    assert d.archive_type == 'zip'
 
+def test_delivery():
+    as3_config = {
+        'amazon_s3': {
+            'aws_access_key_id': 'aws_access_key_id',
+            'aws_secret_access_key': 'aws_secret_access_key',
+            'bucket': 'bucket',
+            'aws_region': 'aws_region'
+            }
+    }
+    delivery_config = order_details.delivery(
+        'zip',
+        True,
+        TEST_ARCHIVE_FILENAME,
+        cloud_config=as3_config
+    )
 
-@pytest.fixture
-def delivery_details():
-    return {
+    expected = {
       'archive_type': 'zip',
       'single_archive': True,
-      'archive_filename': TEST_ARCHIVE_FILENAME
+      'archive_filename': TEST_ARCHIVE_FILENAME,
+      'amazon_s3': {
+            'aws_access_key_id': 'aws_access_key_id',
+            'aws_secret_access_key': 'aws_secret_access_key',
+            'bucket': 'bucket',
+            'aws_region': 'aws_region'
+            }
       }
+    assert delivery_config == expected
 
 
-def test_Delivery_from_dict(as3_details, abs_details, delivery_details,
-                            gcs_details, gee_details):
-    d = order_details.Delivery.from_dict(delivery_details)
-    assert isinstance(d, order_details.Delivery)
-    assert d.archive_type == 'zip'
-    assert d.single_archive
-    assert d.archive_filename == TEST_ARCHIVE_FILENAME
-
-    subclass_details = [as3_details, abs_details, gcs_details, gee_details]
-    subclasses = [order_details.AmazonS3Delivery,
-                  order_details.AzureBlobStorageDelivery,
-                  order_details.GoogleCloudStorageDelivery,
-                  order_details.GoogleEarthEngineDelivery]
-    for details, cls in zip(subclass_details, subclasses):
-        assert isinstance(order_details.Delivery.from_dict(details), cls)
-
-
-def test_Delivery_to_dict(delivery_details):
-    d = order_details.Delivery(archive_type='zip',
-                               single_archive=True,
-                               archive_filename=TEST_ARCHIVE_FILENAME)
-    details = d.to_dict()
-    assert details == delivery_details
-
-    d = order_details.Delivery(archive_type='zip')
-    details = d.to_dict()
+def test_amazon_s3():
+    as3_config = order_details.amazon_s3(
+        'aws_access_key_id',
+        'aws_secret_access_key',
+        'bucket',
+        'aws_region'
+    )
     expected = {
-      'archive_type': 'zip',
-      }
-    assert details == expected
-
-
-@pytest.fixture
-def as3_details():
-    return {
         'amazon_s3': {
             'aws_access_key_id': 'aws_access_key_id',
             'aws_secret_access_key': 'aws_secret_access_key',
             'bucket': 'bucket',
             'aws_region': 'aws_region'
-            },
-        'archive_type': 'zip',
+            }
     }
+    assert as3_config == expected
 
 
-def test_AmazonS3Delivery_from_dict(as3_details):
-    d2 = order_details.AmazonS3Delivery.from_dict(as3_details)
-    assert d2.aws_region == 'aws_region'
-
-
-def test_AmazonS3Delivery_to_dict(as3_details):
-    aws_access_key_id = 'aws_access_key_id'
-    aws_secret_access_key = 'aws_secret_access_key'
-    bucket = 'bucket'
-    aws_region = 'aws_region'
-    archive_type = 'zip'
-
-    d = order_details.AmazonS3Delivery(
-        aws_access_key_id,
-        aws_secret_access_key,
-        bucket,
-        aws_region,
-        archive_type=archive_type)
-    details = d.to_dict()
-    assert details == as3_details
-
-
-@pytest.fixture
-def abs_details():
-    return {
+def test_azure_blob_storage():
+    abs_config = order_details.azure_blob_storage(
+        'account',
+        'container',
+        'sas_token'
+    )
+    expected = {
         'azure_blob_storage': {
             'account': 'account',
             'container': 'container',
             'sas_token': 'sas_token',
-            },
-        'archive_type': 'zip',
+            }
     }
+    assert abs_config == expected
 
 
-def test_AzureBlobStorageDelivery_from_dict(abs_details):
-    d2 = order_details.AzureBlobStorageDelivery.from_dict(abs_details)
-    assert d2.sas_token == 'sas_token'
-
-
-def test_AzureBlobStorageDelivery_to_dict(abs_details):
-    account = 'account'
-    container = 'container'
-    sas_token = 'sas_token'
-    archive_type = 'zip'
-
-    d = order_details.AzureBlobStorageDelivery(
-        account,
-        container,
-        sas_token,
-        archive_type=archive_type)
-    details = d.to_dict()
-    assert details == abs_details
-
+def test_google_cloud_storage():
+    gcs_config = order_details.google_cloud_storage(
+        'bucket',
+        'credentials'
+    )
 
-@pytest.fixture
-def gcs_details():
-    return {
+    expected = {
         'google_cloud_storage': {
             'bucket': 'bucket',
             'credentials': 'credentials',
-            },
-        'archive_type': 'zip',
+            }
     }
+    assert gcs_config == expected
 
 
-def test_GoogleCloudStorageDelivery_from_dict(gcs_details):
-    d2 = order_details.GoogleCloudStorageDelivery.from_dict(gcs_details)
-    assert d2.credentials == 'credentials'
-
-
-def test_GoogleCloudStorageDelivery_to_dict(gcs_details):
-    bucket = 'bucket'
-    credentials = 'credentials'
-    archive_type = 'zip'
-
-    d = order_details.GoogleCloudStorageDelivery(
-        bucket,
-        credentials,
-        archive_type=archive_type)
-    details = d.to_dict()
-    assert details == gcs_details
-
-
-@pytest.fixture
-def gee_details():
-    return {
+def test_google_earth_engine():
+    gee_config = order_details.google_earth_engine('project', 'collection')
+    expected = {
         'google_earth_engine': {
             'project': 'project',
             'collection': 'collection',
-            },
-        'archive_type': 'zip',
+            }
     }
 
-
-def test_GoogleEarthEngineDelivery_from_dict(gee_details):
-    d2 = order_details.GoogleEarthEngineDelivery.from_dict(gee_details)
-    assert d2.collection == 'collection'
+    assert gee_config == expected
 
 
-def test_GoogleEarthEngineDelivery_to_dict(gee_details):
-    project = 'project'
-    collection = 'collection'
-    archive_type = 'zip'
-
-    d = order_details.GoogleEarthEngineDelivery(
-        project,
-        collection,
-        archive_type=archive_type)
-    details = d.to_dict()
-    assert details == gee_details
-
-
-def test_Tool():
-    _ = order_details.Tool('band_math', 'jsonstring')
+def test__tool():
+    test_tool = order_details._tool('band_math', 'jsonstring')
+    assert test_tool == {'band_math': 'jsonstring'}
 
     with pytest.raises(specs.SpecificationException):
-        _ = order_details.Tool('notsupported', 'jsonstring')
+        _ = order_details._tool('notsupported', 'jsonstring')
 
 
-def test_Tool_from_dict():
-    details = {
-            'band_math': {'b1': 'b1+b2'}
-    }
-    tool = order_details.Tool.from_dict(details)
-    assert tool.name == 'band_math'
-    assert tool.parameters == {'b1': 'b1+b2'}
-
-    with pytest.raises(order_details.ToolException):
-        _ = order_details.Tool.from_dict({'name': 'val', 'oops': 'error'})
-
+def test_clip_tool(geom_geojson, point_geom_geojson):
+    ct = order_details.clip_tool(geom_geojson)
+    expected = {
+        'clip': {
+            'aoi': geom_geojson
+        }}
+    assert ct == expected
 
-def test_Tool_to_dict():
-    _ = order_details.Tool('band_math', 'jsonstring')
+    with pytest.raises(geojson.WrongTypeException):
+        _ = order_details.clip_tool(point_geom_geojson)
 
-    with pytest.raises(specs.SpecificationException):
-        _ = order_details.Tool('notsupported', 'jsonstring')
 
+def test_reproject_tool():
+    rt = order_details.reproject_tool(
+        projection='proj',
+        resolution=5
+    )
+    expected = {
+        'reproject': {
+            'projection': 'proj',
+            'resolution': 5
+            }
+        }
+    assert rt == expected
 
-def test_ClipTool_success_dict(geom_geojson):
-    ct = order_details.ClipTool(geom_geojson)
-    assert ct.name == 'clip'
-    assert ct.parameters['aoi'] == geom_geojson
 
+def test_tile_tool():
+    tt = order_details.tile_tool(
+        30,
+        pixel_size=3
+    )
+    expected = {
+        'tile': {
+            'tile_size': 30,
+            'pixel_size': 3
+        }
+    }
+    assert tt == expected
 
-def test_ClipTool_success_geom(geom_geojson):
-    ct = order_details.ClipTool(geom_geojson)
-    assert ct.name == 'clip'
-    assert ct.parameters['aoi'] == geom_geojson
 
+def test_toar_tool():
+    tt = order_details.toar_tool(
+        scale_factor=5
+    )
+    expected = {
+        'toar': {
+            'scale_factor': 5
+            }
+    }
+    assert tt == expected
 
-def test_ClipTool_wrong_type(point_geom_geojson):
-    with pytest.raises(geojson.WrongTypeException):
-        _ = order_details.ClipTool(point_geom_geojson)
+    tt_empty = order_details.toar_tool()
+    expected_empty = {
+        'toar': {}
+    }
+    assert tt_empty == expected_empty
diff --git a/tests/unit/test_specs.py b/tests/unit/test_specs.py
index 5ffd9b7..f3f3610 100644
--- a/tests/unit/test_specs.py
+++ b/tests/unit/test_specs.py
@@ -67,6 +67,13 @@ def test_validate_arhive_type():
         specs.validate_archive_type('notsupported')
 
 
+def test_validate_file_format():
+    assert 'COG' == specs.validate_file_format('cog')
+
+    with pytest.raises(specs.SpecificationException):
+        specs.validate_archive_type('notsupported')
+
+
 def test_get_product_bundles():
     bundles = specs.get_product_bundles()
     assert TEST_PRODUCT_BUNDLE in bundles