Skip to content

Commit

Permalink
Merge pull request #143 from vapor-community/expandable-collection
Browse files Browse the repository at this point in the history
Expandable collection support.
  • Loading branch information
Andrewangeta authored Dec 18, 2021
2 parents 369ccae + c5539e6 commit e1b4bae
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 133 deletions.
59 changes: 8 additions & 51 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,54 +1,11 @@
name: 'Test'

name: test
on:
push: { branches: [ main ] }
pull_request:

push: { branches: [ main ] }
jobs:
linux:
strategy:
fail-fast: false
matrix:
swiftver:
- 'swift:5.2'
- 'swift:5.3'
- 'swift:5.4'
- 'swift:5.5'
- 'swiftlang/swift:nightly-main'
swiftos:
- bionic
- focal
- centos7
- centos8
- amazonlinux2
container: ${{ format('{0}-{1}', matrix.swiftver, matrix.swiftos) }}
runs-on: ubuntu-latest
steps:
- name: Workaround SPM incompatibility with old Git on CentOS 7
if: ${{ contains(matrix.swiftos, 'centos7') }}
run: |
yum install -y make libcurl-devel
git clone https://github.com/git/git -bv2.28.0 --depth 1 && cd git
make prefix=/usr -j all install NO_OPENSSL=1 NO_EXPAT=1 NO_TCLTK=1 NO_GETTEXT=1 NO_PERL=1
- name: Checkout code
uses: actions/checkout@v2
- name: Run tests with Thread Sanitizer
run: swift test --enable-test-discovery --sanitize=thread

macos:
strategy:
fail-fast: false
matrix:
xcode:
- latest-stable
- latest
runs-on: macos-latest
steps:
- name: Select latest available Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ matrix.xcode }}
- name: Checkout code
uses: actions/checkout@v2
- name: Run tests with Thread Sanitizer
run: swift test --enable-test-discovery --sanitize=thread
unit-tests:
uses: vapor/ci/.github/workflows/run-unit-tests.yml@reusable-workflows
with:
with_coverage: false
with_tsan: true
coverage_ignores: '/Tests/'
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 16.1.0 - 2021-12-17
* [#143](https://github.com/vapor-community/stripe-kit/pull/143)
* Adds support for expanding an array of models using `@ExpandableCollection`
* Adds `checkoutSession`, `invoice`, `invoiceItem` and `promotionCode` to `StripeDiscount`.

## 16.0.0 - 2021-12-17
* [#142](https://github.com/vapor-community/stripe-kit/pull/142) ⚠️ Breaking changes ⚠️ Multiple API updates.

Expand Down
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ For example to use the `charges` API, the stripeclient has a property to access

## Expandable objects

StripeKit supports [expandable objects](https://stripe.com/docs/api/expanding_objects) via 2 property wrappers:
StripeKit supports [expandable objects](https://stripe.com/docs/api/expanding_objects) via 3 property wrappers:

`@Expandable` and `@DynamicExpandable`
`@Expandable`, `@DynamicExpandable` and `@ExpandableCollection`

All API routes that can return expanded objects have an extra parameter `expand: [String]?` that allows specifying which objects to expand.

Expand Down Expand Up @@ -119,6 +119,20 @@ stripeclient.applicationFees.retrieve(fee: "fee_1234", expand: ["originatingTran
}
```

### Usage with `@ExpandableCollection`:
1. Expanding an array of `id`s

```swift
stripeClient.retrieve(invoice: "in_12345", expand: ["discounts"])
.flatMap { invoice in
// Access the discounts array as `String`s
invoice.discounts.map { print($0) } // "","","",..

// Access the array of `StripeDiscount`s
invoice.$discounts.compactMap(\.id).map { print($0) } // "di_1","di_2","di_3",...
}
```

## Nuances with parameters and type safety
Stripe has a habit of changing APIs and having dynamic parameters for a lot of their APIs.
To accomadate for these changes, certain routes that take arguments that are `hash`s or `Dictionaries`, are represented by a Swift dictionary `[String: Any]`.
Expand Down
2 changes: 1 addition & 1 deletion Sources/StripeKit/Billing/Invoices/Invoice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public struct StripeInvoice: StripeModel {
/// An arbitrary string attached to the object. Often useful for displaying to users. Referenced as ‘memo’ in the Dashboard.
public var description: String?
/// The discounts applied to the invoice. Line item discounts are applied before invoice discounts. Use expand[]=discounts to expand each discount.
public var discounts: [String]?
@ExpandableCollection<StripeDiscount> public var discounts: [String]?
/// The date on which payment for this invoice is due. This value will be `null` for invoices where `billing=charge_automatically`.
public var dueDate: Date?
/// Ending customer balance after the invoice is finalized. Invoices are finalized approximately an hour after successful webhook delivery or when payment collection is attempted for the invoice. If the invoice has not been finalized yet, this will be null.
Expand Down
2 changes: 1 addition & 1 deletion Sources/StripeKit/Billing/Quotes/Quote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public struct StripeQuote: StripeModel {
/// A description that will be displayed on the quote PDF.
public var description: String?
/// The discounts applied to this quote.
public var discounts: [String]?
@ExpandableCollection<StripeDiscount> public var discounts: [String]?
/// The date on which the quote will be canceled if in `open` or `draft` status. Measured in seconds since the Unix epoch.
public var expiresAt: Date?
/// A footer that will be displayed on the quote PDF.
Expand Down
12 changes: 11 additions & 1 deletion Sources/StripeKit/Products/Discounts/Discount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@

import Foundation

/// The [Discount Object](https://stripe.com/docs/api/discounts/object).
/// The [Discount Object](https://stripe.com/docs/api/discounts/object)
public struct StripeDiscount: StripeModel {
/// The ID of the discount object. Discounts cannot be fetched by ID. Use expand[]=discounts in API calls to expand discount IDs in an array.
public var id: String
/// String representing the object’s type. Objects of the same type share the same value.
public var object: String
/// Hash describing the coupon applied to create this discount.
Expand All @@ -22,4 +24,12 @@ public struct StripeDiscount: StripeModel {
public var start: Date?
/// The subscription that this coupon is applied to, if it is applied to a particular subscription.
public var subscription: String?
/// The Checkout session that this coupon is applied to, if it is applied to a particular session in payment mode. Will not be present for subscription mode.
public var checkoutSession: String?
/// The invoice that the discount’s coupon was applied to, if it was applied directly to a particular invoice.
public var invoice: String?
/// The invoice item id (or invoice line item id for invoice line items of type=‘subscription’) that the discount’s coupon was applied to, if it was applied directly to a particular invoice item or invoice line item.
public var invoiceItem: String?
/// The promotion code applied to create this discount.
@Expandable<StripePromotionCode> public var promotionCode: String?
}
160 changes: 83 additions & 77 deletions Sources/StripeKit/Shared Models/StripeExpandable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
import Foundation

extension KeyedDecodingContainer {
// public func decode<U>(_ type: ExpandableCollection<[U]>.Type, forKey key: Self.Key) throws -> ExpandableCollection<[U]> where U: StripeModel {
// return try decodeIfPresent(type, forKey: key) ?? ExpandableCollection<[U]>()
// }
public func decode<U>(_ type: ExpandableCollection<U>.Type, forKey key: Self.Key) throws -> ExpandableCollection<U> where U: StripeModel {
return try decodeIfPresent(type, forKey: key) ?? ExpandableCollection<U>()
}

public func decode<U>(_ type: Expandable<U>.Type, forKey key: Self.Key) throws -> Expandable<U> where U: StripeModel {
return try decodeIfPresent(type, forKey: key) ?? Expandable<U>()
Expand All @@ -35,20 +35,14 @@ public struct Expandable<Model: StripeModel>: StripeModel {
}

public init(from decoder: Decoder) throws {
let codingPath = decoder.codingPath
do {
let container = try decoder.singleValueContainer()
if let container = try decoder.singleValueContainerIfPresentAndNotNull() {
do {
if container.decodeNil() {
_state = .empty
} else {
_state = .unexpanded(try container.decode(String.self))
}
self._state = .unexpanded(try container.decode(String.self))
} catch DecodingError.typeMismatch(let type, _) where type is String.Type {
_state = .expanded(try container.decode(Model.self))
self._state = .expanded(try container.decode(Model.self))
}
} catch DecodingError.keyNotFound(_, let context) where context.codingPath.count == codingPath.count {
_state = .empty
} else {
self._state = .empty
}
}

Expand Down Expand Up @@ -166,66 +160,78 @@ public struct DynamicExpandable<A: StripeModel, B: StripeModel>: StripeModel {
}
}

//@propertyWrapper
//public struct ExpandableCollection<[Model]>: StripeModel {
// private enum ExpandableState {
// case unexpanded([String])
// indirect case expanded([StripeModel])
// case empty
// }
//
// public init() {
// self._state = .empty
// }
//
// public init(from decoder: Decoder) throws {
// let codingPath = decoder.codingPath
// do {
// var container = try decoder.unkeyedContainer()
// do {
// if try container.decodeNil() {
// _state = .empty
// } else {
// _state = .unexpanded(try container.decode([String].self))
// }
// } catch DecodingError.typeMismatch(let type, _) where type is [String].Type {
// _state = .expanded(try container.decode([Model].self))
// }
// } catch DecodingError.keyNotFound(_, let context) where context.codingPath.count == codingPath.count {
// _state = .empty
// }
// }
//
// private var _state: ExpandableState
//
// public func encode(to encoder: Encoder) throws {
// var container = encoder.unkeyedContainer()
//
// switch _state {
// case let .unexpanded(ids):
// try container.encode(ids)
// case let .expanded(models):
// try container.encode(models)
// default:
// try container.encodeNil()
// }
// }
//
// public var wrappedValue: [String]? {
// switch _state {
// case .unexpanded(let ids):
// return ids
// case .expanded(_), .empty:
// return nil
// }
// }
//
// public var projectedValue: [Model]? {
// switch _state {
// case .unexpanded(_), .empty:
// return nil
// case .expanded(let models):
// return models
// }
// }
//}
@propertyWrapper
public struct ExpandableCollection<Model: StripeModel>: StripeModel {
private enum ExpandableState {
case unexpanded([String])
indirect case expanded([Model])
case empty
}

public init() {
self._state = .empty
}

public init(from decoder: Decoder) throws {
if let container = try decoder.singleValueContainerIfPresentAndNotNull() {
do {
self._state = .unexpanded(try container.decode([String].self))
} catch DecodingError.typeMismatch(let type, _) where type is String.Type {
self._state = .expanded(try container.decode([Model].self))
}
} else {
self._state = .empty
}
}

private var _state: ExpandableState

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()

switch _state {
case let .unexpanded(ids):
try container.encode(ids)
case let .expanded(models):
try container.encode(models)
default:
try container.encodeNil()
}
}

public var wrappedValue: [String]? {
switch _state {
case .unexpanded(let ids):
return ids
case .expanded(_), .empty:
return nil
}
}

public var projectedValue: [Model]? {
switch _state {
case .unexpanded(_), .empty:
return nil
case .expanded(let models):
return models
}
}
}

internal extension Decoder {
func singleValueContainerIfPresentAndNotNull() throws -> SingleValueDecodingContainer? {
do {
let container = try self.singleValueContainer()

if container.decodeNil() {
return nil
}
return container
}
catch DecodingError.keyNotFound(_, let context)
where context.codingPath.count == self.codingPath.count
{
return nil
}
}
}
32 changes: 32 additions & 0 deletions Tests/StripeKitTests/ExpandableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -356,4 +356,36 @@ class ExpandableTests: XCTestCase {
let sess = try JSONDecoder().decode(StripeSession.self, from: session)
_ = try JSONDecoder().decode(StripeSession.self, from: JSONEncoder().encode(sess))
}

func testExpandableCollection_decodesProperly() throws {

struct SimpleType: StripeModel {
@ExpandableCollection<StripeDiscount> var discounts: [String]?
}

let discounts = """
{
"discounts": [
{
"id": "di_1234",
"object": "discount"
},
{
"id": "di_12345",
"object": "discount"
},
]
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
decoder.keyDecodingStrategy = .convertFromSnakeCase

let simple = try decoder.decode(SimpleType.self, from: discounts)

XCTAssertEqual(simple.$discounts?.count, 2)
XCTAssertEqual(simple.$discounts?[0].id, "di_1234")
XCTAssertEqual(simple.$discounts?[1].id, "di_12345")
}
}

0 comments on commit e1b4bae

Please sign in to comment.