1+ from fastapi import FastAPI , HTTPException , Depends , status
2+ from fastapi .security import APIKeyHeader
3+ from shippo import Shippo
4+ from shippo .models import components
5+ from shippo .models .errors import SDKError
6+ from pydantic import BaseModel , Field
7+ from fastapi .middleware .cors import CORSMiddleware
8+ import os
9+ import logging
10+ from dotenv import load_dotenv
11+ from typing import Optional
12+ import re
13+
14+ load_dotenv ()
15+
16+ logging .basicConfig (
17+ level = logging .INFO ,
18+ format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" ,
19+ handlers = [logging .StreamHandler ()]
20+ )
21+ logger = logging .getLogger (__name__ )
22+
23+ app = FastAPI (
24+ title = "Shipping API" ,
25+ description = "API for creating and tracking shipments using Shippo" ,
26+ version = "1.0.0"
27+ )
28+
29+ SHIPPO_API_KEY = os .getenv ("SHIPPO_API_KEY" )
30+ if not SHIPPO_API_KEY :
31+ logger .error ("SHIPPO_API_KEY not set in environment variables" )
32+ raise RuntimeError ("SHIPPO_API_KEY is required" )
33+
34+ shippo_sdk = Shippo (api_key_header = SHIPPO_API_KEY )
35+
36+ MOCK_TRACKING_NUMBER = "SHIPPO_TRANSIT"
37+
38+ app .add_middleware (
39+ CORSMiddleware ,
40+ allow_origins = ["http://localhost:3000" ],
41+ allow_credentials = True ,
42+ allow_methods = ["*" ],
43+ allow_headers = ["*" ],
44+ )
45+
46+ class AddressRequest (BaseModel ):
47+ name : str = Field (..., min_length = 1 , max_length = 100 )
48+ street1 : str = Field (..., min_length = 1 , max_length = 100 )
49+ street2 : Optional [str ] = Field (None , max_length = 100 )
50+ city : str = Field (..., min_length = 1 , max_length = 100 )
51+ state : str = Field (..., min_length = 2 , max_length = 2 )
52+ zip : str = Field (..., pattern = r"^\d{5}(-\d{4})?$" )
53+ country : str = Field (..., min_length = 2 , max_length = 2 )
54+ phone : Optional [str ] = Field (None , pattern = r"^\+?\d{10,15}$" )
55+ email : Optional [str ] = Field (None , max_length = 100 )
56+
57+ class ParcelRequest (BaseModel ):
58+ length : str = Field (..., pattern = r"^\d+(\.\d+)?$" )
59+ width : str = Field (..., pattern = r"^\d+(\.\d+)?$" )
60+ height : str = Field (..., pattern = r"^\d+(\.\d+)?$" )
61+ distance_unit : components .DistanceUnitEnum # Updated from DistanceUnitEnum
62+ weight : str = Field (..., pattern = r"^\d+(\.\d+)?$" )
63+ mass_unit : components .WeightUnitEnum # Updated from WeightUnitEnum
64+
65+ class ShipmentRequest (BaseModel ):
66+ address_from : AddressRequest
67+ address_to : AddressRequest
68+ parcels : list [ParcelRequest ]
69+
70+ api_key_header = APIKeyHeader (name = "X-API-Key" , auto_error = False )
71+
72+ def require_api_key (api_key : str = Depends (api_key_header )):
73+ if not api_key or api_key != os .getenv ("INTERNAL_API_KEY" ):
74+ raise HTTPException (
75+ status_code = status .HTTP_403_FORBIDDEN ,
76+ detail = "Invalid or missing API key"
77+ )
78+ return api_key
79+
80+ @app .post ("/create-order" , response_model = dict , dependencies = [Depends (require_api_key )])
81+ def create_order (request : ShipmentRequest ):
82+ try :
83+ address_from = components .AddressCreateRequest (
84+ name = request .address_from .name ,
85+ street1 = request .address_from .street1 ,
86+ street2 = request .address_from .street2 ,
87+ city = request .address_from .city ,
88+ state = request .address_from .state ,
89+ zip = request .address_from .zip ,
90+ country = request .address_from .country ,
91+ phone = request .address_from .phone ,
92+ email = request .address_from .email
93+ )
94+
95+ address_to = components .AddressCreateRequest (
96+ name = request .address_to .name ,
97+ street1 = request .address_to .street1 ,
98+ street2 = request .address_to .street2 ,
99+ city = request .address_to .city ,
100+ state = request .address_to .state ,
101+ zip = request .address_to .zip ,
102+ country = request .address_to .country ,
103+ phone = request .address_to .phone ,
104+ email = request .address_to .email
105+ )
106+
107+ parcel_data = request .parcels [0 ]
108+ parcel = components .ParcelCreateRequest (
109+ length = parcel_data .length ,
110+ width = parcel_data .width ,
111+ height = parcel_data .height ,
112+ distance_unit = parcel_data .distance_unit ,
113+ weight = parcel_data .weight ,
114+ mass_unit = parcel_data .mass_unit
115+ )
116+
117+ logger .info ("Creating shipment from %s to %s" , address_from .city , address_to .city )
118+ shipment = shippo_sdk .shipments .create (
119+ components .ShipmentCreateRequest (
120+ address_from = address_from ,
121+ address_to = address_to ,
122+ parcels = [parcel ],
123+ async_ = False
124+ )
125+ )
126+
127+ if not shipment .rates :
128+ logger .warning ("No rates available for shipment %s" , shipment .object_id )
129+ raise HTTPException (status_code = 400 , detail = "No rates available for this shipment" )
130+
131+ rates = [f"{ r .provider } : { r .amount } { r .currency } " for r in shipment .rates ]
132+ logger .info ("Rates available: %s" , rates )
133+
134+ rate = next ((r for r in shipment .rates if r .provider == "USPS" ), shipment .rates [0 ])
135+
136+ logger .info ("Creating transaction with rate %s" , rate .object_id )
137+ transaction = shippo_sdk .transactions .create (
138+ components .TransactionCreateRequest (
139+ rate = rate .object_id ,
140+ label_file_type = "PDF" ,
141+ async_ = False
142+ )
143+ )
144+
145+ response_data = {
146+ "shipment_object_id" : shipment .object_id ,
147+ "transaction_status" : transaction .status ,
148+ }
149+
150+ if transaction .status == "SUCCESS" :
151+ response_data ["tracking_number" ] = transaction .tracking_number
152+ response_data ["label_url" ] = transaction .label_url
153+ logger .info ("Transaction successful. Tracking: %s, Label: %s" ,
154+ transaction .tracking_number , transaction .label_url )
155+ else :
156+ messages = [msg .to_dict () for msg in transaction .messages ] if transaction .messages else []
157+ logger .error ("Transaction failed: %s" , messages )
158+ response_data ["error" ] = "Transaction failed"
159+ response_data ["messages" ] = messages
160+ raise HTTPException (status_code = 400 , detail = {"error" : "Transaction failed" , "messages" : messages })
161+
162+ return response_data
163+
164+ except SDKError as e :
165+ logger .error ("Shippo API error: %s" , str (e ))
166+ raise HTTPException (status_code = 500 , detail = f"API Error: { str (e )} " )
167+ except HTTPException as e :
168+ raise e
169+ except Exception as e :
170+ logger .error ("Unexpected error: %s" , str (e ))
171+ raise HTTPException (status_code = 500 , detail = "Internal server error" )
172+
173+ @app .get ("/track/{tracking_number}" , response_model = dict , dependencies = [Depends (require_api_key )])
174+ def track_order (tracking_number : str ):
175+ if not re .match (r"^[A-Za-z0-9_-]+$" , tracking_number ):
176+ logger .warning ("Invalid tracking number format: %s" , tracking_number )
177+ raise HTTPException (status_code = 400 , detail = "Invalid tracking number format" )
178+
179+ try :
180+ logger .info ("Tracking request for %s (using mock %s)" , tracking_number , MOCK_TRACKING_NUMBER )
181+ tracking = shippo_sdk .tracking_status .get (carrier = "shippo" , tracking_number = MOCK_TRACKING_NUMBER )
182+
183+ response = {
184+ "carrier" : tracking .carrier ,
185+ "tracking_number" : tracking_number ,
186+ "status" : tracking .tracking_status .status ,
187+ "history" : [
188+ {
189+ "status" : event .status ,
190+ "date" : event .status_date ,
191+ "details" : event .status_details ,
192+ "location" : event .location or "Unknown"
193+ } for event in tracking .tracking_history
194+ ]
195+ }
196+ logger .info ("Tracking response for %s: %s" , tracking_number , response ["status" ])
197+ return response
198+
199+ except SDKError as e :
200+ logger .error ("Tracking error for %s: %s" , tracking_number , str (e ))
201+ raise HTTPException (status_code = 500 , detail = f"Tracking error: { str (e )} " )
202+ except Exception as e :
203+ logger .error ("Unexpected tracking error for %s: %s" , tracking_number , str (e ))
204+ raise HTTPException (status_code = 500 , detail = "Internal server error" )
205+
206+ if __name__ == "__main__" :
207+ import uvicorn
208+ uvicorn .run (app , host = "0.0.0.0" , port = 8000 )
0 commit comments