airbyte-source-shopify 2.4.14.dev202407181247__py3-none-any.whl → 3.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/METADATA +4 -4
  2. {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/RECORD +25 -27
  3. {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/WHEEL +1 -1
  4. source_shopify/auth.py +0 -1
  5. source_shopify/config_migrations.py +4 -1
  6. source_shopify/http_request.py +4 -2
  7. source_shopify/schemas/countries.json +7 -19
  8. source_shopify/schemas/customer_journey_summary.json +228 -148
  9. source_shopify/schemas/deleted_products.json +27 -0
  10. source_shopify/schemas/orders.json +38 -0
  11. source_shopify/schemas/product_variants.json +26 -8
  12. source_shopify/schemas/profile_location_groups.json +10 -0
  13. source_shopify/scopes.py +7 -6
  14. source_shopify/shopify_graphql/bulk/exceptions.py +6 -1
  15. source_shopify/shopify_graphql/bulk/job.py +173 -65
  16. source_shopify/shopify_graphql/bulk/query.py +440 -88
  17. source_shopify/shopify_graphql/bulk/record.py +260 -29
  18. source_shopify/shopify_graphql/bulk/retry.py +12 -12
  19. source_shopify/shopify_graphql/bulk/tools.py +17 -2
  20. source_shopify/source.py +6 -10
  21. source_shopify/spec.json +11 -5
  22. source_shopify/streams/base_streams.py +181 -54
  23. source_shopify/streams/streams.py +211 -58
  24. source_shopify/utils.py +47 -12
  25. source_shopify/schemas/customer_saved_search.json +0 -32
  26. source_shopify/schemas/products_graph_ql.json +0 -123
  27. source_shopify/shopify_graphql/graphql.py +0 -64
  28. source_shopify/shopify_graphql/schema.py +0 -29442
  29. {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/entry_points.txt +0 -0
@@ -3,16 +3,16 @@
3
3
  #
4
4
 
5
5
 
6
+ import logging
7
+ import sys
6
8
  from typing import Any, Iterable, Mapping, MutableMapping, Optional
7
9
 
8
10
  import requests
9
- from airbyte_cdk.sources.streams.core import package_name_from_class
10
- from airbyte_cdk.sources.utils.schema_helpers import ResourceSchemaLoader
11
- from requests.exceptions import RequestException
12
11
  from source_shopify.shopify_graphql.bulk.query import (
13
12
  Collection,
14
13
  CustomerAddresses,
15
14
  CustomerJourney,
15
+ DeliveryProfile,
16
16
  DiscountCode,
17
17
  FulfillmentOrder,
18
18
  InventoryItem,
@@ -30,13 +30,19 @@ from source_shopify.shopify_graphql.bulk.query import (
30
30
  Product,
31
31
  ProductImage,
32
32
  ProductVariant,
33
+ ProfileLocationGroups,
33
34
  Transaction,
34
35
  )
35
- from source_shopify.shopify_graphql.graphql import get_query_products
36
- from source_shopify.utils import ApiTypeEnum
37
- from source_shopify.utils import ShopifyRateLimiter as limiter
36
+ from source_shopify.utils import LimitReducingErrorHandler, ShopifyNonRetryableErrors
37
+
38
+ from airbyte_cdk import HttpSubStream
39
+ from airbyte_cdk.sources.streams.core import package_name_from_class
40
+ from airbyte_cdk.sources.streams.http.error_handlers import ErrorHandler
41
+ from airbyte_cdk.sources.streams.http.error_handlers.default_error_mapping import DEFAULT_ERROR_MAPPING
42
+ from airbyte_cdk.sources.utils.schema_helpers import ResourceSchemaLoader
38
43
 
39
44
  from .base_streams import (
45
+ FullRefreshShopifyGraphQlBulkStream,
40
46
  IncrementalShopifyGraphQlBulkStream,
41
47
  IncrementalShopifyNestedStream,
42
48
  IncrementalShopifyStream,
@@ -76,21 +82,32 @@ class Customers(IncrementalShopifyStream):
76
82
 
77
83
 
78
84
  class MetafieldCustomers(IncrementalShopifyGraphQlBulkStream):
85
+ parent_stream_class = Customers
79
86
  bulk_query: MetafieldCustomer = MetafieldCustomer
80
87
 
81
88
 
82
89
  class Orders(IncrementalShopifyStreamWithDeletedEvents):
83
90
  data_field = "orders"
84
91
  deleted_events_api_name = "Order"
92
+ initial_limit = 250
85
93
 
86
- def request_params(
87
- self, stream_state: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None, **kwargs
88
- ) -> MutableMapping[str, Any]:
94
+ def __init__(self, config: Mapping[str, Any]):
95
+ self._error_handler = LimitReducingErrorHandler(
96
+ max_retries=5,
97
+ error_mapping=DEFAULT_ERROR_MAPPING | ShopifyNonRetryableErrors("orders"),
98
+ )
99
+ super().__init__(config)
100
+
101
+ def request_params(self, stream_state=None, next_page_token=None, **kwargs):
89
102
  params = super().request_params(stream_state=stream_state, next_page_token=next_page_token, **kwargs)
103
+ params["limit"] = self.initial_limit # Always start with the default limit; error handler will mutate on retry
90
104
  if not next_page_token:
91
105
  params["status"] = "any"
92
106
  return params
93
107
 
108
+ def get_error_handler(self):
109
+ return self._error_handler
110
+
94
111
 
95
112
  class Disputes(IncrementalShopifyStream):
96
113
  data_field = "disputes"
@@ -116,69 +133,130 @@ class MetafieldDraftOrders(IncrementalShopifyGraphQlBulkStream):
116
133
 
117
134
  class Products(IncrementalShopifyGraphQlBulkStream):
118
135
  bulk_query: Product = Product
119
- # pin the api version
120
136
 
121
137
 
122
- class ProductsGraphQl(IncrementalShopifyStream):
123
- filter_field = "updatedAt"
124
- cursor_field = "updatedAt"
138
+ class DeletedProducts(IncrementalShopifyStream):
139
+ """
140
+ Stream for fetching deleted products using the Shopify GraphQL Events API.
141
+ This stream queries events with action:destroy and subject_type:Product to get deleted product records.
142
+ https://shopify.dev/docs/api/admin-graphql/latest/queries/events
143
+
144
+ Note: This stream extends IncrementalShopifyStream (REST base class) rather than IncrementalShopifyGraphQlBulkStream
145
+ because it uses Shopify's standard GraphQL Events API, NOT the Bulk Operations API (bulkOperationRunQuery).
146
+ The Events API has a fundamentally different architecture:
147
+ - Uses immediate queries with cursor pagination (not async job creation + file download)
148
+ - Returns results in response.data.events.nodes (not JSONL files)
149
+ - Requires different URL endpoint (/graphql.json), request format (POST with query in body), and pagination logic
150
+
151
+ Therefore, url_base, path, request_params, next_page_token, and parse_response are overridden to accommodate
152
+ the GraphQL request/response structure while maintaining incremental sync capabilities from the base class.
153
+ """
154
+
125
155
  data_field = "graphql"
156
+ cursor_field = "deleted_at"
126
157
  http_method = "POST"
127
- # pin the old api_version before this stream is deprecated
128
- api_version = "2023-07"
158
+ filter_field = None
159
+
160
+ _page_cursor: Optional[str] = None
161
+
162
+ EVENTS_QUERY = """
163
+ query GetDeletedProductEvents($first: Int!, $after: String, $query: String) {
164
+ events(first: $first, after: $after, query: $query, sortKey: CREATED_AT) {
165
+ pageInfo {
166
+ hasNextPage
167
+ endCursor
168
+ }
169
+ nodes {
170
+ ... on BasicEvent {
171
+ id
172
+ createdAt
173
+ message
174
+ subjectId
175
+ subjectType
176
+ }
177
+ }
178
+ }
179
+ }
180
+ """
181
+
182
+ @property
183
+ def url_base(self) -> str:
184
+ return f"https://{self.config['shop']}.myshopify.com/admin/api/{self.api_version}/graphql.json"
185
+
186
+ def path(self, **kwargs) -> str:
187
+ return ""
129
188
 
130
189
  def request_params(
131
- self,
132
- stream_state: Optional[Mapping[str, Any]] = None,
133
- next_page_token: Optional[Mapping[str, Any]] = None,
134
- **kwargs,
190
+ self, stream_state: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None, **kwargs
135
191
  ) -> MutableMapping[str, Any]:
136
192
  return {}
137
193
 
138
194
  def request_body_json(
139
195
  self,
140
- stream_state: Mapping[str, Any],
196
+ stream_state: Optional[Mapping[str, Any]] = None,
141
197
  stream_slice: Optional[Mapping[str, Any]] = None,
142
198
  next_page_token: Optional[Mapping[str, Any]] = None,
143
- ) -> Optional[Mapping]:
144
- state_value = stream_state.get(self.filter_field)
145
- if state_value:
146
- filter_value = state_value
147
- else:
148
- filter_value = self.default_filter_field_value
149
- query = get_query_products(
150
- first=self.limit, filter_field=self.filter_field, filter_value=filter_value, next_page_token=next_page_token
151
- )
152
- return {"query": query}
153
-
154
- @staticmethod
155
- def next_page_token(response: requests.Response) -> Optional[Mapping[str, Any]]:
156
- page_info = response.json()["data"]["products"]["pageInfo"]
157
- has_next_page = page_info["hasNextPage"]
158
- if has_next_page:
159
- return page_info["endCursor"]
160
- else:
161
- return None
199
+ ) -> Optional[Mapping[str, Any]]:
200
+ query_filter = "action:destroy AND subject_type:Product"
201
+ if stream_state and stream_state.get(self.cursor_field):
202
+ state_value = stream_state[self.cursor_field]
203
+ query_filter += f" AND created_at:>'{state_value}'"
204
+
205
+ variables = {
206
+ "first": 250,
207
+ "query": query_filter,
208
+ }
209
+
210
+ if next_page_token and next_page_token.get("cursor"):
211
+ variables["after"] = next_page_token["cursor"]
212
+
213
+ return {
214
+ "query": self.EVENTS_QUERY,
215
+ "variables": variables,
216
+ }
217
+
218
+ def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, Any]]:
219
+ json_response = response.json()
220
+ page_info = json_response.get("data", {}).get("events", {}).get("pageInfo", {})
221
+ if page_info.get("hasNextPage"):
222
+ return {"cursor": page_info.get("endCursor")}
223
+ return None
162
224
 
163
- @limiter.balance_rate_limit(api_type=ApiTypeEnum.graphql.value)
164
225
  def parse_response(self, response: requests.Response, **kwargs) -> Iterable[Mapping]:
165
- if response.status_code is requests.codes.OK:
166
- try:
167
- json_response = response.json()["data"]["products"]["nodes"]
168
- yield from self.produce_records(json_response)
169
- except RequestException as e:
170
- self.logger.warning(f"Unexpected error in `parse_ersponse`: {e}, the actual response data: {response.text}")
226
+ json_response = response.json()
227
+ events = json_response.get("data", {}).get("events", {}).get("nodes", [])
228
+ for event in events:
229
+ if event.get("subjectType") == "PRODUCT" and event.get("subjectId"):
230
+ subject_id = event.get("subjectId", "")
231
+ product_id = int(subject_id.split("/")[-1]) if "/" in subject_id else None
232
+ if product_id:
233
+ yield {
234
+ "id": product_id,
235
+ "deleted_at": event.get("createdAt"),
236
+ "deleted_message": event.get("message"),
237
+ "deleted_description": None,
238
+ "shop_url": self.config.get("shop"),
239
+ }
171
240
 
172
241
 
173
242
  class MetafieldProducts(IncrementalShopifyGraphQlBulkStream):
243
+ parent_stream_class = Products
174
244
  bulk_query: MetafieldProduct = MetafieldProduct
175
245
 
246
+ state_checkpoint_interval = sys.maxsize
247
+
248
+ @property
249
+ def filter_by_state_checkpoint(self) -> bool:
250
+ return True
251
+
176
252
 
177
253
  class ProductImages(IncrementalShopifyGraphQlBulkStream):
254
+ parent_stream_class = Products
178
255
  bulk_query: ProductImage = ProductImage
179
256
 
180
257
 
181
258
  class MetafieldProductImages(IncrementalShopifyGraphQlBulkStream):
259
+ parent_stream_class = Products
182
260
  bulk_query: MetafieldProductImage = MetafieldProductImage
183
261
 
184
262
 
@@ -246,7 +324,6 @@ class MetafieldCollections(IncrementalShopifyGraphQlBulkStream):
246
324
 
247
325
 
248
326
  class BalanceTransactions(IncrementalShopifyStream):
249
-
250
327
  """
251
328
  PaymentsTransactions stream does not support Incremental Refresh based on datetime fields, only `since_id` is supported:
252
329
  https://shopify.dev/api/admin-rest/2021-07/resources/transactions
@@ -274,7 +351,6 @@ class OrderRefunds(IncrementalShopifyNestedStream):
274
351
 
275
352
  class OrderRisks(IncrementalShopifyGraphQlBulkStream):
276
353
  bulk_query: OrderRisk = OrderRisk
277
- # the updated stream works only with >= `2024-04` shopify api version
278
354
 
279
355
 
280
356
  class Transactions(IncrementalShopifySubstream):
@@ -375,19 +451,96 @@ class MetafieldShops(IncrementalShopifyStream):
375
451
  data_field = "metafields"
376
452
 
377
453
 
378
- class CustomerSavedSearch(IncrementalShopifyStream):
379
- api_version = "2022-01"
380
- cursor_field = "id"
381
- order_field = "id"
382
- data_field = "customer_saved_searches"
383
- filter_field = "since_id"
384
-
385
-
386
454
  class CustomerAddress(IncrementalShopifyGraphQlBulkStream):
387
455
  parent_stream_class = Customers
388
456
  bulk_query: CustomerAddresses = CustomerAddresses
389
457
  cursor_field = "id"
390
458
 
391
459
 
392
- class Countries(ShopifyStream):
393
- data_field = "countries"
460
+ class ProfileLocationGroups(IncrementalShopifyGraphQlBulkStream):
461
+ bulk_query: ProfileLocationGroups = ProfileLocationGroups
462
+ filter_field = None
463
+
464
+
465
+ class Countries(HttpSubStream, FullRefreshShopifyGraphQlBulkStream):
466
+ # https://shopify.dev/docs/api/admin-graphql/latest/queries/deliveryProfiles
467
+ _page_cursor = None
468
+ _sub_page_cursor = None
469
+
470
+ _synced_countries_ids = []
471
+
472
+ query = DeliveryProfile
473
+ response_field = "deliveryProfiles"
474
+
475
+ def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, Any]]:
476
+ json_response = response.json().get("data", {})
477
+ if not json_response:
478
+ return None
479
+
480
+ page_info = json_response.get("deliveryProfiles", {}).get("pageInfo", {})
481
+
482
+ sub_page_info = {"hasNextPage": False}
483
+ # only one top page in query
484
+ delivery_profiles_nodes = json_response.get("deliveryProfiles", {}).get("nodes")
485
+ if delivery_profiles_nodes:
486
+ profile_location_groups = delivery_profiles_nodes[0].get("profileLocationGroups")
487
+ if profile_location_groups:
488
+ sub_page_info = (
489
+ # only first one
490
+ profile_location_groups[0].get("locationGroupZones", {}).get("pageInfo", {})
491
+ )
492
+
493
+ if not sub_page_info["hasNextPage"] and not page_info["hasNextPage"]:
494
+ return None
495
+ if sub_page_info["hasNextPage"]:
496
+ # The cursor to retrieve nodes after in the connection. Typically, you should pass the endCursor of the previous page as after.
497
+ self._sub_page_cursor = sub_page_info["endCursor"]
498
+ if page_info["hasNextPage"] and not sub_page_info["hasNextPage"]:
499
+ # The cursor to retrieve nodes after in the connection. Typically, you should pass the endCursor of the previous page as after.
500
+ self._page_cursor = page_info["endCursor"]
501
+ self._sub_page_cursor = None
502
+
503
+ return {
504
+ "cursor": self._page_cursor,
505
+ "sub_cursor": self._sub_page_cursor,
506
+ }
507
+
508
+ def request_body_json(
509
+ self,
510
+ stream_state: Optional[Mapping[str, Any]],
511
+ stream_slice: Optional[Mapping[str, Any]] = None,
512
+ next_page_token: Optional[Mapping[str, Any]] = None,
513
+ ) -> Optional[Mapping[str, Any]]:
514
+ location_group_id = stream_slice["parent"]["profile_location_groups"][0]["locationGroup"]["id"]
515
+ return {
516
+ "query": self.query(location_group_id=location_group_id, location_group_zones_cursor=self._sub_page_cursor).get(
517
+ query_args={
518
+ "cursor": self._page_cursor,
519
+ }
520
+ ),
521
+ }
522
+
523
+ def parse_response(self, response: requests.Response, **kwargs) -> Iterable[Mapping]:
524
+ # TODO: to refactor this using functools + partial, see comment https://github.com/airbytehq/airbyte/pull/55823#discussion_r2016335672
525
+ for node in super().parse_response(response, **kwargs):
526
+ for location_group in node.get("profileLocationGroups", []):
527
+ for location_group_zone in location_group.get("locationGroupZones", {}).get("nodes", []):
528
+ for country in location_group_zone.get("zone", {}).get("countries"):
529
+ country = self._process_country(country)
530
+ if country["id"] not in self._synced_countries_ids:
531
+ self._synced_countries_ids.append(country["id"])
532
+ yield country
533
+
534
+ def _process_country(self, country: Mapping[str, Any]) -> Mapping[str, Any]:
535
+ country["id"] = int(country["id"].split("/")[-1])
536
+
537
+ for province in country.get("provinces", []):
538
+ province["id"] = int(province["id"].split("/")[-1])
539
+ province["country_id"] = country["id"]
540
+
541
+ if country.get("code"):
542
+ country["rest_of_world"] = country["code"]["rest_of_world"] if country["code"].get("rest_of_world") is not None else "*"
543
+ country["code"] = country["code"]["country_code"] if country["code"].get("country_code") is not None else "*"
544
+
545
+ country["shop_url"] = self.config["shop"]
546
+ return country
source_shopify/utils.py CHANGED
@@ -7,12 +7,19 @@ import enum
7
7
  import logging
8
8
  from functools import wraps
9
9
  from time import sleep
10
- from typing import Any, Callable, Dict, List, Mapping, Optional
10
+ from typing import Any, Callable, Dict, Final, List, Mapping, Optional, Union
11
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
11
12
 
12
13
  import requests
14
+
15
+ from airbyte_cdk.models import FailureType
16
+ from airbyte_cdk.sources.streams.http.error_handlers import HttpStatusErrorHandler
13
17
  from airbyte_cdk.sources.streams.http.error_handlers.response_models import ErrorResolution, ResponseAction
14
18
  from airbyte_cdk.utils import AirbyteTracedException
15
- from airbyte_protocol.models import FailureType
19
+
20
+
21
+ # default logger instance
22
+ LOGGER: Final[logging.Logger] = logging.getLogger("airbyte")
16
23
 
17
24
 
18
25
  class ShopifyNonRetryableErrors:
@@ -40,11 +47,6 @@ class ShopifyNonRetryableErrors:
40
47
  failure_type=FailureType.config_error,
41
48
  error_message=f"Stream `{stream}`. Not available or missing.",
42
49
  ),
43
- 500: ErrorResolution(
44
- response_action=ResponseAction.IGNORE,
45
- failure_type=FailureType.config_error,
46
- error_message=f"Stream `{stream}`. Entity might not be available or missing.",
47
- )
48
50
  # extend the mapping with more handable errors, if needed.
49
51
  }
50
52
 
@@ -112,8 +114,6 @@ class ShopifyRateLimiter:
112
114
  on_mid_load: float = 1.5
113
115
  on_high_load: float = 5.0
114
116
 
115
- logger = logging.getLogger("airbyte")
116
-
117
117
  log_message_count = 0
118
118
  log_message_frequency = 3
119
119
 
@@ -124,7 +124,7 @@ class ShopifyRateLimiter:
124
124
  if ShopifyRateLimiter.log_message_count < ShopifyRateLimiter.log_message_frequency:
125
125
  ShopifyRateLimiter.log_message_count += 1
126
126
  else:
127
- ShopifyRateLimiter.logger.info(message)
127
+ LOGGER.info(message)
128
128
  ShopifyRateLimiter.log_message_count = 0
129
129
 
130
130
  def get_response_from_args(*args) -> Optional[requests.Response]:
@@ -138,8 +138,8 @@ class ShopifyRateLimiter:
138
138
  Define wait_time based on load conditions.
139
139
 
140
140
  :: load - represents how close we are to being throttled
141
- - 0.5 is half way through our allowance
142
- - 1 indicates that all of the allowance is used and the api will start rejecting calls
141
+ - 0.5 is half way through our allowance
142
+ - 1 indicates that all of the allowance is used and the api will start rejecting calls
143
143
  :: threshold - is the % cutoff for the rate_limits/load
144
144
  :: wait_time - time to wait between each request in seconds
145
145
 
@@ -325,3 +325,38 @@ class EagerlyCachedStreamState:
325
325
  return func(*args, **kwargs)
326
326
 
327
327
  return decorator
328
+
329
+
330
+ class LimitReducingErrorHandler(HttpStatusErrorHandler):
331
+ """
332
+ Error handler that halves the page size (limit) on each 500 error, down to 1.
333
+ No stream instance required; operates directly on the request URL.
334
+ """
335
+
336
+ def __init__(self, max_retries: int, error_mapping: dict):
337
+ super().__init__(logger=None, max_retries=max_retries, error_mapping=error_mapping)
338
+
339
+ def interpret_response(self, response_or_exception: Optional[Union[requests.Response, Exception]] = None) -> ErrorResolution:
340
+ if isinstance(response_or_exception, requests.Response):
341
+ response = response_or_exception
342
+ if response.status_code == 500:
343
+ # Extract current limit from the URL, default to 250 if not present
344
+ parsed = urlparse(response.request.url)
345
+ query = parse_qs(parsed.query)
346
+ current_limit = int(query.get("limit", ["250"])[0])
347
+ if current_limit > 1:
348
+ new_limit = max(1, current_limit // 2)
349
+ query["limit"] = [str(new_limit)]
350
+ new_query = urlencode(query, doseq=True)
351
+ response.request.url = urlunparse(parsed._replace(query=new_query))
352
+ return ErrorResolution(
353
+ response_action=ResponseAction.RETRY,
354
+ failure_type=FailureType.transient_error,
355
+ error_message=f"Server error 500: Reduced limit to {new_limit} and updating request URL",
356
+ )
357
+ return ErrorResolution(
358
+ response_action=ResponseAction.FAIL,
359
+ failure_type=FailureType.transient_error,
360
+ error_message="Persistent 500 error after reducing limit to 1",
361
+ )
362
+ return super().interpret_response(response_or_exception)
@@ -1,32 +0,0 @@
1
- {
2
- "type": ["null", "object"],
3
- "additionalProperties": true,
4
- "properties": {
5
- "created_at": {
6
- "description": "The date and time when the customer saved search was created.",
7
- "type": ["null", "string"],
8
- "format": "date-time"
9
- },
10
- "id": {
11
- "description": "The unique identifier for the customer saved search.",
12
- "type": ["null", "integer"]
13
- },
14
- "name": {
15
- "description": "The name given to the customer saved search.",
16
- "type": ["null", "string"]
17
- },
18
- "query": {
19
- "description": "The search query string or parameters used for this saved search.",
20
- "type": ["null", "string"]
21
- },
22
- "shop_url": {
23
- "description": "The URL of the shop associated with this customer saved search.",
24
- "type": ["null", "string"]
25
- },
26
- "updated_at": {
27
- "description": "The date and time when the customer saved search was last updated.",
28
- "type": ["null", "string"],
29
- "format": "date-time"
30
- }
31
- }
32
- }
@@ -1,123 +0,0 @@
1
- {
2
- "$schema": "http://json-schema.org/schema#",
3
- "properties": {
4
- "createdAt": {
5
- "description": "The date and time when the product was created.",
6
- "type": "string"
7
- },
8
- "description": {
9
- "description": "The product's description.",
10
- "type": "string"
11
- },
12
- "descriptionHtml": {
13
- "description": "The product's description in HTML format.",
14
- "type": "string"
15
- },
16
- "handle": {
17
- "description": "The unique URL-friendly handle of the product.",
18
- "type": "string"
19
- },
20
- "id": {
21
- "description": "The unique identifier of the product.",
22
- "type": "string"
23
- },
24
- "isGiftCard": {
25
- "description": "Indicates whether the product is a gift card.",
26
- "type": "boolean"
27
- },
28
- "legacyResourceId": {
29
- "description": "The legacy resource ID of the product.",
30
- "type": "string"
31
- },
32
- "mediaCount": {
33
- "description": "The total count of media (images/videos) associated with the product.",
34
- "type": "integer"
35
- },
36
- "onlineStorePreviewUrl": {
37
- "description": "The URL for previewing the product on the online store.",
38
- "type": "string"
39
- },
40
- "onlineStoreUrl": {
41
- "description": "The URL of the product on the online store.",
42
- "type": ["null", "string"]
43
- },
44
- "options": {
45
- "description": "Represents various options available for the product",
46
- "items": {
47
- "properties": {
48
- "id": {
49
- "description": "The unique identifier of the option.",
50
- "type": "string"
51
- },
52
- "name": {
53
- "description": "The name of the option.",
54
- "type": "string"
55
- },
56
- "position": {
57
- "description": "The position of the option.",
58
- "type": "integer"
59
- },
60
- "values": {
61
- "description": "Contains the different values for the options",
62
- "items": {
63
- "description": "The possible values for the option.",
64
- "type": "string"
65
- },
66
- "type": "array"
67
- }
68
- },
69
- "type": "object"
70
- },
71
- "type": "array"
72
- },
73
- "productType": {
74
- "description": "The type or category of the product.",
75
- "type": "string"
76
- },
77
- "publishedAt": {
78
- "description": "The date and time when the product was published.",
79
- "type": ["null", "string"]
80
- },
81
- "shop_url": {
82
- "description": "The URL of the shop where the product is listed.",
83
- "type": "string"
84
- },
85
- "status": {
86
- "description": "The status of the product.",
87
- "type": "string"
88
- },
89
- "tags": {
90
- "description": "Contains tags associated with the product",
91
- "items": {
92
- "description": "The tags associated with the product.",
93
- "type": "string"
94
- },
95
- "type": "array"
96
- },
97
- "title": {
98
- "description": "The title or name of the product.",
99
- "type": "string"
100
- },
101
- "totalInventory": {
102
- "description": "The total inventory count of the product.",
103
- "type": "integer"
104
- },
105
- "totalVariants": {
106
- "description": "The total number of variants available for the product.",
107
- "type": "integer"
108
- },
109
- "tracksInventory": {
110
- "description": "Indicates whether inventory tracking is enabled for the product.",
111
- "type": "boolean"
112
- },
113
- "updatedAt": {
114
- "description": "The date and time when the product was last updated.",
115
- "type": "string"
116
- },
117
- "vendor": {
118
- "description": "The vendor or manufacturer of the product.",
119
- "type": "string"
120
- }
121
- },
122
- "type": "object"
123
- }