Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BIT-162: Add sync API #22

Merged
merged 11 commits into from
Sep 15, 2023
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

extension JSONDecoder {
/// The default `JSONDecoder` used to decode JSON payloads throughout the app.
static let defaultDecoder: JSONDecoder = {
let dateFormatterWithFractionalSeconds = ISO8601DateFormatter()
dateFormatterWithFractionalSeconds.formatOptions = [.withInternetDateTime, .withFractionalSeconds]

let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime]

let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = .custom { dateDecoder in
let container = try dateDecoder.singleValueContainer()
let stringValue = try container.decode(String.self)

// ISO8601DateFormatter supports ISO 8601 dates with or without fractional seconds, but
// not both at the same time ๐Ÿ™ƒ. Since the API contains both formats with the more
// common containing fractional seconds, attempt to parse that first and fall back to
// parsing without fractional seconds.
if let date = dateFormatterWithFractionalSeconds.date(from: stringValue) {
return date
} else if let date = dateFormatter.date(from: stringValue) {
return date
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Unable to decode date with value '\(stringValue)'"
)
}
}
return jsonDecoder
}()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import XCTest

@testable import BitwardenShared

class JSONDecoderBitwardenTests: BitwardenTestCase {
// MARK: Tests

/// `JSONDecoder.defaultDecoder` can decode ISO8601 dates with fractional seconds.
func test_decode_iso8601DateWithFractionalSeconds() {
let subject = JSONDecoder.defaultDecoder

XCTAssertEqual(
try subject
.decode(Date.self, from: Data(#""2023-08-18T21:33:31.6366667Z""#.utf8)),
Date(timeIntervalSince1970: 1_692_394_411.636)
)
XCTAssertEqual(
try subject
.decode(Date.self, from: Data(#""2023-06-14T13:51:24.45Z""#.utf8)),
Date(timeIntervalSince1970: 1_686_750_684.450)
)
}

/// `JSONDecoder.defaultDecoder` can decode ISO8601 dates without fractional seconds.
func test_decode_iso8601DateWithoutFractionalSeconds() {
let subject = JSONDecoder.defaultDecoder

XCTAssertEqual(
try subject
.decode(Date.self, from: Data(#""2023-08-25T21:33:00Z""#.utf8)),
Date(timeIntervalSince1970: 1_692_999_180)
)
XCTAssertEqual(
try subject
.decode(Date.self, from: Data(#""2023-07-12T15:46:12Z""#.utf8)),
Date(timeIntervalSince1970: 1_689_176_772)
)
}

/// `JSONDecoder.defaultDecoder` will throw an error if an invalid or unsupported date format is
/// encountered.
func test_decode_invalidDateThrowsError() {
let subject = JSONDecoder.defaultDecoder

XCTAssertThrowsError(
try subject.decode(Date.self, from: Data(#""2023-08-23""#.utf8))
) { error in
XCTAssertTrue(error is DecodingError)
guard case let .dataCorrupted(context) = error as? DecodingError else {
return XCTFail("Expected error to be DecodingError.dataCorrupted")
}
XCTAssertEqual(context.debugDescription, "Unable to decode date with value '2023-08-23'")
}

XCTAssertThrowsError(
try subject.decode(Date.self, from: Data(#""๐Ÿ”’""#.utf8))
) { error in
XCTAssertTrue(error is DecodingError)
guard case let .dataCorrupted(context) = error as? DecodingError else {
return XCTFail("Expected error to be DecodingError.dataCorrupted")
}
XCTAssertEqual(context.debugDescription, "Unable to decode date with value '๐Ÿ”’'")
}

XCTAssertThrowsError(
try subject.decode(Date.self, from: Data(#""date""#.utf8))
) { error in
XCTAssertTrue(error is DecodingError)
guard case let .dataCorrupted(context) = error as? DecodingError else {
return XCTFail("Expected error to be DecodingError.dataCorrupted")
}
XCTAssertEqual(context.debugDescription, "Unable to decode date with value 'date'")
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐ŸŽจ Instead of repeating each time with different values what do you think on having something like:

Suggested change
XCTAssertThrowsError(
try subject.decode(Date.self, from: Data(#""2023-08-23""#.utf8))
) { error in
XCTAssertTrue(error is DecodingError)
guard case let .dataCorrupted(context) = error as? DecodingError else {
return XCTFail("Expected error to be DecodingError.dataCorrupted")
}
XCTAssertEqual(context.debugDescription, "Unable to decode date with value '2023-08-23'")
}
XCTAssertThrowsError(
try subject.decode(Date.self, from: Data(#""๐Ÿ”’""#.utf8))
) { error in
XCTAssertTrue(error is DecodingError)
guard case let .dataCorrupted(context) = error as? DecodingError else {
return XCTFail("Expected error to be DecodingError.dataCorrupted")
}
XCTAssertEqual(context.debugDescription, "Unable to decode date with value '๐Ÿ”’'")
}
XCTAssertThrowsError(
try subject.decode(Date.self, from: Data(#""date""#.utf8))
) { error in
XCTAssertTrue(error is DecodingError)
guard case let .dataCorrupted(context) = error as? DecodingError else {
return XCTFail("Expected error to be DecodingError.dataCorrupted")
}
XCTAssertEqual(context.debugDescription, "Unable to decode date with value 'date'")
}
func expectFail(dateFrom value: String) {
XCTAssertThrowsError(
try subject.decode(Date.self, from: Data(value))
) { error in
XCTAssertTrue(error is DecodingError)
guard case let .dataCorrupted(context) = error as? DecodingError else {
return XCTFail("Expected error to be DecodingError.dataCorrupted")
}
XCTAssertEqual(context.debugDescription, "Unable to decode date with value \(value)")
}
}
expectFail(dateFrom: #""2023-08-23""#.utf8)
expectFail(dateFrom: #""๐Ÿ”’""#.utf8)
expectFail(dateFrom: #""date""#.utf8)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion! I'll update this.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation
import Networking

extension JSONResponse {
/// The decoder used by default to decode JSON responses from the API.
static var decoder: JSONDecoder { .defaultDecoder }
}
17 changes: 17 additions & 0 deletions BitwardenShared/Core/Tools/Models/API/SendFileModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// API model for a file send.
///
struct SendFileModel: Codable, Equatable {
// MARK: Properties

/// The filename of the file to send.
let fileName: String?

/// The send file identifier.
let id: String?

/// The size of the file.
let size: Int?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿค” Just in case, bear in mind that currently the size is a string because of some problems when converting the number and server side is allowing expecting an string.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'll convert it to a string.


///
let sizeName: String?
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fedemkr I wasn't sure what this field was used for based on the name. Is this a formatted string of the size (e.g. "5 MB") or something else?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. You can find the usage here.
And here the Server side code that converts the size into human readable string SizeName.

}
11 changes: 11 additions & 0 deletions BitwardenShared/Core/Tools/Models/API/SendTextModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// API model for a text send.
///
struct SendTextModel: Codable, Equatable {
// MARK: Properties

/// Whether the text is hidden by default.
let hidden: Bool

/// The text in the send.
let text: String?
}
9 changes: 9 additions & 0 deletions BitwardenShared/Core/Tools/Models/Enum/SendType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/// An enum describing the type of data in a send.
///
enum SendType: Int, Codable {
/// The send contains text data.
case text = 0

/// The send contains an attached file.
case file = 1
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

@testable import BitwardenShared

extension SendResponseModel {
static func fixture(
accessCount: Int = 0,
accessId: String? = nil,
deletionDate: Date,
disabled: Bool = false,
expirationDate: Date? = nil,
file: SendFileModel? = nil,
hideEmail: Bool = false,
id: String = UUID().uuidString,
key: String? = nil,
maxAccessCount: Int? = nil,
name: String? = nil,
notes: String? = nil,
object: String? = nil,
password: String? = nil,
revisionDate: Date,
text: SendTextModel? = nil,
type: SendType? = nil
) -> SendResponseModel {
self.init(
accessCount: accessCount,
accessId: accessId,
deletionDate: deletionDate,
disabled: disabled,
expirationDate: expirationDate,
file: file,
hideEmail: hideEmail,
id: id,
key: key,
maxAccessCount: maxAccessCount,
name: name,
notes: notes,
object: object,
password: password,
revisionDate: revisionDate,
text: text,
type: type
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation

/// API response model for a send.
///
struct SendResponseModel: Codable, Equatable {
// MARK: Properties

/// The number of times the send has been accessed.
let accessCount: Int

/// The identifier used to access the send.
let accessId: String?

/// The deletion date of the send.
let deletionDate: Date

/// Whether the send is disabled.
let disabled: Bool

/// The expiration date of the send.
let expirationDate: Date?

/// The file included in the send.
let file: SendFileModel?

/// Whether the user's email address should be hidden from recipients.
let hideEmail: Bool
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โ›๏ธ This can be nil, although it's transformed into false when that happens in the process of creating the SendData mobile object. I don't know if you plan to do that directly when decoding.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know, I'll probably make it optional for now, but may either change that in the future or default it to false when transforming it into an app model.


/// The send's identifier.
let id: String

/// The key used to decrypt the send.
let key: String?

/// The maximum number of times the send can be accessed.
let maxAccessCount: Int?

/// The name of the send.
let name: String?

/// Notes about the send.
let notes: String?

/// The response object type.
let object: String?

/// An optional password used to access the send.
let password: String?

/// The date of the sends's last revision.
let revisionDate: Date

/// The text included in the send.
let text: SendTextModel?

/// The type of data in the send.
let type: SendType?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โ›๏ธ type is never nil

}
23 changes: 23 additions & 0 deletions BitwardenShared/Core/Vault/Models/API/CipherCardModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// API model for a card cipher.
///
struct CipherCardModel: Codable, Equatable {
// MARK: Properties

/// The card's brand.
let brand: String?

/// The card's cardholder name.
let cardholderName: String?

/// The card's code.
let code: String?

/// The card's expiration month.
let expMonth: String?

/// The card's expiration year.
let expYear: String?

/// The card's number.
let number: String?
}
17 changes: 17 additions & 0 deletions BitwardenShared/Core/Vault/Models/API/CipherFieldModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// API model for a cipher custom field.
///
struct CipherFieldModel: Codable, Equatable {
// MARK: Properties

/// The type of value that the field is linked to for a linked field type.
let linkedId: LinkedIdType?

/// The field's name.
let name: String?

/// The field's type.
let type: FieldType?

/// The field's value.
let value: String?
}
59 changes: 59 additions & 0 deletions BitwardenShared/Core/Vault/Models/API/CipherIdentityModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/// API model for a cipher identity.
///
struct CipherIdentityModel: Codable, Equatable {
// MARK: Properties

/// The identity's address line 1.
let address1: String?

/// The identity's address line 2.
let address2: String?

/// The identity's address line 3.
let address3: String?

/// The identity's city.
let city: String?

/// The identity's company.
let company: String?

/// The identity's country.
let country: String?

/// The identity's email.
let email: String?

/// The identity's first name.
let firstName: String?

/// The identity's last name.
let lastName: String?

/// The identity's license number.
let licenseNumber: String?

/// The identity's middle name.
let middleName: String?

/// The identity's passport number.
let passportNumber: String?

/// The identity's phone number.
let phone: String?

/// The identity's postal code.
let postalCode: String?

/// The identity's SSN.
let ssn: String?

/// The identity's state.
let state: String?

/// The identity's title.
let title: String?

/// The identity's username.
let username: String?
}
28 changes: 28 additions & 0 deletions BitwardenShared/Core/Vault/Models/API/CipherLoginModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

/// API model for a cipher login.
///
struct CipherLoginModel: Codable, Equatable {
// MARK: Properties

/// Whether the login should be autofilled when the page loads.
let autofillOnPageLoad: Bool?

/// The login's password.
let password: String?

/// The date of the password's last revision.
let passwordRevisionDate: Date?

/// The login's TOTP details.
let totp: String?

/// The login's URI.
let uri: String?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โ›๏ธ This can be removed (or transformed into a computed property). It's just a computed property based on the first one on uris (server code)


/// The login's list of URI details.
let uris: [CipherLoginUriModel]?

/// The login's username.
let username: String?
}
Loading