diff --git a/README.md b/README.md index 2f68ddbe..74c63ff1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # skyflow-iOS --- -Skyflow’s iOS SDK can be used to securely collect, tokenize, and display sensitive data in the mobile without exposing your front-end infrastructure to sensitive data. +Skyflow's iOS SDK can be used to securely collect, tokenize, and display sensitive data in the mobile without exposing your front-end infrastructure to sensitive data. [![CI](https://img.shields.io/static/v1?label=CI&message=passing&color=green?style=plastic&logo=github)](https://github.com/skyflowapi/skyflow-ios/actions) [![GitHub release](https://img.shields.io/github/v/release/skyflowapi/skyflow-ios.svg)](https://github.com/skyflowapi/skyflow-ios/releases) @@ -136,7 +136,6 @@ For `env` parameter, there are 2 accepted values in Skyflow.Env --- # Securely collecting data client-side -- [**Inserting data into the vault**](#insert-data-into-the-vault) - [**Using Skyflow Elements to collect data**](#using-skyflow-elements-to-collect-data) - [**Using Skyflow Elements to update data**](#using-skyflow-elements-to-update-data) - [**Using validations on Collect Elements**](#validations) @@ -144,75 +143,6 @@ For `env` parameter, there are 2 accepted values in Skyflow.Env - [**UI Error for Collect Elements**](#ui-error-for-collect-elements) - [**Set and Clear value for Collect Elements (DEV ENV ONLY)**](#set-and-clear-value-for-collect-elements-dev-env-only) -## Insert data into the vault - - To insert data into your vault, use the `skyflowClient.insert()` method. - ```swift - insert(records: [String: Any], options: InsertOptions?= InsertOptions() , callback: Skyflow.Callback) - ``` - The `insert()` method requires a records parameter. - The `records` parameter takes an array of records to insert into the vault. - -#### Insert call example - ```swift - let insertCallback = InsertCallback() //A Custom callback using Skyflow.Callback - skyflowClient.insert( - records: [ - "records": [ - [ - "table": "cards", - "fields": [ - "cardNumber": "41111111111", - "cvv": "123", - ] - ] - ] - ], - callback: insertCallback -) -``` -Skyflow returns tokens for the record you just inserted. -```json -{ - "records": [ { - "table": "cards", - "fields": { - "cardNumber": "f37186-e7e2-466f-91e5-48e12c2bcbc1", - "cvv": "1989cb56-63da-4482-a2df-1f74cd0dd1a5" - } - }] -} -``` - - The `options` parameter takes a Skyflow.InsertOptions object. - - InsertOptions includes a `tokens` boolean that controls whether you receive tokens after inserting a record into the vault. - -InsertOptions also supports the **upsert** feature, that lets you conditionally insert or update an existing record by specifying the unique column to use as the unique value. - -For example, if you specify the ‘customer_id’ column to use for upsert, and the same customer_id already exists, the existing record will update. If the customer_id doesn't exist, the vault creates a new record. - -#### Insert call example -```swift -let records = [ - "records" : [ - [ - "table": "customers", //The table where you are inserting the record. - "fields": [ - "name" : "Francis", - "customer_id" : "12345" - ] - ] - ] -] - -let upsertOptions = [["table": "customers", "column": "customer_id"]] as [[String : Any]] -let insertOptions = Skyflow.InsertOptions(tokens: false, upsert: upsertOptions) -let insertCallback = InsertCallback() //Custom callback - implementation of Skyflow.Callback - -skyflowClient.insert(records: records, options: insertOptions, callback: insertCallback) -``` - ## Using Skyflow Elements to collect data **Skyflow Elements** provide developers with pre-built form elements to securely collect sensitive data client-side. This reduces your PCI compliance scope by not exposing your front-end application to sensitive data. Follow the steps below to securely collect data with Skyflow Elements in your application. @@ -235,16 +165,19 @@ let collectElementInput = Skyflow.CollectElementInput( inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + iconStyles: Skyflow.Styles, // optional styles that will be applied to the card icon of the collect element label: String, // optional label for the form element placeholder: String, // optional placeholder for the form element altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element - validations: ValidationSet, // optional set of validations for the input element type: Skyflow.ElementType, // Skyflow.ElementType enum + validations: ValidationSet, // optional set of validations for the input element + skyflowId: String, // optional, the skyflow_id of the record to update ) ``` -The `table` and `column` fields indicate which table and column in the vault the Element corresponds to. **Note**: +The `table` and `column` fields indicate which table and column in the vault the Element corresponds to. +**Note**: - Use dot delimited strings to specify columns nested inside JSON fields (e.g. `address.street.line1`) -- `table` and `column` are optional only if the element is being used in invokeConnection() + The `inputStyles` parameter accepts a Skyflow.Styles object which consists of multiple `Skyflow.Styles` objects which should be applied to the form element in the following states: @@ -287,7 +220,7 @@ let styles = Skyflow.Styles( empty: style, // optional focus: style, // optional invalid: style, // optional - requiredAsterisk: style // optional + requiredAstrisk: style // optional ) ``` @@ -295,7 +228,7 @@ The `labelStyles` and `errorTextStyles` fields accept the above mentioned `Skyfl The states that are available for `labelStyles` are `base` and `focus`. -`requiredAsterisk`: Styles applied for the Asterisk symbol in the label. Defaults to red. +`requiredAstrisk`: Styles applied for the Asterisk symbol in the label. Defaults to red. The state that is available for `errorTextStyles` is only the `base` state, it shows up when there is some error in the collect element. @@ -307,7 +240,7 @@ The parameters in `Skyflow.Style` object that are respected for `label` and `err Other parameters in the `Skyflow.Style` object are ignored for `label` and `errorText` text views. -Finally, the `type` parameter takes a Skyflow.ElementType. Each type applies the appropriate regex and validations to the form element. There are currently 5 types: +Finally, the `type` parameter takes a Skyflow.ElementType. Each type applies the appropriate regex and validations to the form element. There are currently 8 types: - `INPUT_FIELD` - `CARDHOLDER_NAME` - `CARD_NUMBER` @@ -411,11 +344,13 @@ let collectElementInput = Skyflow.CollectElementInput( inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + iconStyles: Skyflow.Styles, // optional styles that will be applied to the card icon of the collect element label: String, // optional label for the form element placeholder: String, // optional placeholder for the form element altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element - validations: ValidationSet, // optional set of validations for the input element type: Skyflow.ElementType, // Skyflow.ElementType enum + validations: ValidationSet, // optional set of validations for the input element + skyflowId: String, // optional, the skyflow_id of the record to update ) let collectElementOptions = Skyflow.CollectElementOptions( @@ -451,18 +386,32 @@ func clearFieldsOnSubmit(_ elements: [TextField]) { ``` #### Step 4 : Collect data from Elements -When you submit the form, call the `collect(options: Skyflow.CollectOptions? = nil, callback: Skyflow.Callback)` method on the container object. The options parameter takes a `Skyflow.CollectOptions` object as shown below: +When you submit the form, call the `collect(callback: Skyflow.CollectCallback, options: Skyflow.CollectOptions? = Skyflow.CollectOptions())` method on the container object. The options parameter takes a `Skyflow.CollectOptions` object as shown below: ```swift // Non-PCI records -let nonPCIRecords = ["table": "persons", "fields": [["gender": "MALE"]]] +let nonPCIRecords = Skyflow.AdditionalFields(records: [ + Skyflow.AdditionalFieldsRecord(tableName: "persons", data: ["gender": "MALE"]) +]) // Upsert -let upsertOptions = [["table": "cards", "column": "cardNumber"]] as [[String : Any]] -// Send the non-PCI records as additionalFields of InsertOptions (optional) and apply upsert using `upsert` field of InsertOptions (optional) - -let options = Skyflow.CollectOptions(tokens: true, additionalFields: nonPCIRecords) - -//Custom callback - implementation of Skyflow.callback -let insertCallback = InsertCallback() +let upsertOptions = [Skyflow.UpsertOption(tableName: "cards", uniqueColumns: ["cardNumber"], updateType: .UPDATE)] +// Send the non-PCI records as additionalFields of CollectOptions (optional) and apply upsert using `upsert` field of CollectOptions (optional) + +let options = Skyflow.CollectOptions(additionalFields: nonPCIRecords) + +let insertCallback = Skyflow.CollectCallback( + onSuccess: { (response: Skyflow.CollectResponse) in + for record in response.records { + if let error = record.error { + print("failed:", error, "httpCode:", record.httpCode) + } else { + print("success:", record) + } + } + }, + onFailure: { (skyflowError: Skyflow.SkyflowError) in + print(skyflowError.httpCode, skyflowError.message, skyflowError.grpcCode ?? "", skyflowError.httpStatus ?? "", skyflowError.details ?? "") + } +) container?.collect(callback: insertCallback, options: options) ``` @@ -514,47 +463,103 @@ let skyflowElement = container?.create(input: input, options: requiredOption) // Can interact with this object as a normal UIView Object and add to View // Non-PCI records -let nonPCIRecords = ["table": "persons", "fields": [["gender": "MALE"]]] +let nonPCIRecords = Skyflow.AdditionalFields(records: [ + Skyflow.AdditionalFieldsRecord(tableName: "persons", data: ["gender": "MALE"]) +]) //Upsert options - let upsertOptions = [["table": "cards", "column": "cardNumber"]] as [[String : Any]] + let upsertOptions = [Skyflow.UpsertOption(tableName: "cards", uniqueColumns: ["cardNumber"], updateType: .UPDATE)] // Send the Non-PCI records as additionalFields of CollectOptions (optional) and apply upsert using optional field `upsert` of CollectOptions. -let collectOptions = Skyflow.CollectOptions(tokens: true, additionalFields: nonPCIRecords, upsert: upsertOptions) - - -//Implement a custom Skyflow.Callback to call on Insertion success/failure. -public class InsertCallback: Skyflow.Callback { - public func onSuccess(_ responseBody: Any) { - print(responseBody) - } - public func onFailure(_ error: Any) { - print(error) - } -} - - -// Initialize custom Skyflow.Callback. -let insertCallback = InsertCallback() +let collectOptions = Skyflow.CollectOptions(additionalFields: nonPCIRecords, upsert: upsertOptions) + + +// Initialize a Skyflow.CollectCallback - required by CollectContainer's collect(callback:options:). +// It parses onSuccess into a typed Skyflow.CollectResponse, and onFailure into a typed +// Skyflow.SkyflowError whenever the vault returns that structured shape (see below); +// otherwise onFailure receives the raw error (e.g. a validation SkyflowError) unchanged. +let insertCallback = Skyflow.CollectCallback( + onSuccess: { (response: Skyflow.CollectResponse) in + for record in response.records { + if let error = record.error { + print("failed:", error, "httpCode:", record.httpCode) + } else { + print("success:", record) + } + } + }, + onFailure: { (skyflowError: Skyflow.SkyflowError) in + print(skyflowError.httpCode, skyflowError.message, skyflowError.grpcCode ?? "", skyflowError.httpStatus ?? "", skyflowError.details ?? "") + } +) // Call collect method on CollectContainer. container?.collect(callback: insertCallback, options: collectOptions) ``` #### Skyflow returns tokens for the record you just inserted: -``` +```json { - "records": [ { - "table": "cards", - "fields": { - "cardNumber": "f3907186-e7e2-466f-91e5-48e12c2bcbc1" + "records": [ + { + "tableName": "cards", + "skyflowId": "f1714ef8-8deb-489a-a18d-77e0e007f403", + "tokens": { + "cardNumber": [{"token": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 + }, + { + "tableName": "persons", + "skyflowId": "77dc3caf-c452-49e1-8625-07219d7567bf", + "tokens": { + "gender": [{"token": "12f670af-6c7d-4837-83fb-30365fbc0b1e", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 } - }, { - "table": "persons", - "fields": { - "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", + ] +} +``` +**Note:** Successful and failed records are both returned in the same `records` array, each carrying its own `httpCode`. + +#### Sample partial error response: +Successful and failed records are both returned together in the same `records` array, each carrying its own `httpCode`: +```json +{ + "records": [ + { + "tableName": "cards", + "skyflowId": "f1714ef8-8deb-489a-a18d-77e0e007f403", + "tokens": { + "cardNumber": [{"token": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 + }, + { + "error": "Invalid request. Table name table not present for record. Specify a valid table name.", + "skyflowId": null, + "tableName": "", + "httpCode": 400 } - }] + ] +} +``` + +#### If the entire request fails (for example, the vault itself can't be found): +This is the API's raw wire-format error - `onFailure` doesn't hand you this dictionary as-is, it's normalized into a `Skyflow.SkyflowError` object with these same fields as flat properties (no `error` wrapper key): +```json +{ + "grpcCode": 5, + "httpCode": 404, + "message": "Invalid request. Vault not found for vaultID: sd0ff0b064c04faabd4d392512b2e5. Specify a valid vaultID. - request-id: cb397-8521-42c2-870c-92dbeec", + "httpStatus": "Not Found", + "details": [] + } +``` +`Skyflow.CollectCallback`/`Skyflow.RevealCallback`'s `onFailure` is typed `(Skyflow.SkyflowError) -> Void` - this shape (and every other failure, including client-side validation) is always delivered as a `Skyflow.SkyflowError`, with `httpCode`, `message`, `grpcCode`, `httpStatus`, and `details` all available as direct properties (`grpcCode`/`httpStatus`/`details` are only populated for this whole-request-failure shape - `nil` for validation failures): +```swift +onFailure: { (skyflowError: Skyflow.SkyflowError) in + print(skyflowError.httpCode, skyflowError.message, skyflowError.grpcCode ?? "", skyflowError.httpStatus ?? "", skyflowError.details ?? "") } ``` @@ -584,7 +589,7 @@ let collectElementInput = Skyflow.CollectElementInput( altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element validations: ValidationSet, // optional set of validations for the input element type: Skyflow.ElementType, // Skyflow.ElementType enum - skyflowID: String, // The skyflow_id of the record to be updated. + skyflowId: String, // The skyflow_id of the record to be updated. ) ``` @@ -592,7 +597,6 @@ The `table` and `column` fields indicate which table and column in the vault the **Note**: - Use dot delimited strings to specify columns nested inside JSON fields (e.g. `address.street.line1`) -- `table` and `column` are optional only if the element is being used in invokeConnection() Along with `CollectElementInput`, you can define other options in the `CollectElementOptions` object which is described below. @@ -629,25 +633,38 @@ func clearFieldsOnSubmit(_ elements: [TextField]) { ### Step 4 : Update data from Elements When the form is ready to submit, call the `collect(options?)` method on the container object. The `options` parameter takes a object of optional parameters as shown below: -- `tokens`: indicates whether tokens for the collected data should be returned or not. Defaults to 'true' -- `additionalFields`: Non-PCI elements data to update or insert into the vault which should be in the records object format. -- `upsert`: To support upsert operations while collecting data from Skyflow elements, pass the table and column marked as unique in the table. +- `additionalFields`: A `Skyflow.AdditionalFields` object - non-PCI records to update or insert into the vault alongside whatever's collected from the mounted elements. +- `upsert`: An array of `Skyflow.UpsertOption` objects to support upsert while collecting data from Skyflow elements. Each option specifies the `tableName`, the `uniqueColumns` used to match existing records, and an optional `updateType` (`UpdateType.UPDATE` merges the new fields into the matched record, `UpdateType.REPLACE` replaces it). ```swift // Non-PCI records -let nonPCIRecords = ["table": "persons", "fields": [["gender": "MALE", "skyflowID": "value"]]] +let nonPCIRecords = Skyflow.AdditionalFields(records: [ + Skyflow.AdditionalFieldsRecord(tableName: "persons", data: ["gender": "MALE"], skyflowId: "value") +]) // Upsert -let upsertOptions = [["table": "cards", "column": "cardNumber"]] as [[String : Any]] -// Send the non-PCI records as additionalFields of InsertOptions (optional) and apply upsert using `upsert` field of InsertOptions (optional) - -let options = Skyflow.CollectOptions(tokens: true, additionalFields: nonPCIRecords) - -//Custom callback - implementation of Skyflow.callback -let insertCallback = InsertCallback() +let upsertOptions = [Skyflow.UpsertOption(tableName: "cards", uniqueColumns: ["cardNumber"], updateType: .UPDATE)] +// Send the non-PCI records as additionalFields of CollectOptions (optional) and apply upsert using `upsert` field of CollectOptions (optional) + +let options = Skyflow.CollectOptions(additionalFields: nonPCIRecords) + +let insertCallback = Skyflow.CollectCallback( + onSuccess: { (response: Skyflow.CollectResponse) in + for record in response.records { + if let error = record.error { + print("failed:", error, "httpCode:", record.httpCode) + } else { + print("success:", record) + } + } + }, + onFailure: { (skyflowError: Skyflow.SkyflowError) in + print(skyflowError.httpCode, skyflowError.message, skyflowError.grpcCode ?? "", skyflowError.httpStatus ?? "", skyflowError.details ?? "") + } +) container?.collect(callback: insertCallback, options: options) ``` -**Note:** `skyflowID` is required if you want to update the data. If `skyflowID` isn't specified, the `collect(options?)` method creates a new record in the vault. +**Note:** `skyflowId` is required if you want to update the data. If `skyflowId` isn't specified, the `collect(options?)` method creates a new record in the vault. ### End to end example of updating data with Skyflow Elements ```swift @@ -683,7 +700,7 @@ let input = Skyflow.CollectElementInput( label: "card number", placeholder: "card number", type: Skyflow.ElementType.CARD_NUMBER, - skyflowID: "431eaa6c-5c15-4513-aa15-29f50babe882" + skyflowId: "431eaa6c-5c15-4513-aa15-29f50babe882" ) // Create an option to require the element. @@ -695,28 +712,34 @@ let skyflowElement = container?.create(input: input, options: requiredOption) // Can interact with this object as a normal UIView Object and add to View // Non-PCI records -let nonPCIRecords = [["table": "persons", "fields": ["gender": "MALE"]], ["table": "cards", "fields": ["first_name": "Joe"], "skyflowID": "431eaa6c-5c15-4513-aa15-29f50babe882"]] +let nonPCIRecords = Skyflow.AdditionalFields(records: [ + Skyflow.AdditionalFieldsRecord(tableName: "persons", data: ["gender": "MALE"]), + Skyflow.AdditionalFieldsRecord(tableName: "cards", data: ["first_name": "Joe"], skyflowId: "431eaa6c-5c15-4513-aa15-29f50babe882") +]) //Upsert options - let upsertOptions = [["table": "cards", "column": "cardNumber"]] as [[String : Any]] + let upsertOptions = [Skyflow.UpsertOption(tableName: "cards", uniqueColumns: ["cardNumber"], updateType: .UPDATE)] // Send the Non-PCI records as additionalFields of CollectOptions (optional) and apply upsert using optional field `upsert` of CollectOptions. -let collectOptions = Skyflow.CollectOptions(tokens: true, additionalFields: nonPCIRecords, upsert: upsertOptions) - - -//Implement a custom Skyflow.Callback to call on Insertion success/failure. -public class InsertCallback: Skyflow.Callback { - public func onSuccess(_ responseBody: Any) { - print(responseBody) - } - public func onFailure(_ error: Any) { - print(error) - } -} - - -// Initialize custom Skyflow.Callback. -let insertCallback = InsertCallback() +let collectOptions = Skyflow.CollectOptions(additionalFields: nonPCIRecords, upsert: upsertOptions) + + +// Initialize a Skyflow.CollectCallback - required by CollectContainer's collect(callback:options:). +// See "If the entire request fails" above for how onFailure surfaces a typed Skyflow.SkyflowError. +let insertCallback = Skyflow.CollectCallback( + onSuccess: { (response: Skyflow.CollectResponse) in + for record in response.records { + if let error = record.error { + print("failed:", error, "httpCode:", record.httpCode) + } else { + print("success:", record) + } + } + }, + onFailure: { (skyflowError: Skyflow.SkyflowError) in + print(skyflowError.httpCode, skyflowError.message, skyflowError.grpcCode ?? "", skyflowError.httpStatus ?? "", skyflowError.details ?? "") + } +) // Call collect method on CollectContainer. container?.collect(callback: insertCallback, options: collectOptions) @@ -728,24 +751,62 @@ container?.collect(callback: insertCallback, options: collectOptions) { "records": [ { - "table": "persons", - "fields": { - "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", - "skyflow_id": "77dc3caf-c452-49e1-8625-07219d7567bf" - } + "tableName": "persons", + "skyflowId": "77dc3caf-c452-49e1-8625-07219d7567bf", + "tokens": { + "gender": [{"token": "12f670af-6c7d-4837-83fb-30365fbc0b1e", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 }, { - "table": "cards", - "fields": { - "skyflow_id": "431eaa6c-5c15-4513-aa15-29f50babe882", - "cardNumber": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", - "first_name": "131e70dc-6f76-4319-bdd3-96281e051051" - } + "tableName": "cards", + "skyflowId": "431eaa6c-5c15-4513-aa15-29f50babe882", + "tokens": { + "cardNumber": [{"token": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", "tokenGroupName": "deterministic_string"}], + "first_name": [{"token": "131e70dc-6f76-4319-bdd3-96281e051051", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 + } + ] +} +``` +**Note:** Successful and failed records are both returned in the same `records` array, each carrying its own `httpCode`. + +#### Sample partial error response: +```json +{ + "records": [ + { + "tableName": "cards", + "skyflowId": "431eaa6c-5c15-4513-aa15-29f50babe882", + "tokens": { + "cardNumber": [{"token": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 + }, + { + "error": "Update failed. skyflow_ids [77dc3caf-c452-49e1-8625-07219d7567bf] are invalid. Specify valid Skyflow IDs.", + "skyflowId": null, + "tableName": "", + "httpCode": 400 } ] } ``` +#### If the entire request fails +This is the API's error - `onFailure` doesn't hand you this dictionary as-is, it's normalized into a `Skyflow.SkyflowError` object with these same fields as flat properties (no `error` wrapper key): +```json +{ + "grpcCode": 5, + "httpCode": 404, + "message": "Invalid request. Vault not found for vaultID: sd0ff0b064c04faabd4d392512b2e5. Specify a valid vaultID. - request-id: cb397-8521-42c2-870c-92dbeec", + "httpStatus": "Not Found", + "details": [] +} +``` +`Skyflow.CollectCallback`'s `onFailure` is typed `(Skyflow.SkyflowError) -> Void` - see [If the entire request fails](#if-the-entire-request-fails-for-example-the-vault-itself-cant-be-found) above for how to read the properties on `Skyflow.SkyflowError`. + ## Validations Skyflow-iOS provides two types of validations on Collect Elements @@ -825,7 +886,7 @@ element!.on(eventName: Skyflow.EventName) { _ in } ``` -There are 4 events in `Skyflow.EventName` +4 of `Skyflow.EventName`'s events apply to an individual Element's `.on(eventName:)` listener: - `CHANGE` Change event is triggered when the Element's value changes. - `READY` @@ -834,6 +895,9 @@ There are 4 events in `Skyflow.EventName` Focus event is triggered when the Element gains focus - `BLUR` Blur event is triggered when the Element loses focus. + +`Skyflow.EventName` has a 5th case, `SUBMIT`, but it's a no-op on an individual Element's `.on(eventName:)` - it only fires on a Composable **container's** `.on(eventName:)`, see [Set an event listener on a composable container](#set-an-event-listener-on-a-composable-container). + The handler ```(state: [String: Any]) -> Void``` is a callback function you provide, that will be called when the event is fired with the state object as shown below. ```swift @@ -1049,11 +1113,13 @@ let composableElementInput = Skyflow.CollectElementInput( inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + iconStyles: Skyflow.Styles, // optional styles that will be applied to the card icon of the collect element label: String, // optional label for the form element placeholder: String, // optional placeholder for the form element altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element - validations: ValidationSet, // optional set of validations for the input element type: Skyflow.ElementType, // Skyflow.ElementType enum + validations: ValidationSet, // optional set of validations for the input element + skyflowId: String, // optional, the skyflow_id of the record to update ) ``` The `table` and `column` fields indicate which table and column in the vault the Element correspond to. @@ -1136,6 +1202,8 @@ Skyflow.CollectElementOptions( enableCardIcon: Boolean, // Indicates whether card icon should be enabled (only for CARD_NUMBER inputs) format: String, // Format for the element translation: [Character: String] // Indicates the allowed data type value for format. + enableCopy: Boolean, // Indicates whether to enable the copy icon in collect elements to copy text to clipboard. Defaults to 'false' + cardMetaData: [String: [Skyflow.CardType]] // Optional, metadata to control card number element behavior. (only applicable for CARD_NUMBER ElementType). ) ``` - `required`: Indicates whether the field is marked as required or not. Default is `false`. @@ -1167,11 +1235,13 @@ let composableElementInput = Skyflow.CollectElementInput( inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + iconStyles: Skyflow.Styles, // optional styles that will be applied to the card icon of the collect element label: String, // optional label for the form element placeholder: String, // optional placeholder for the form element altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element - validations: ValidationSet, // optional set of validations for the input element type: Skyflow.ElementType, // Skyflow.ElementType enum + validations: ValidationSet, // optional set of validations for the input element + skyflowId: String, // optional, the skyflow_id of the record to update ) let collectElementOptions = Skyflow.CollectElementOptions( @@ -1207,24 +1277,38 @@ func clearFieldsOnSubmit(_ elements: [TextField]) { } ``` ### Step 4: Collect data from elements -When you submit the form, call the `collect(options: Skyflow.CollectOptions? = nil, callback: Skyflow.Callback)` method on the container object. +When you submit the form, call the `collect(callback: Skyflow.CollectCallback, options: Skyflow.CollectOptions? = Skyflow.CollectOptions())` method on the container object. The options parameter takes a `Skyflow.CollectOptions` object as shown below: - `tokens`: Whether or not tokens for the collected data are returned. Defaults to 'true' -- `additionalFields`: Non-PCI elements data to insert into the vault, specified in the records object format. -- `upsert`: To support upsert operations, the table containing the data and a column marked as unique in that table. +- `additionalFields`: A `Skyflow.AdditionalFields` object - non-PCI records to insert into the vault alongside whatever's collected from the mounted elements. +- `upsert`: An array of `Skyflow.UpsertOption` objects to support upsert while collecting data from Skyflow elements. Each option specifies the `tableName`, the `uniqueColumns` used to match existing records, and an optional `updateType` (`UpdateType.UPDATE` merges the new fields into the matched record, `UpdateType.REPLACE` replaces it). ```swift // Non-PCI records -let nonPCIRecords = ["table": "persons", "fields": [["gender": "MALE"]]] +let nonPCIRecords = Skyflow.AdditionalFields(records: [ + Skyflow.AdditionalFieldsRecord(tableName: "persons", data: ["gender": "MALE"]) +]) // Upsert -let upsertOptions = [["table": "cards", "column": "cardNumber"]] as [[String : Any]] -// Send the non-PCI records as additionalFields of InsertOptions (optional) and apply upsert using `upsert` field of InsertOptions (optional) - -let options = Skyflow.CollectOptions(tokens: true, additionalFields: nonPCIRecords, upsert: upsertOptions) - -//Custom callback - implementation of Skyflow.callback -let insertCallback = InsertCallback() +let upsertOptions = [Skyflow.UpsertOption(tableName: "cards", uniqueColumns: ["cardNumber"], updateType: .UPDATE)] +// Send the non-PCI records as additionalFields of CollectOptions (optional) and apply upsert using `upsert` field of CollectOptions (optional) + +let options = Skyflow.CollectOptions(additionalFields: nonPCIRecords, upsert: upsertOptions) + +let insertCallback = Skyflow.CollectCallback( + onSuccess: { (response: Skyflow.CollectResponse) in + for record in response.records { + if let error = record.error { + print("failed:", error, "httpCode:", record.httpCode) + } else { + print("success:", record) + } + } + }, + onFailure: { (skyflowError: Skyflow.SkyflowError) in + print(skyflowError.httpCode, skyflowError.message, skyflowError.grpcCode ?? "", skyflowError.httpStatus ?? "", skyflowError.details ?? "") + } +) container?.collect(callback: insertCallback, options: options) ``` End to end example of collecting data with Composable Elements @@ -1305,46 +1389,81 @@ do { } // Non-PCI records -let nonPCIRecords = ["table": "persons", "fields": [["gender": "MALE"]]] +let nonPCIRecords = Skyflow.AdditionalFields(records: [ + Skyflow.AdditionalFieldsRecord(tableName: "persons", data: ["gender": "MALE"]) +]) //Upsert options -let upsertOptions = [["table": "cards", "column": "cardNumber"]] as [[String : Any]] +let upsertOptions = [Skyflow.UpsertOption(tableName: "cards", uniqueColumns: ["cardNumber"], updateType: .UPDATE)] // Send the Non-PCI records as additionalFields of CollectOptions (optional) and apply upsert using optional field `upsert` of CollectOptions. -let collectOptions = Skyflow.CollectOptions(tokens: true, additionalFields: nonPCIRecords, upsert: upsertOptions) +let collectOptions = Skyflow.CollectOptions(additionalFields: nonPCIRecords, upsert: upsertOptions) -//Implement a custom Skyflow.Callback to call on Insertion success/failure. -public class InsertCallback: Skyflow.Callback { - public func onSuccess(_ responseBody: Any) { - print(responseBody) - } - public func onFailure(_ error: Any) { - print(error) - } -} - -// Initialize custom Skyflow.Callback. -let insertCallback = InsertCallback() +// Initialize a Skyflow.CollectCallback - required by CollectContainer's collect(callback:options:). +let insertCallback = Skyflow.CollectCallback( + onSuccess: { (response: Skyflow.CollectResponse) in + for record in response.records { + if let error = record.error { + print("failed:", error, "httpCode:", record.httpCode) + } else { + print("success:", record) + } + } + }, + onFailure: { (skyflowError: Skyflow.SkyflowError) in + print(skyflowError.httpCode, skyflowError.message, skyflowError.grpcCode ?? "", skyflowError.httpStatus ?? "", skyflowError.details ?? "") + } +) // Call collect method on CollectContainer. container?.collect(callback: insertCallback, options: collectOptions) ``` ### Sample Response: -``` +```json { - "records": [ { - "table": "cards", - "fields": { - "cardNumber": "f3907186-e7e2-466f-91e5-48e12c2bcbc1" + "records": [ + { + "tableName": "cards", + "skyflowId": "f1714ef8-8deb-489a-a18d-77e0e007f403", + "tokens": { + "cardNumber": [{"token": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 + }, + { + "tableName": "persons", + "skyflowId": "77dc3caf-c452-49e1-8625-07219d7567bf", + "tokens": { + "gender": [{"token": "12f670af-6c7d-4837-83fb-30365fbc0b1e", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 } - }, { - "table": "persons", - "fields": { - "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", + ] +} +``` +**Note:** Successful and failed records are both returned in the same `records` array, each carrying its own `httpCode`. + +#### Sample partial error response: +```json +{ + "records": [ + { + "tableName": "cards", + "skyflowId": "f1714ef8-8deb-489a-a18d-77e0e007f403", + "tokens": { + "cardNumber": [{"token": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 + }, + { + "error": "Invalid request. Table name table not present for record. Specify a valid table name.", + "skyflowId": null, + "tableName": "", + "httpCode": 400 } - }] + ] } ``` [For information on validations, see validations.](#validations) @@ -1360,13 +1479,15 @@ element!.on(eventName: Skyflow.EventName) { _ in } ``` -The SDK supports four events: +4 of `Skyflow.EventName`'s events apply to an individual Element's `.on(eventName:)` listener: - CHANGE: Triggered when the Element's value changes. - READY: Triggered when the Element is fully rendered. - FOCUS: Triggered when the Element gains focus. - BLUR: Triggered when the Element loses focus. +`Skyflow.EventName` has a 5th case, `SUBMIT`, but it's a no-op on an individual Element's `.on(eventName:)` - it only fires on a Composable **container's** `.on(eventName:)`, see [Set an event listener on a composable container](#set-an-event-listener-on-a-composable-container) below. + The handler ```(state: [String: Any]) -> Void``` is a callback function you provide, that will be called when the event is fired with the state object as shown below. ```swift @@ -1543,7 +1664,7 @@ let lengthRule = LengthMatchRule( // Update validations and placeholder property on cardHolderName. cardHolderName.update(update: CollectElementInput( placeholder: "cardHolderName", - validations: ValidationSet(rules: [lengthRule])) + validations: ValidationSet(rules: [lengthRule]) )) ``` @@ -1581,7 +1702,7 @@ do { // Subscribing to the `SUBMIT` event, which gets triggered when the user hits `enter` key in any container element input. composableContainer?.on(eventName: .SUBMIT){ // Your implementation when the SUBMIT(enter) event occurs. - print('Submit Event Listener is being Triggered.') + print("Submit Event Listener is being Triggered.") } ``` @@ -1611,12 +1732,13 @@ let composableElementInput = Skyflow.CollectElementInput( inputStyles: Skyflow.Styles, // optional styles that should be applied to the form element labelStyles: Skyflow.Styles, // optional styles that will be applied to the label of the collect element errorTextStyles: Skyflow.Styles, // optional styles that will be applied to the errorText of the collect element + iconStyles: Skyflow.Styles, // optional styles that will be applied to the card icon of the collect element label: String, // optional label for the form element placeholder: String, // optional placeholder for the form element altText: String, // (DEPRECATED) optional that acts as an initial value for the collect element - validations: ValidationSet, // optional set of validations for the input element type: Skyflow.ElementType, // Skyflow.ElementType enum - skyflowID: String, // The skyflow_id of the record to be updated. + validations: ValidationSet, // optional set of validations for the input element + skyflowId: String, // The skyflow_id of the record to be updated. ) ``` The `table` and `column` fields indicate which table and column in the vault the Element correspond to. @@ -1665,24 +1787,38 @@ func clearFieldsOnSubmit(_ elements: [TextField]) { } ``` ### Step 4: Update data from Elements -When you submit the form, call the `collect(options: Skyflow.CollectOptions? = nil, callback: Skyflow.Callback)` method on the container object. +When you submit the form, call the `collect(callback: Skyflow.CollectCallback, options: Skyflow.CollectOptions? = Skyflow.CollectOptions())` method on the container object. The options parameter takes a `Skyflow.CollectOptions` object as shown below: - `tokens`: Whether or not tokens for the collected data are returned. Defaults to 'true' -- `additionalFields`: Non-PCI elements data to insert into the vault, specified in the records object format. -- `upsert`: To support upsert operations, the table containing the data and a column marked as unique in that table. +- `additionalFields`: A `Skyflow.AdditionalFields` object - non-PCI records to insert into the vault alongside whatever's collected from the mounted elements. +- `upsert`: An array of `Skyflow.UpsertOption` objects to support upsert while collecting data from Skyflow elements. Each option specifies the `tableName`, the `uniqueColumns` used to match existing records, and an optional `updateType` (`UpdateType.UPDATE` merges the new fields into the matched record, `UpdateType.REPLACE` replaces it). ```swift // Non-PCI records -let nonPCIRecords = ["table": "persons", "fields": [["gender": "MALE"]]] +let nonPCIRecords = Skyflow.AdditionalFields(records: [ + Skyflow.AdditionalFieldsRecord(tableName: "persons", data: ["gender": "MALE"]) +]) // Upsert -let upsertOptions = [["table": "cards", "column": "cardNumber"]] as [[String : Any]] -// Send the non-PCI records as additionalFields of InsertOptions (optional) and apply upsert using `upsert` field of InsertOptions (optional) - -let options = Skyflow.CollectOptions(tokens: true, additionalFields: nonPCIRecords, upsert: upsertOptions) - -//Custom callback - implementation of Skyflow.callback -let insertCallback = InsertCallback() +let upsertOptions = [Skyflow.UpsertOption(tableName: "cards", uniqueColumns: ["cardNumber"], updateType: .UPDATE)] +// Send the non-PCI records as additionalFields of CollectOptions (optional) and apply upsert using `upsert` field of CollectOptions (optional) + +let options = Skyflow.CollectOptions(additionalFields: nonPCIRecords, upsert: upsertOptions) + +let insertCallback = Skyflow.CollectCallback( + onSuccess: { (response: Skyflow.CollectResponse) in + for record in response.records { + if let error = record.error { + print("failed:", error, "httpCode:", record.httpCode) + } else { + print("success:", record) + } + } + }, + onFailure: { (skyflowError: Skyflow.SkyflowError) in + print(skyflowError.httpCode, skyflowError.message, skyflowError.grpcCode ?? "", skyflowError.httpStatus ?? "", skyflowError.details ?? "") + } +) container?.collect(callback: insertCallback, options: options) ``` End to end example of collecting data with Composable Elements @@ -1719,7 +1855,7 @@ let cardHolderNameElementInput = Skyflow.CollectElementInput( label: "first name", placeholder: "first name", type: Skyflow.ElementType.CARDHOLDER_NAME, - skyflowID: "431eaa6c-5c15-4513-aa15-29f50babe882" + skyflowId: "431eaa6c-5c15-4513-aa15-29f50babe882" ) // Create an option to require the element. let requiredOption = Skyflow.CollectElementOptions(required: true) @@ -1737,7 +1873,7 @@ let cardNumberElementInput = Skyflow.CollectElementInput( label: "card number", placeholder: "card number", type: Skyflow.ElementType.CARD_NUMBER, - skyflowID: "431eaa6c-5c15-4513-aa15-29f50babe882" + skyflowId: "431eaa6c-5c15-4513-aa15-29f50babe882" ) let cardNumberElement = container?.create(input: cardNumberElementInput, options: requiredOption) @@ -1751,7 +1887,7 @@ let cvvElementInput = Skyflow.CollectElementInput( label: "cvv", placeholder: "cvv", type: Skyflow.ElementType.CVV, - skyflowID: "431eaa6c-5c15-4513-aa15-29f50babe882" + skyflowId: "431eaa6c-5c15-4513-aa15-29f50babe882" ) let cvvElement = container?.create(input: cvvElementInput, options: requiredOption) @@ -1766,26 +1902,31 @@ do { } // Non-PCI records -let nonPCIRecords = [["table": "persons", "fields": ["gender": "MALE"],"skyflowID": "77dc3caf-c452-49e1-8625-07219d7567bf"]] +let nonPCIRecords = Skyflow.AdditionalFields(records: [ + Skyflow.AdditionalFieldsRecord(tableName: "persons", data: ["gender": "MALE"], skyflowId: "77dc3caf-c452-49e1-8625-07219d7567bf") +]) //Upsert options - let upsertOptions = [["table": "cards", "column": "cardNumber"]] as [[String : Any]] + let upsertOptions = [Skyflow.UpsertOption(tableName: "cards", uniqueColumns: ["cardNumber"], updateType: .UPDATE)] // Send the Non-PCI records as additionalFields of CollectOptions (optional) and apply upsert using optional field `upsert` of CollectOptions. -let collectOptions = Skyflow.CollectOptions(tokens: true, additionalFields: nonPCIRecords, upsert: upsertOptions) - -//Implement a custom Skyflow.Callback to call on Insertion success/failure. -public class InsertCallback: Skyflow.Callback { - public func onSuccess(_ responseBody: Any) { - print(responseBody) - } - public func onFailure(_ error: Any) { - print(error) - } -} - -// Initialize custom Skyflow.Callback. -let insertCallback = InsertCallback() +let collectOptions = Skyflow.CollectOptions(additionalFields: nonPCIRecords, upsert: upsertOptions) + +// Initialize a Skyflow.CollectCallback - required by CollectContainer's collect(callback:options:). +let insertCallback = Skyflow.CollectCallback( + onSuccess: { (response: Skyflow.CollectResponse) in + for record in response.records { + if let error = record.error { + print("failed:", error, "httpCode:", record.httpCode) + } else { + print("success:", record) + } + } + }, + onFailure: { (skyflowError: Skyflow.SkyflowError) in + print(skyflowError.httpCode, skyflowError.message, skyflowError.grpcCode ?? "", skyflowError.httpStatus ?? "", skyflowError.details ?? "") + } +) // Call collect method on CollectContainer. container?.collect(callback: insertCallback, options: collectOptions) @@ -1795,250 +1936,69 @@ container?.collect(callback: insertCallback, options: collectOptions) { "records": [ { - "table": "persons", - "fields": { - "gender": "12f670af-6c7d-4837-83fb-30365fbc0b1e", - "skyflow_id": "77dc3caf-c452-49e1-8625-07219d7567bf" - } + "tableName": "persons", + "skyflowId": "77dc3caf-c452-49e1-8625-07219d7567bf", + "tokens": { + "gender": [{"token": "12f670af-6c7d-4837-83fb-30365fbc0b1e", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 }, { - "table": "cards", - "fields": { - "skyflow_id": "431eaa6c-5c15-4513-aa15-29f50babe882", - "cardNumber": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", - "first_name": "131e70dc-6f76-4319-bdd3-96281e051051", - "cvv": "098834fe-de99-4fc8-abdf-88c18a28a2cf" - } + "tableName": "cards", + "skyflowId": "431eaa6c-5c15-4513-aa15-29f50babe882", + "tokens": { + "cardNumber": [{"token": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", "tokenGroupName": "deterministic_string"}], + "first_name": [{"token": "131e70dc-6f76-4319-bdd3-96281e051051", "tokenGroupName": "deterministic_string"}], + "cvv": [{"token": "098834fe-de99-4fc8-abdf-88c18a28a2cf", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 } ] } ``` ### Sample Partial Error Response: +Successful and failed records are both returned together in the same `records` array, each carrying its own `httpCode`: ```json { "records": [ { - "table": "cards", - "fields": { - "skyflow_id": "431eaa6c-5c15-4513-aa15-29f50babe882", - "cardNumber": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", - "first_name": "131e70dc-6f76-4319-bdd3-96281e051051", - "cvv": "098834fe-de99-4fc8-abdf-88c18a28a2cf" - } - } - ], - "errors": [ + "tableName": "cards", + "skyflowId": "431eaa6c-5c15-4513-aa15-29f50babe882", + "tokens": { + "cardNumber": [{"token": "f3907186-e7e2-466f-91e5-48e12c2bcbc1", "tokenGroupName": "deterministic_string"}], + "first_name": [{"token": "131e70dc-6f76-4319-bdd3-96281e051051", "tokenGroupName": "deterministic_string"}], + "cvv": [{"token": "098834fe-de99-4fc8-abdf-88c18a28a2cf", "tokenGroupName": "deterministic_string"}] + }, + "httpCode": 200 + }, { - "error": { - "code": 400, - "message": "Update failed. skyflow_ids [77dc3caf-c452-49e1-8625-07219d7567bf] are invalid. Specify valid Skyflow IDs. - request-id: cb397-8521-42c2-870c-92dbeec" - } + "error": "Update failed. skyflow_ids [77dc3caf-c452-49e1-8625-07219d7567bf] are invalid. Specify valid Skyflow IDs.", + "skyflowId": null, + "tableName": "", + "httpCode": 400 } ] } ``` +If the entire request fails (for example, the vault itself can't be found), `onFailure` receives a single structured `Skyflow.SkyflowError` instead of a `records` array. +Below is the API's error that `SkyflowError` is built from - the object itself exposes these as flat properties (`grpcCode`, `httpCode`, `message`, `httpStatus`, `details`): +```json + + "grpcCode": 5, + "httpCode": 404, + "message": "Invalid request. Vault not found for vaultID: sd0ff0b064c04faabd4d392512b2e5. Specify a valid vaultID. - request-id: cb397-8521-42c2-870c-92dbeec", + "httpStatus": "Not Found", + "details": [] + } +``` + # Securely revealing data client-side -- [**Retrieving data from the vault**](#retrieving-data-from-the-vault) - [**Using Skyflow Elements to reveal data**](#using-skyflow-elements-to-reveal-data) - [**UI Error for Reveal Elements**](#ui-error-for-reveal-elements) - [**Set token for Reveal Elements**](#set-token-for-reveal-elements) - [**Set and clear altText for Reveal Elements**](#set-and-clear-alttext-for-reveal-elements) -## Retrieving data from the vault -For non-PCI use-cases, retrieving data from the vault and revealing it in the mobile can be done either using the SkyflowID's or tokens as described below - -- ### Using Skyflow tokens - To retrieve record data using tokens, use the `detokenize(records)` method. The records parameter takes a Dictionary object that contains tokens for `record` values to fetch - ```swift - [ - "records": [ - [ - "token": String, - "redaction": Skyflow.RedactionType // Optional. Redaction to apply for retrieved data. - ] - ] - ] - ``` - Note: `redaction` defaults to [RedactionType.PLAIN_TEXT](#redaction-types). - -The following example code makes a detokenize call to reveal the masked value of a token: - -```swift - let getCallback = GetCallback() // Custom callback - implementation of Skyflow.Callback - - let records = [ - "records": [ - [ - "token": "45012507-f72b-4f5c-9bf9-86b133bae719", - ], - [ - "token": "1r434532-6f76-4319-bdd3-96281e051051", - "redaction": Skyflow.RedactionType.MASKED - ] - ] - ] as [String: Any] - - skyflowClient.detokenize(records: records, callback: getCallback) - ``` - The sample response: - ```json - { - "records": [ - { - "token": "131e70dc-6f76-4319-bdd3-96281e051051", - "value": "1990-01-01" - }, - { - "token": "1r434532-6f76-4319-bdd3-96281e051051", - "value": "xxxxxxer", - } - ] - } - ``` - -- ### Using Skyflow ID's or Unique Column Values - For retrieving data from the vault, use the `get(records: JSONObject, options: GetOptions? = GetOptions(), callback: Skyflow.Callback)` method. - The `records` parameter takes a Dictionary object that contains `records` to be fetched as shown below. Each object inside array should contain: - - - Either an array of Skyflow IDs to fetch - - Or a column name and an array of column values - - The second parameter, `options`, is a `GetOptions` object that retrieves tokens of Skyflow IDs. - - Notes: - - You can use either Skyflow IDs or unique values to retrieve records. You can't use both at the same time. - - GetOptions parameter is applicable only for retrieving tokens using Skyflow ID. - - You can't pass GetOptions along with the redaction type. - - tokens defaults to false. - - ```swift - [ - "records": [ - [ - "ids": ArrayList(), // Array of SkyflowID's of the records to be fetched - "table": String, // Name of table holding the above skyflow_id's - "redaction": Skyflow.RedactionType // Redaction to be applied to retrieved data - ], - [ - "columnValues": ArrayList(), // Array of column values of the records to be fetched - "table": String, // Name of table holding the above skyflow_id's - "columnName": String, // A unique column name - "redaction": Skyflow.RedactionType // Redaction to be applied to retrieved data - ], - ] - ] - ``` - ### Redaction Types - There are 4 accepted values in Skyflow.RedactionTypes: - - `PLAIN_TEXT` - - `MASKED` - - `REDACTED` - - `DEFAULT` - - An example of get call to fetch records: - ```swift - let getCallback = GetCallback() // Custom callback - implementation of Skyflow.Callback - - let skyflowIDs = ["f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9"] - let record = ["ids": skyflowIDs, "table": "cards", "redaction": Skyflow.RedactionType.PLAIN_TEXT] as [String : Any] - - let columnValues = ["12345"] - let columnRecord = ["columnValues": columnValues,"columnName": "card_pin", "table": "cards", "redaction": Skyflow.RedactionType.PLAIN_TEXT] as [String : Any] - - let invalidID = ["invalid skyflow ID"] - let badRecord = ["ids": invalidID, "table": "cards", "redaction": Skyflow.RedactionType.PLAIN_TEXT] as [String : Any] - - let records = ["records": [record,columnRecord, badRecord]] - - skyflowClient.get(records: records, callback: getCallback) - ``` - - The sample response: - ```json - { - "records": [{ - "fields": { - "card_number": "4111111111111111", - "cvv": "127", - "expiry_date": "11/35", - "fullname": "myname", - "id": "f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9" - }, - "table": "cards" - },{ - "fields": { - "card_number": "4111111111111111", - "cvv": "345", - "card_pin":"12345" - "expiry_date": "12/29", - "fullname": "joey", - "id": "ed2e7851-9f84-4d70-9e5d-182f6d8d4fa3" - }, - "table": "cards" - } - ], - "errors": [ { - "error": { - "code": "404", - "description": "No Records Found" - }, - "ids": ["invalid skyflow id"] - }] - } - ``` - - An example of get call to fetch tokens: - - ```swift - let getCallback = GetCallback() // Custom callback - implementation of Skyflow.Callback - - let skyflowIDs = ["f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9", "da26de53-95d5-4bdb-99db-8d8c66a35ff9"] - let record = ["ids": skyflowIDs, "table": "cards", "redaction": Skyflow.RedactionType.PLAIN_TEXT] as [String : Any] - - let invalidID = ["invalid skyflow ID"] - let badRecord = ["ids": invalidID, "table": "cards", "redaction": Skyflow.RedactionType.PLAIN_TEXT] as [String : Any] - - let records = ["records": [record, badRecord]] - - skyflowClient.get(records: records, options: GetOptions(tokens: true), callback: getCallback) - ``` - The sample Response: - ```json - { - "records": [ - { - "fields": { - "card_number": "9802-3257-3113-0294", - "expiry_date": "45012507-f72b-4f5c-9bf9-86b133bae719", - "fullname": "131e2507-f72b-4f5c-9bf9-86b133bae719", - "id": "f8d8a622-b557-4c6b-a12c-c5ebe0b0bfd9" - }, - "table": "cards" - }, - { - "fields": { - "card_number": "0294-3213-3157-9802", - "expiry_date": "131e2507-f72b-4f5c-9bf9-86b133bae719", - "fullname": "45012507-f72b-4f5c-9bf9-86b133bae719", - "id": "ed2e7851-9f84-4d70-9e5d-182f6d8d4fa3" - }, - "table": "cards" - } - ], - "errors": [ - { - "error": { - "code": "404", - "description": "No Records Found" - }, - "ids": ["invalid skyflow id"] - } - ] -} - ``` - - ## Using Skyflow Elements to reveal data Skyflow Elements can be used to securely reveal data in an application without exposing your front end to the sensitive data. This is great for use-cases like card issuance where you may want to reveal the card number to a user without increasing your PCI compliance scope. ### Step 1: Create a container @@ -2056,14 +2016,12 @@ let revealElementInput = Skyflow.RevealElementInput( inputStyles: Skyflow.Styles(), // optional, styles to be applied to the element labelStyles: Skyflow.Styles(), // optional, styles to be applied to the label of the reveal element errorTextStyles: Skyflow.Styles(), // optional styles that will be applied to the errorText of the reveal element - label: "cardNumber", // optional, label for the element, + label: "cardNumber", // required, label for the element, altText: "XXXX XXXX XXXX XXXX", // optional, string that is shown before reveal, will show token if it is not provided - redaction: Skyflow.RedactionType, // optional. Redaction to apply for retrieved data. E.g. RedactionType.MASKED ) ``` `Note`: -- `token` is optional only if it is being used in invokeConnection() -- `redaction` defaults to [`RedactionType.PLAIN_TEXT`](#redaction-types). +- To apply a redaction to a token group as part of the detokenize call, use `tokenGroupRedactions` on `Skyflow.RevealOptions` (passed to `reveal(callback:options:)`) rather than on the individual `RevealElementInput` - see [Step 4: Reveal data](#step-4-reveal-data). If not provided, the vault-configured default redaction is applied. Supported redaction values are `PLAIN_TEXT`, `MASKED`, `REDACTED` and `DEFAULT`. The `inputStyles` parameter accepts a styles object as described in the [previous section](#step-2-create-a-collect-element) for collecting data but the only state available for a reveal element is the base state. @@ -2140,9 +2098,33 @@ Elements used for revealing data are mounted to the screen the same way as Eleme ### Step 4: Reveal data When the sensitive data is ready to be retrieved and revealed, call the `reveal()` method on the container as shown below: ```swift -let revealCallback = RevealCallback() // Custom callback - implementation of Skyflow.Callback +let revealCallback = Skyflow.RevealCallback( + onSuccess: { (response: Skyflow.RevealResponse) in + for record in response.records { + if let error = record.error { + print("failed:", error, "httpCode:", record.httpCode) + } else { + print("revealed:", record) + } + } + }, + onFailure: { (skyflowError: Skyflow.SkyflowError) in + print(skyflowError.httpCode, skyflowError.message, skyflowError.grpcCode ?? "", skyflowError.httpStatus ?? "", skyflowError.details ?? "") + } +) container.reveal(callback: revealCallback) ``` + +To apply a redaction to one or more token groups as part of the detokenize call, pass `tokenGroupRedactions` via `Skyflow.RevealOptions`: +```swift +let revealOptions = Skyflow.RevealOptions( + tokenGroupRedactions: [ + Skyflow.TokenGroupRedaction(tokenGroupName: "deterministic_string", redaction: "MASKED") + ] +) +container.reveal(callback: revealCallback, options: revealOptions) +``` +This is a request-level setting - the redaction applies to every token in the named group, not to individual reveal elements. ### UI Error for Reveal Elements @@ -2188,8 +2170,7 @@ let cardNumberInput = Skyflow.RevealElementInput( labelStyles: labelStyles, errorTextStyles: errorTextStyles, label: "cardnumber", - altText: "XXXX XXXX XXXX XXXX", - redaction: SKyflow.RedactionType.MASKED + altText: "XXXX XXXX XXXX XXXX" ) let cardNumberElement = container?.create(input: cardNumberInput) @@ -2221,42 +2202,80 @@ cvvElement!.setError("custom error") // reset error to the element cvvElement!.resetError() -// Implement a custom Skyflow.Callback to be called on Reveal success/failure -public class RevealCallback: Skyflow.Callback { - public func onSuccess(_ responseBody: Any) { - print(responseBody) - } - public func onFailure(_ error: Any) { - print(error) +// Initialize a Skyflow.RevealCallback - required by RevealContainer's reveal(callback:options:). +let revealCallback = Skyflow.RevealCallback( + onSuccess: { (response: Skyflow.RevealResponse) in + for record in response.records { + if let error = record.error { + print("failed:", error, "httpCode:", record.httpCode) + } else { + print("revealed:", record) + } + } + }, + onFailure: { (skyflowError: Skyflow.SkyflowError) in + print(skyflowError.httpCode, skyflowError.message, skyflowError.grpcCode ?? "", skyflowError.httpStatus ?? "", skyflowError.details ?? "") } -} +) -// Initialize custom Skyflow.Callback -let revealCallback = RevealCallback() +// Optional: apply a redaction to a token group as part of the detokenize call +let revealOptions = Skyflow.RevealOptions( + tokenGroupRedactions: [ + Skyflow.TokenGroupRedaction(tokenGroupName: "deterministic_string", redaction: "MASKED") + ] +) // Call reveal method on RevealContainer -container?.reveal(callback: revealCallback) +container?.reveal(callback: revealCallback, options: revealOptions) ``` -The response below shows that some tokens assigned to the reveal elements get revealed successfully, while others fail and remain unrevealed. - #### Sample Response: ```json { - "success": [ { - "token": "b63ec4e0-bbad-4e43-96e6-6bd50f483f75" - }, - { - "token": "89024714-6a26-4256-b9d4-55ad69aa4047" - }], - "errors": [ { - "id": "a4b24714-6a26-4256-b9d4-55ad69aa4047", - "error": { - "code": 404, - "description": "Tokens not found for a4b24714-6a26-4256-b9d4-55ad69aa4047" + "records": [ + { + "token": "b63ec4e0-bbad-4e43-96e6-6bd50f483f75", + "tokenGroupName": "deterministic_string", + "metadata": { + "skyflowId": "3ac0424e-fe45-43a9-9193-2e6d2913cbd2", + "tableName": "cards" + }, + "httpCode": 200 + }, + { + "token": "89024714-6a26-4256-b9d4-55ad69aa4047", + "tokenGroupName": "deterministic_string", + "metadata": { + "skyflowId": "3ac0424e-fe45-43a9-9193-2e6d2913cbd2", + "tableName": "cards" + }, + "httpCode": 200 + } + ] +} +``` + +#### Sample Partial Error Response: +Some tokens assigned to the reveal elements get revealed successfully, while others fail and remain unrevealed - both are returned together in the same `records` array, each carrying its own `httpCode`. A successful record carries `metadata` (e.g. `skyflowId`, `tableName`), a failed one carries `error` instead: +```json +{ + "records": [ + { + "token": "b63ec4e0-bbad-4e43-96e6-6bd50f483f75", + "tokenGroupName": "deterministic_string", + "metadata": { + "skyflowId": "3ac0424e-fe45-43a9-9193-2e6d2913cbd2", + "tableName": "cards" + }, + "httpCode": 200 + }, + { + "token": "a4b24714-6a26-4256-b9d4-55ad69aa4047", + "error": "Tokens not found for a4b24714-6a26-4256-b9d4-55ad69aa4047", + "httpCode": 404 } - }] + ] } ``` diff --git a/Samples/CollectAndRevealSample/CollectAndRevealSample.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Samples/CollectAndRevealSample/CollectAndRevealSample.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..31459b56 --- /dev/null +++ b/Samples/CollectAndRevealSample/CollectAndRevealSample.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "9d7a641543031e54b3470012de29bf3cd9a042920b5fe71a347a8d0e07ccefe2", + "pins" : [ + { + "identity" : "skyflow-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/skyflowapi/skyflow-iOS.git", + "state" : { + "revision" : "cb30c6b6b8a36105fa4bf8ba6a879af8b777d2c7", + "version" : "1.25.1" + } + } + ], + "version" : 3 +} diff --git a/Samples/CollectAndRevealSample/CollectAndRevealSample/CardBrandChoiceSampleViewController.swift b/Samples/CollectAndRevealSample/CollectAndRevealSample/CardBrandChoiceSampleViewController.swift index 270ff189..9c7c742b 100644 --- a/Samples/CollectAndRevealSample/CollectAndRevealSample/CardBrandChoiceSampleViewController.swift +++ b/Samples/CollectAndRevealSample/CollectAndRevealSample/CardBrandChoiceSampleViewController.swift @@ -39,7 +39,7 @@ class CardBrandChoiceSampleViewController: UIViewController { let iconStyles = Skyflow.Styles(base: Style(cardIconAlignment: .right)) // create card number element - let collectCardNumberInput = Skyflow.CollectElementInput(table: "", column: "", inputStyles: inputStyles, iconStyles: Styles(base: Style(cardIconAlignment: .right)),label: "Card Number", placeholder: "XXXXXXXXXXXXX", type: Skyflow.ElementType.CARD_NUMBER) + let collectCardNumberInput = Skyflow.CollectElementInput(tableName: "", column: "", inputStyles: inputStyles, iconStyles: Styles(base: Style(cardIconAlignment: .right)),label: "Card Number", placeholder: "XXXXXXXXXXXXX", type: Skyflow.ElementType.CARD_NUMBER) let requiredOption = Skyflow.CollectElementOptions(required: true, enableCardIcon: true, enableCopy: false) collectCardNumberElement = container?.create(input: collectCardNumberInput, options: requiredOption) diff --git a/Samples/CollectAndRevealSample/CollectAndRevealSample/CollectAndRevealViewController.swift b/Samples/CollectAndRevealSample/CollectAndRevealSample/CollectAndRevealViewController.swift index 48368c2d..9f334fac 100644 --- a/Samples/CollectAndRevealSample/CollectAndRevealSample/CollectAndRevealViewController.swift +++ b/Samples/CollectAndRevealSample/CollectAndRevealSample/CollectAndRevealViewController.swift @@ -22,6 +22,12 @@ class CollectAndRevealViewController: UIViewController { private var revealButton: UIButton! private var revealed = false + private var tokenGroupRedactions: [Skyflow.TokenGroupRedaction] = [] + + // Manual/visual check for the mock-CVV feature: shows the CVV token straight from + // collect()'s response so you can see it's a short numeric mock (not a real vault token) + // after submitting, without needing to inspect console output. + private var cvvMockCheckLabel: UILabel? override func viewDidLoad() { @@ -67,7 +73,7 @@ class CollectAndRevealViewController: UIViewController { ) let collectCardNumberInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "card_number", inputStyles: styles, label: "Card Number", @@ -75,7 +81,7 @@ class CollectAndRevealViewController: UIViewController { type: Skyflow.ElementType.CARD_NUMBER ) let collectNameInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "cardholder_name", inputStyles: styles, label: "Card Holder Name", @@ -83,7 +89,7 @@ class CollectAndRevealViewController: UIViewController { type: Skyflow.ElementType.CARDHOLDER_NAME ) let collectCVVInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "cvv", inputStyles: styles, label: "CVV", @@ -91,7 +97,7 @@ class CollectAndRevealViewController: UIViewController { type: .CVV ) let collectExpMonthInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "expiry_month", inputStyles: styles, label: "Expiration Month", @@ -99,7 +105,7 @@ class CollectAndRevealViewController: UIViewController { type: .EXPIRATION_MONTH ) let collectExpYearInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "expiry_year", inputStyles: styles, label: "Expiration Year", @@ -116,6 +122,14 @@ class CollectAndRevealViewController: UIViewController { collectButton.backgroundColor = .blue collectButton.setTitle("Submit", for: .normal) collectButton.addTarget(self, action: #selector(submitForm), for: .touchUpInside) + + let cvvMockCheckLabel = UILabel() + cvvMockCheckLabel.text = "CVV token will appear here after submit" + cvvMockCheckLabel.numberOfLines = 0 + cvvMockCheckLabel.textColor = .darkGray + cvvMockCheckLabel.font = .systemFont(ofSize: 13) + self.cvvMockCheckLabel = cvvMockCheckLabel + self.revealContainer = skyflow?.container(type: Skyflow.ContainerType.REVEAL, options: nil) self.revealButton = UIButton(frame: CGRect(x: 100, y: 400, width: 100, height: 40)) revealButton.backgroundColor = .blue @@ -127,23 +141,23 @@ class CollectAndRevealViewController: UIViewController { stackView.addArrangedSubview(collectExpMonth!) stackView.addArrangedSubview(collectExpYear!) stackView.addArrangedSubview(collectButton) - + stackView.axis = .vertical stackView.distribution = .fill stackView.spacing = 5 stackView.alignment = .fill stackView.translatesAutoresizingMaskIntoConstraints = false - + let scrollView = UIScrollView(frame: .zero) scrollView.isScrollEnabled = true scrollView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(scrollView) - + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 2).isActive = true scrollView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 2).isActive = true scrollView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor, constant: -10).isActive = true scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true - + scrollView.addSubview(stackView) stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -10).isActive = true stackView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true @@ -153,26 +167,51 @@ class CollectAndRevealViewController: UIViewController { } } @objc func revealForm() { - self.revealContainer?.reveal(callback: ExampleAPICallback()) + let revealCallback = Skyflow.RevealCallback( + onSuccess: { (response: Skyflow.RevealResponse) in + for record in response.records { + if let error = record.error { + print("reveal failed:", error, "httpCode:", record.httpCode) + } else { + print("revealed:", record.token ?? "", record.tokenGroupName ?? "", record.metadata ?? [:]) + } + } + }, + onFailure: { (error: Skyflow.SkyflowError) in print("reveal failure:", error.httpCode, error.message) } + ) + let revealOptions = Skyflow.RevealOptions(tokenGroupRedactions: self.tokenGroupRedactions.isEmpty ? nil : self.tokenGroupRedactions) + self.revealContainer?.reveal(callback: revealCallback, options: revealOptions) } @objc func submitForm() { - let exampleAPICallback = ExampleAPICallback(updateSuccess: updateSuccess, updateFailure: updateFailure) - container!.collect(callback: exampleAPICallback, options: Skyflow.CollectOptions(tokens: true)) + let collectCallback = Skyflow.CollectCallback(onSuccess: updateSuccess, onFailure: updateFailure) + container!.collect(callback: collectCallback, options: Skyflow.CollectOptions()) } - internal func updateSuccess(_ response: SuccessResponse) { + internal func updateSuccess(_ response: Skyflow.CollectResponse) { print(response) retryCount = 0 - updateRevealInputs(tokens: response.records[0].fields) + for result in response.records { + if let error = result.error { + print("Record failed:", error, "httpCode:", result.httpCode) + } + } + if let tokens = response.records.first?.tokens { + updateRevealInputs(tokens: tokens) + } print("Successfully got response:", response) } - internal func updateFailure(error: Any) { - if((error as AnyObject).contains("Invalid Bearer token") && retryCount <= 2){ // To do, it will be replaced with error code in the future + internal func updateFailure(error: Skyflow.SkyflowError) { + if(error.message.contains("Invalid Bearer token") && retryCount <= 2){ // To do, it will be replaced with error code in the future retryCount += 1 submitForm() } - print("Failed Operation", error) + print("Failed Operation", error.httpCode, error.message, error.grpcCode ?? "", error.httpStatus ?? "", error.details ?? "") + } + // fields is [String: Any] - column name -> array of {"token","tokenGroupName"} dicts. + internal func firstToken(_ fields: [String: Any], column: String) -> (token: String, tokenGroupName: String?) { + guard let entries = fields[column] as? [[String: Any]], let first = entries.first else { return ("", nil) } + return (first["token"] as? String ?? "", first["tokenGroupName"] as? String) } - internal func updateRevealInputs(tokens: Fields) { + internal func updateRevealInputs(tokens: [String: Any]) { let revealBaseStyle = Skyflow.Style( borderColor: UIColor.black, cornerRadius: 20, @@ -188,40 +227,48 @@ class CollectAndRevealViewController: UIViewController { } else { self.revealed = true } + // tokenGroupRedactions: request-level redaction per token group, applied when + // reveal() is called (see revealForm()) - not per reveal element. + self.tokenGroupRedactions = [] + func addTokenGroupRedaction(_ redaction: String, forColumn column: String) { + if let tokenGroupName = self.firstToken(tokens, column: column).tokenGroupName { + self.tokenGroupRedactions.append(Skyflow.TokenGroupRedaction(tokenGroupName: tokenGroupName, redaction: redaction)) + } + } + addTokenGroupRedaction("REDACTED", forColumn: "card_number") + addTokenGroupRedaction("MASKED", forColumn: "cvv") + addTokenGroupRedaction("DEFAULT", forColumn: "cardholder_name") + addTokenGroupRedaction("PLAIN_TEXT", forColumn: "expiry_month") + let revealCardNumberInput = Skyflow.RevealElementInput( - token: tokens.card_number, + token: self.firstToken(tokens, column: "card_number").token, inputStyles: revealStyles, - label: "Card Number", - redaction: .REDACTED + label: "Card Number" ) self.revealCardNumber = self.revealContainer?.create( input: revealCardNumberInput, options: Skyflow.RevealElementOptions() ) let revealCVVtInput = Skyflow.RevealElementInput( - token: tokens.cvv, + token: self.firstToken(tokens, column: "cvv").token, inputStyles: revealStyles, - label: "CVV", - redaction: .MASKED + label: "CVV" ) self.revealCVV = self.revealContainer?.create(input: revealCVVtInput) let revealNameInput = Skyflow.RevealElementInput( - token: tokens.cardholder_name, + token: self.firstToken(tokens, column: "cardholder_name").token, inputStyles: revealStyles, - label: "Card Holder Name", - redaction: .DEFAULT - + label: "Card Holder Name" ) self.revealName = self.revealContainer?.create(input: revealNameInput) let revealExpirationMonthInput = Skyflow.RevealElementInput( - token: tokens.expiry_month, + token: self.firstToken(tokens, column: "expiry_month").token, inputStyles: revealStyles, - label: "Expiration Month", - redaction: .PLAIN_TEXT + label: "Expiration Month" ) self.revealExpirationMonth = self.revealContainer?.create(input: revealExpirationMonthInput) let revealExpirationYearInput = Skyflow.RevealElementInput( - token: tokens.expiry_year, + token: self.firstToken(tokens, column: "expiry_year").token, inputStyles: revealStyles, label: "Expiration Year" ) diff --git a/Samples/CollectAndRevealSample/CollectAndRevealSample/ExampleAPICallback.swift b/Samples/CollectAndRevealSample/CollectAndRevealSample/ExampleAPICallback.swift index 0d8be466..12e3bd84 100644 --- a/Samples/CollectAndRevealSample/CollectAndRevealSample/ExampleAPICallback.swift +++ b/Samples/CollectAndRevealSample/CollectAndRevealSample/ExampleAPICallback.swift @@ -2,47 +2,6 @@ * Copyright (c) 2022 Skyflow */ -import Foundation -import Skyflow - -public class ExampleAPICallback: Skyflow.Callback { - private var updateSuccess: ((SuccessResponse) -> Void)? - private var updateFailure: ((Any) -> Void)? - private var decoder: JSONDecoder? - - internal init(updateSuccess: @escaping (SuccessResponse) -> Void, updateFailure: @escaping (Any) -> Void) { - self.updateSuccess = updateSuccess - self.updateFailure = updateFailure - self.decoder = JSONDecoder() - } - - internal init() { - } - - public func onSuccess(_ responseBody: Any) { - if updateSuccess == nil { - print("success:", responseBody) - return - } - do { - let dataString = String(data: try! JSONSerialization.data(withJSONObject: responseBody), encoding: .utf8) - let responseData = Data(dataString!.utf8) - let jsonResponse = try decoder!.decode(SuccessResponse.self, from: responseData) - - updateSuccess!(jsonResponse) - - print("success:", jsonResponse) - } catch { - print("error deserializing", error) - } - } - - public func onFailure(_ error: Any) { - print(error) - if updateFailure != nil { - print("failure:", error) - return - } - print("onfailure", error) - } -} +// No longer needed - use Skyflow.CollectCallback / Skyflow.RevealCallback directly +// (see CollectAndRevealViewController.swift), which already deliver typed responses +// (Skyflow.CollectResponse / Skyflow.RevealResponse) without a custom Callback subclass. diff --git a/Samples/CollectAndRevealSample/CollectAndRevealSample/ResponseStructs.swift b/Samples/CollectAndRevealSample/CollectAndRevealSample/ResponseStructs.swift index 959044f5..67c28811 100644 --- a/Samples/CollectAndRevealSample/CollectAndRevealSample/ResponseStructs.swift +++ b/Samples/CollectAndRevealSample/CollectAndRevealSample/ResponseStructs.swift @@ -2,22 +2,6 @@ * Copyright (c) 2022 Skyflow */ -import Foundation - -struct SuccessResponse: Codable { - let records: [Records] -} - -struct Records: Codable { - let fields: Fields - let table: String -} - -struct Fields: Codable { - let cardholder_name: String - let card_number: String - let expiry_month: String - let expiry_year: String - let cvv: String - let skyflow_id: String -} +// No longer needed - the SDK now provides typed responses directly +// (Skyflow.CollectResponse / Skyflow.RevealResponse), so this sample no longer +// needs to declare its own local response structs. diff --git a/Samples/CollectAndRevealSample/Podfile b/Samples/CollectAndRevealSample/Podfile index a02fa6bd..ddb60497 100644 --- a/Samples/CollectAndRevealSample/Podfile +++ b/Samples/CollectAndRevealSample/Podfile @@ -5,9 +5,8 @@ source 'https://github.com/CocoaPods/Specs.git' target 'CollectAndRevealSample' do #use_frameworks! - pod 'AEXML', '4.6.1' # Pods for skyflow-iOS-sample-app - pod 'Skyflow', ">= 1.23.0" + pod 'Skyflow', ">= 1.25.0" end diff --git a/Samples/ComposableElements/ComposableElements/ExampleAPICallback.swift b/Samples/ComposableElements/ComposableElements/ExampleAPICallback.swift index 0d8be466..548a8e4c 100644 --- a/Samples/ComposableElements/ComposableElements/ExampleAPICallback.swift +++ b/Samples/ComposableElements/ComposableElements/ExampleAPICallback.swift @@ -2,47 +2,6 @@ * Copyright (c) 2022 Skyflow */ -import Foundation -import Skyflow - -public class ExampleAPICallback: Skyflow.Callback { - private var updateSuccess: ((SuccessResponse) -> Void)? - private var updateFailure: ((Any) -> Void)? - private var decoder: JSONDecoder? - - internal init(updateSuccess: @escaping (SuccessResponse) -> Void, updateFailure: @escaping (Any) -> Void) { - self.updateSuccess = updateSuccess - self.updateFailure = updateFailure - self.decoder = JSONDecoder() - } - - internal init() { - } - - public func onSuccess(_ responseBody: Any) { - if updateSuccess == nil { - print("success:", responseBody) - return - } - do { - let dataString = String(data: try! JSONSerialization.data(withJSONObject: responseBody), encoding: .utf8) - let responseData = Data(dataString!.utf8) - let jsonResponse = try decoder!.decode(SuccessResponse.self, from: responseData) - - updateSuccess!(jsonResponse) - - print("success:", jsonResponse) - } catch { - print("error deserializing", error) - } - } - - public func onFailure(_ error: Any) { - print(error) - if updateFailure != nil { - print("failure:", error) - return - } - print("onfailure", error) - } -} +// No longer needed - use Skyflow.CollectCallback / Skyflow.RevealCallback directly +// (see ViewController.swift), which already deliver typed responses +// (Skyflow.CollectResponse / Skyflow.RevealResponse) without a custom Callback subclass. diff --git a/Samples/ComposableElements/ComposableElements/ResponseStructs.swift b/Samples/ComposableElements/ComposableElements/ResponseStructs.swift index 959044f5..67c28811 100644 --- a/Samples/ComposableElements/ComposableElements/ResponseStructs.swift +++ b/Samples/ComposableElements/ComposableElements/ResponseStructs.swift @@ -2,22 +2,6 @@ * Copyright (c) 2022 Skyflow */ -import Foundation - -struct SuccessResponse: Codable { - let records: [Records] -} - -struct Records: Codable { - let fields: Fields - let table: String -} - -struct Fields: Codable { - let cardholder_name: String - let card_number: String - let expiry_month: String - let expiry_year: String - let cvv: String - let skyflow_id: String -} +// No longer needed - the SDK now provides typed responses directly +// (Skyflow.CollectResponse / Skyflow.RevealResponse), so this sample no longer +// needs to declare its own local response structs. diff --git a/Samples/ComposableElements/ComposableElements/ViewController.swift b/Samples/ComposableElements/ComposableElements/ViewController.swift index 5597e404..78404afe 100644 --- a/Samples/ComposableElements/ComposableElements/ViewController.swift +++ b/Samples/ComposableElements/ComposableElements/ViewController.swift @@ -22,6 +22,7 @@ class ViewController: UIViewController { private var revealButton: UIButton! private var revealed = false + private var tokenGroupRedactions: [Skyflow.TokenGroupRedaction] = [] override func loadView() { let view = UIView() @@ -73,7 +74,7 @@ class ViewController: UIViewController { ) let collectCardNumberInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "card_number", inputStyles: styles, label: "Card Number", @@ -81,7 +82,7 @@ class ViewController: UIViewController { type: Skyflow.ElementType.CARD_NUMBER ) let collectNameInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "cardholder_name", inputStyles: styles, label: "Card Holder Name", @@ -89,7 +90,7 @@ class ViewController: UIViewController { type: Skyflow.ElementType.CARDHOLDER_NAME ) let collectCVVInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "cvv", inputStyles: styles, label: "CVV", @@ -97,7 +98,7 @@ class ViewController: UIViewController { type: .CVV ) let collectExpMonthInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "expiry_month", inputStyles: styles, label: "Expiration Month", @@ -105,7 +106,7 @@ class ViewController: UIViewController { type: .EXPIRATION_MONTH ) let collectExpYearInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "expiry_year", inputStyles: styles, label: "Expiration Year", @@ -170,26 +171,65 @@ class ViewController: UIViewController { } } @objc func revealForm() { - self.revealContainer?.reveal(callback: ExampleAPICallback()) + let revealCallback = Skyflow.RevealCallback( + onSuccess: { (response: Skyflow.RevealResponse) in + for record in response.records { + if let error = record.error { + print("reveal failed:", error, "httpCode:", record.httpCode) + } else { + print("revealed:", record.token ?? "", record.tokenGroupName ?? "", record.metadata ?? [:]) + } + } + }, + onFailure: { (error: Skyflow.SkyflowError) in print("reveal failure:", error.httpCode, error.message) } + ) + let revealOptions = Skyflow.RevealOptions(tokenGroupRedactions: self.tokenGroupRedactions.isEmpty ? nil : self.tokenGroupRedactions) + self.revealContainer?.reveal(callback: revealCallback, options: revealOptions) } @objc func submitForm() { - let exampleAPICallback = ExampleAPICallback(updateSuccess: updateSuccess, updateFailure: updateFailure) - container!.collect(callback: exampleAPICallback, options: Skyflow.CollectOptions(tokens: true)) + let collectCallback = Skyflow.CollectCallback(onSuccess: updateSuccess, onFailure: updateFailure) + + // Upsert example: insert-or-update credit_cards records, matching on card_number. + // If a record with a matching card_number already exists, its fields are merged + // in (updateType: .UPDATE) instead of creating a duplicate row. + let upsertOptions = [ + Skyflow.UpsertOption(tableName: "credit_cards", uniqueColumns: ["card_number"], updateType: .UPDATE) + ] + + // additionalFields: extra records submitted alongside whatever's collected from + // the mounted elements, one entry per table. + let additionalFields = Skyflow.AdditionalFields(records: [ + Skyflow.AdditionalFieldsRecord(tableName: "credit_cards", data: ["billing_zip": "94105"]) + ]) + + container!.collect(callback: collectCallback, options: Skyflow.CollectOptions(additionalFields: additionalFields, upsert: upsertOptions)) } - internal func updateSuccess(_ response: SuccessResponse) { + internal func updateSuccess(_ response: Skyflow.CollectResponse) { print(response) retryCount = 0 - updateRevealInputs(tokens: response.records[0].fields) + for result in response.records { + if let error = result.error { + print("Record failed:", error, "httpCode:", result.httpCode) + } + } + if let tokens = response.records.first?.tokens { + updateRevealInputs(tokens: tokens) + } print("Successfully got response:", response) } - internal func updateFailure(error: Any) { - if((error as AnyObject).contains("Invalid Bearer token") && retryCount <= 2){ // To do, it will be replaced with error code in the future + internal func updateFailure(error: Skyflow.SkyflowError) { + if(error.message.contains("Invalid Bearer token") && retryCount <= 2){ // To do, it will be replaced with error code in the future retryCount += 1 submitForm() } - print("Failed Operation", error) + print("Failed Operation", error.httpCode, error.message, error.grpcCode ?? "", error.httpStatus ?? "", error.details ?? "") + } + // fields is [String: Any] - column name -> array of {"token","tokenGroupName"} dicts. + internal func firstToken(_ fields: [String: Any], column: String) -> (token: String, tokenGroupName: String?) { + guard let entries = fields[column] as? [[String: Any]], let first = entries.first else { return ("", nil) } + return (first["token"] as? String ?? "", first["tokenGroupName"] as? String) } - internal func updateRevealInputs(tokens: Fields) { + internal func updateRevealInputs(tokens: [String: Any]) { let revealBaseStyle = Skyflow.Style( borderColor: UIColor.black, cornerRadius: 20, @@ -205,40 +245,48 @@ class ViewController: UIViewController { } else { self.revealed = true } + // tokenGroupRedactions: request-level redaction per token group, applied when + // reveal() is called (see revealForm()) - not per reveal element. + self.tokenGroupRedactions = [] + func addTokenGroupRedaction(_ redaction: String, forColumn column: String) { + if let tokenGroupName = self.firstToken(tokens, column: column).tokenGroupName { + self.tokenGroupRedactions.append(Skyflow.TokenGroupRedaction(tokenGroupName: tokenGroupName, redaction: redaction)) + } + } + addTokenGroupRedaction("REDACTED", forColumn: "card_number") + addTokenGroupRedaction("MASKED", forColumn: "cvv") + addTokenGroupRedaction("DEFAULT", forColumn: "cardholder_name") + addTokenGroupRedaction("PLAIN_TEXT", forColumn: "expiry_month") + let revealCardNumberInput = Skyflow.RevealElementInput( - token: tokens.card_number, + token: self.firstToken(tokens, column: "card_number").token ?? "", inputStyles: revealStyles, - label: "Card Number", - redaction: .REDACTED + label: "Card Number" ) self.revealCardNumber = self.revealContainer?.create( input: revealCardNumberInput, options: Skyflow.RevealElementOptions() ) let revealCVVtInput = Skyflow.RevealElementInput( - token: tokens.cvv, + token: self.firstToken(tokens, column: "cvv").token ?? "", inputStyles: revealStyles, - label: "CVV", - redaction: .MASKED + label: "CVV" ) self.revealCVV = self.revealContainer?.create(input: revealCVVtInput) let revealNameInput = Skyflow.RevealElementInput( - token: tokens.cardholder_name, + token: self.firstToken(tokens, column: "cardholder_name").token ?? "", inputStyles: revealStyles, - label: "Card Holder Name", - redaction: .DEFAULT - + label: "Card Holder Name" ) self.revealName = self.revealContainer?.create(input: revealNameInput) let revealExpirationMonthInput = Skyflow.RevealElementInput( - token: tokens.expiry_month, + token: self.firstToken(tokens, column: "expiry_month").token ?? "", inputStyles: revealStyles, - label: "Expiration Month", - redaction: .PLAIN_TEXT + label: "Expiration Month" ) self.revealExpirationMonth = self.revealContainer?.create(input: revealExpirationMonthInput) let revealExpirationYearInput = Skyflow.RevealElementInput( - token: tokens.expiry_year, + token: self.firstToken(tokens, column: "expiry_year").token ?? "", inputStyles: revealStyles, label: "Expiration Year" ) diff --git a/Samples/ComposableElements/Podfile b/Samples/ComposableElements/Podfile index 9d8d3f0e..77ef17f8 100644 --- a/Samples/ComposableElements/Podfile +++ b/Samples/ComposableElements/Podfile @@ -4,8 +4,7 @@ target 'ComposableElements' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! - pod 'AEXML', '4.6.1' - pod 'Skyflow', ">= 1.22.0" + pod 'Skyflow', ">= 1.25.0" # Pods for ComposableElements target 'ComposableElementsTests' do diff --git a/Samples/GetSample/GetSample.xcodeproj/project.pbxproj b/Samples/GetSample/GetSample.xcodeproj/project.pbxproj deleted file mode 100644 index bdef97b9..00000000 --- a/Samples/GetSample/GetSample.xcodeproj/project.pbxproj +++ /dev/null @@ -1,640 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 56; - objects = { - -/* Begin PBXBuildFile section */ - 7C7BCD1D2A49A5B900C6FBCF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BCD1C2A49A5B900C6FBCF /* AppDelegate.swift */; }; - 7C7BCD1F2A49A5B900C6FBCF /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BCD1E2A49A5B900C6FBCF /* SceneDelegate.swift */; }; - 7C7BCD212A49A5B900C6FBCF /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BCD202A49A5B900C6FBCF /* ViewController.swift */; }; - 7C7BCD242A49A5B900C6FBCF /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7C7BCD222A49A5B900C6FBCF /* Main.storyboard */; }; - 7C7BCD262A49A5BA00C6FBCF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7C7BCD252A49A5BA00C6FBCF /* Assets.xcassets */; }; - 7C7BCD292A49A5BA00C6FBCF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7C7BCD272A49A5BA00C6FBCF /* LaunchScreen.storyboard */; }; - 7C7BCD342A49A5BB00C6FBCF /* GetSampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BCD332A49A5BB00C6FBCF /* GetSampleTests.swift */; }; - 7C7BCD3E2A49A5BB00C6FBCF /* GetSampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BCD3D2A49A5BB00C6FBCF /* GetSampleUITests.swift */; }; - 7C7BCD402A49A5BB00C6FBCF /* GetSampleUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BCD3F2A49A5BB00C6FBCF /* GetSampleUITestsLaunchTests.swift */; }; - 7C7BCD4D2A49A70900C6FBCF /* ExampleAPICallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BCD4C2A49A70900C6FBCF /* ExampleAPICallback.swift */; }; - 7C7BCD4F2A49A72900C6FBCF /* ResponseStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BCD4E2A49A72900C6FBCF /* ResponseStructs.swift */; }; - 7C7BCD512A49A75900C6FBCF /* ExampleTokenProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BCD502A49A75900C6FBCF /* ExampleTokenProvider.swift */; }; - 7C7BCD542A49AD6400C6FBCF /* Skyflow in Frameworks */ = {isa = PBXBuildFile; productRef = 7C7BCD532A49AD6400C6FBCF /* Skyflow */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 7C7BCD302A49A5BB00C6FBCF /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 7C7BCD112A49A5B900C6FBCF /* Project object */; - proxyType = 1; - remoteGlobalIDString = 7C7BCD182A49A5B900C6FBCF; - remoteInfo = GetSample; - }; - 7C7BCD3A2A49A5BB00C6FBCF /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 7C7BCD112A49A5B900C6FBCF /* Project object */; - proxyType = 1; - remoteGlobalIDString = 7C7BCD182A49A5B900C6FBCF; - remoteInfo = GetSample; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXFileReference section */ - 7C7BCD192A49A5B900C6FBCF /* GetSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GetSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 7C7BCD1C2A49A5B900C6FBCF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7C7BCD1E2A49A5B900C6FBCF /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 7C7BCD202A49A5B900C6FBCF /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; - 7C7BCD232A49A5B900C6FBCF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 7C7BCD252A49A5BA00C6FBCF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 7C7BCD282A49A5BA00C6FBCF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 7C7BCD2A2A49A5BA00C6FBCF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 7C7BCD2F2A49A5BB00C6FBCF /* GetSampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GetSampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 7C7BCD332A49A5BB00C6FBCF /* GetSampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSampleTests.swift; sourceTree = ""; }; - 7C7BCD392A49A5BB00C6FBCF /* GetSampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GetSampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 7C7BCD3D2A49A5BB00C6FBCF /* GetSampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSampleUITests.swift; sourceTree = ""; }; - 7C7BCD3F2A49A5BB00C6FBCF /* GetSampleUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSampleUITestsLaunchTests.swift; sourceTree = ""; }; - 7C7BCD4C2A49A70900C6FBCF /* ExampleAPICallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleAPICallback.swift; sourceTree = ""; }; - 7C7BCD4E2A49A72900C6FBCF /* ResponseStructs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseStructs.swift; sourceTree = ""; }; - 7C7BCD502A49A75900C6FBCF /* ExampleTokenProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleTokenProvider.swift; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 7C7BCD162A49A5B900C6FBCF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 7C7BCD542A49AD6400C6FBCF /* Skyflow in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 7C7BCD2C2A49A5BB00C6FBCF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 7C7BCD362A49A5BB00C6FBCF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 7C7BCD102A49A5B900C6FBCF = { - isa = PBXGroup; - children = ( - 7C7BCD1B2A49A5B900C6FBCF /* GetSample */, - 7C7BCD322A49A5BB00C6FBCF /* GetSampleTests */, - 7C7BCD3C2A49A5BB00C6FBCF /* GetSampleUITests */, - 7C7BCD1A2A49A5B900C6FBCF /* Products */, - ); - sourceTree = ""; - }; - 7C7BCD1A2A49A5B900C6FBCF /* Products */ = { - isa = PBXGroup; - children = ( - 7C7BCD192A49A5B900C6FBCF /* GetSample.app */, - 7C7BCD2F2A49A5BB00C6FBCF /* GetSampleTests.xctest */, - 7C7BCD392A49A5BB00C6FBCF /* GetSampleUITests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 7C7BCD1B2A49A5B900C6FBCF /* GetSample */ = { - isa = PBXGroup; - children = ( - 7C7BCD1C2A49A5B900C6FBCF /* AppDelegate.swift */, - 7C7BCD1E2A49A5B900C6FBCF /* SceneDelegate.swift */, - 7C7BCD202A49A5B900C6FBCF /* ViewController.swift */, - 7C7BCD222A49A5B900C6FBCF /* Main.storyboard */, - 7C7BCD252A49A5BA00C6FBCF /* Assets.xcassets */, - 7C7BCD272A49A5BA00C6FBCF /* LaunchScreen.storyboard */, - 7C7BCD2A2A49A5BA00C6FBCF /* Info.plist */, - 7C7BCD4C2A49A70900C6FBCF /* ExampleAPICallback.swift */, - 7C7BCD4E2A49A72900C6FBCF /* ResponseStructs.swift */, - 7C7BCD502A49A75900C6FBCF /* ExampleTokenProvider.swift */, - ); - path = GetSample; - sourceTree = ""; - }; - 7C7BCD322A49A5BB00C6FBCF /* GetSampleTests */ = { - isa = PBXGroup; - children = ( - 7C7BCD332A49A5BB00C6FBCF /* GetSampleTests.swift */, - ); - path = GetSampleTests; - sourceTree = ""; - }; - 7C7BCD3C2A49A5BB00C6FBCF /* GetSampleUITests */ = { - isa = PBXGroup; - children = ( - 7C7BCD3D2A49A5BB00C6FBCF /* GetSampleUITests.swift */, - 7C7BCD3F2A49A5BB00C6FBCF /* GetSampleUITestsLaunchTests.swift */, - ); - path = GetSampleUITests; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 7C7BCD182A49A5B900C6FBCF /* GetSample */ = { - isa = PBXNativeTarget; - buildConfigurationList = 7C7BCD432A49A5BB00C6FBCF /* Build configuration list for PBXNativeTarget "GetSample" */; - buildPhases = ( - 7C7BCD152A49A5B900C6FBCF /* Sources */, - 7C7BCD162A49A5B900C6FBCF /* Frameworks */, - 7C7BCD172A49A5B900C6FBCF /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = GetSample; - packageProductDependencies = ( - 7C7BCD532A49AD6400C6FBCF /* Skyflow */, - ); - productName = GetSample; - productReference = 7C7BCD192A49A5B900C6FBCF /* GetSample.app */; - productType = "com.apple.product-type.application"; - }; - 7C7BCD2E2A49A5BB00C6FBCF /* GetSampleTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 7C7BCD462A49A5BB00C6FBCF /* Build configuration list for PBXNativeTarget "GetSampleTests" */; - buildPhases = ( - 7C7BCD2B2A49A5BB00C6FBCF /* Sources */, - 7C7BCD2C2A49A5BB00C6FBCF /* Frameworks */, - 7C7BCD2D2A49A5BB00C6FBCF /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 7C7BCD312A49A5BB00C6FBCF /* PBXTargetDependency */, - ); - name = GetSampleTests; - productName = GetSampleTests; - productReference = 7C7BCD2F2A49A5BB00C6FBCF /* GetSampleTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 7C7BCD382A49A5BB00C6FBCF /* GetSampleUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 7C7BCD492A49A5BB00C6FBCF /* Build configuration list for PBXNativeTarget "GetSampleUITests" */; - buildPhases = ( - 7C7BCD352A49A5BB00C6FBCF /* Sources */, - 7C7BCD362A49A5BB00C6FBCF /* Frameworks */, - 7C7BCD372A49A5BB00C6FBCF /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 7C7BCD3B2A49A5BB00C6FBCF /* PBXTargetDependency */, - ); - name = GetSampleUITests; - productName = GetSampleUITests; - productReference = 7C7BCD392A49A5BB00C6FBCF /* GetSampleUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 7C7BCD112A49A5B900C6FBCF /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1400; - LastUpgradeCheck = 1400; - TargetAttributes = { - 7C7BCD182A49A5B900C6FBCF = { - CreatedOnToolsVersion = 14.0; - }; - 7C7BCD2E2A49A5BB00C6FBCF = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 7C7BCD182A49A5B900C6FBCF; - }; - 7C7BCD382A49A5BB00C6FBCF = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 7C7BCD182A49A5B900C6FBCF; - }; - }; - }; - buildConfigurationList = 7C7BCD142A49A5B900C6FBCF /* Build configuration list for PBXProject "GetSample" */; - compatibilityVersion = "Xcode 14.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 7C7BCD102A49A5B900C6FBCF; - packageReferences = ( - 7C7BCD522A49AD6400C6FBCF /* XCRemoteSwiftPackageReference "skyflow-iOS" */, - ); - productRefGroup = 7C7BCD1A2A49A5B900C6FBCF /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 7C7BCD182A49A5B900C6FBCF /* GetSample */, - 7C7BCD2E2A49A5BB00C6FBCF /* GetSampleTests */, - 7C7BCD382A49A5BB00C6FBCF /* GetSampleUITests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 7C7BCD172A49A5B900C6FBCF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 7C7BCD292A49A5BA00C6FBCF /* LaunchScreen.storyboard in Resources */, - 7C7BCD262A49A5BA00C6FBCF /* Assets.xcassets in Resources */, - 7C7BCD242A49A5B900C6FBCF /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 7C7BCD2D2A49A5BB00C6FBCF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 7C7BCD372A49A5BB00C6FBCF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 7C7BCD152A49A5B900C6FBCF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 7C7BCD4F2A49A72900C6FBCF /* ResponseStructs.swift in Sources */, - 7C7BCD512A49A75900C6FBCF /* ExampleTokenProvider.swift in Sources */, - 7C7BCD212A49A5B900C6FBCF /* ViewController.swift in Sources */, - 7C7BCD1D2A49A5B900C6FBCF /* AppDelegate.swift in Sources */, - 7C7BCD4D2A49A70900C6FBCF /* ExampleAPICallback.swift in Sources */, - 7C7BCD1F2A49A5B900C6FBCF /* SceneDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 7C7BCD2B2A49A5BB00C6FBCF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 7C7BCD342A49A5BB00C6FBCF /* GetSampleTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 7C7BCD352A49A5BB00C6FBCF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 7C7BCD3E2A49A5BB00C6FBCF /* GetSampleUITests.swift in Sources */, - 7C7BCD402A49A5BB00C6FBCF /* GetSampleUITestsLaunchTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 7C7BCD312A49A5BB00C6FBCF /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 7C7BCD182A49A5B900C6FBCF /* GetSample */; - targetProxy = 7C7BCD302A49A5BB00C6FBCF /* PBXContainerItemProxy */; - }; - 7C7BCD3B2A49A5BB00C6FBCF /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 7C7BCD182A49A5B900C6FBCF /* GetSample */; - targetProxy = 7C7BCD3A2A49A5BB00C6FBCF /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 7C7BCD222A49A5B900C6FBCF /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 7C7BCD232A49A5B900C6FBCF /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 7C7BCD272A49A5BA00C6FBCF /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 7C7BCD282A49A5BA00C6FBCF /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 7C7BCD412A49A5BB00C6FBCF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 7C7BCD422A49A5BB00C6FBCF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 7C7BCD442A49A5BB00C6FBCF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = GetSample/Info.plist; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.skyflow.InputFormatting.GetSample; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 7C7BCD452A49A5BB00C6FBCF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = GetSample/Info.plist; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UIMainStoryboardFile = Main; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.skyflow.InputFormatting.GetSample; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - 7C7BCD472A49A5BB00C6FBCF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.skyflow.InputFormatting.GetSampleTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GetSample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/GetSample"; - }; - name = Debug; - }; - 7C7BCD482A49A5BB00C6FBCF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.skyflow.InputFormatting.GetSampleTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GetSample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/GetSample"; - }; - name = Release; - }; - 7C7BCD4A2A49A5BB00C6FBCF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.skyflow.InputFormatting.GetSampleUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = GetSample; - }; - name = Debug; - }; - 7C7BCD4B2A49A5BB00C6FBCF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.skyflow.InputFormatting.GetSampleUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = GetSample; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 7C7BCD142A49A5B900C6FBCF /* Build configuration list for PBXProject "GetSample" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 7C7BCD412A49A5BB00C6FBCF /* Debug */, - 7C7BCD422A49A5BB00C6FBCF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 7C7BCD432A49A5BB00C6FBCF /* Build configuration list for PBXNativeTarget "GetSample" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 7C7BCD442A49A5BB00C6FBCF /* Debug */, - 7C7BCD452A49A5BB00C6FBCF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 7C7BCD462A49A5BB00C6FBCF /* Build configuration list for PBXNativeTarget "GetSampleTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 7C7BCD472A49A5BB00C6FBCF /* Debug */, - 7C7BCD482A49A5BB00C6FBCF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 7C7BCD492A49A5BB00C6FBCF /* Build configuration list for PBXNativeTarget "GetSampleUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 7C7BCD4A2A49A5BB00C6FBCF /* Debug */, - 7C7BCD4B2A49A5BB00C6FBCF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - 7C7BCD522A49AD6400C6FBCF /* XCRemoteSwiftPackageReference "skyflow-iOS" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/skyflowapi/skyflow-iOS.git"; - requirement = { - branch = main; - kind = branch; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 7C7BCD532A49AD6400C6FBCF /* Skyflow */ = { - isa = XCSwiftPackageProductDependency; - package = 7C7BCD522A49AD6400C6FBCF /* XCRemoteSwiftPackageReference "skyflow-iOS" */; - productName = Skyflow; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 7C7BCD112A49A5B900C6FBCF /* Project object */; -} diff --git a/Samples/GetSample/GetSample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Samples/GetSample/GetSample.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6..00000000 --- a/Samples/GetSample/GetSample.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/Samples/GetSample/GetSample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Samples/GetSample/GetSample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/Samples/GetSample/GetSample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/Samples/GetSample/GetSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Samples/GetSample/GetSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index f5762feb..00000000 --- a/Samples/GetSample/GetSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,23 +0,0 @@ -{ - "pins" : [ - { - "identity" : "aexml", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tadija/AEXML.git", - "state" : { - "revision" : "38f7d00b23ecd891e1ee656fa6aeebd6ba04ecc3", - "version" : "4.6.1" - } - }, - { - "identity" : "skyflow-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/skyflowapi/skyflow-iOS.git", - "state" : { - "branch" : "main", - "revision" : "d746cff983d44dd4690780c14aa7ca60387d7850" - } - } - ], - "version" : 2 -} diff --git a/Samples/GetSample/GetSample/AppDelegate.swift b/Samples/GetSample/GetSample/AppDelegate.swift deleted file mode 100644 index aec625c4..00000000 --- a/Samples/GetSample/GetSample/AppDelegate.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// AppDelegate.swift -// GetSample -// -// Created by Bharti Sagar on 26/06/23. -// - -import UIKit - -@main -class AppDelegate: UIResponder, UIApplicationDelegate { - - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - return true - } - - // MARK: UISceneSession Lifecycle - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } - - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. - } - - -} - diff --git a/Samples/GetSample/GetSample/Assets.xcassets/AccentColor.colorset/Contents.json b/Samples/GetSample/GetSample/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb878970..00000000 --- a/Samples/GetSample/GetSample/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Samples/GetSample/GetSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Samples/GetSample/GetSample/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 13613e3e..00000000 --- a/Samples/GetSample/GetSample/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Samples/GetSample/GetSample/Assets.xcassets/Contents.json b/Samples/GetSample/GetSample/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/Samples/GetSample/GetSample/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Samples/GetSample/GetSample/Base.lproj/LaunchScreen.storyboard b/Samples/GetSample/GetSample/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 865e9329..00000000 --- a/Samples/GetSample/GetSample/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Samples/GetSample/GetSample/Base.lproj/Main.storyboard b/Samples/GetSample/GetSample/Base.lproj/Main.storyboard deleted file mode 100644 index 25a76385..00000000 --- a/Samples/GetSample/GetSample/Base.lproj/Main.storyboard +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Samples/GetSample/GetSample/ExampleAPICallback.swift b/Samples/GetSample/GetSample/ExampleAPICallback.swift deleted file mode 100644 index 530d74fd..00000000 --- a/Samples/GetSample/GetSample/ExampleAPICallback.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// ExampleAPICallback.swift -// GetSample -// -// Created by Bharti Sagar on 26/06/23. -// -import Foundation -import Skyflow - -public class ExampleAPICallback: Skyflow.Callback { - private var updateSuccess: ((SuccessResponse) -> Void)? - private var updateFailure: ((Any) -> Void)? - private var decoder: JSONDecoder? - - internal init(updateSuccess: @escaping (SuccessResponse) -> Void, updateFailure: @escaping (Any) -> Void) { - self.updateSuccess = updateSuccess - self.updateFailure = updateFailure - self.decoder = JSONDecoder() - } - - internal init() { - } - - public func onSuccess(_ responseBody: Any) { - if updateSuccess == nil { - print("success:", responseBody) - return - } - do { - let dataString = String(data: try! JSONSerialization.data(withJSONObject: responseBody), encoding: .utf8) - let responseData = Data(dataString!.utf8) - let jsonResponse = try decoder!.decode(SuccessResponse.self, from: responseData) - - updateSuccess!(jsonResponse) - - print("success:", jsonResponse) - } catch { - print("error deserializing", error) - } - } - - public func onFailure(_ error: Any) { - print(error) - if updateFailure != nil { - print("failure:", error) - return - } - print("onfailure", error) - } -} diff --git a/Samples/GetSample/GetSample/ExampleTokenProvider.swift b/Samples/GetSample/GetSample/ExampleTokenProvider.swift deleted file mode 100644 index 75a0efee..00000000 --- a/Samples/GetSample/GetSample/ExampleTokenProvider.swift +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2022 Skyflow - */ - -import Foundation -import Skyflow - -public class ExampleTokenProvider: TokenProvider { - public func getBearerToken(_ apiCallback: Skyflow.Callback) { - if let url = URL(string: "") { - let session = URLSession(configuration: .default) - let task = session.dataTask(with: url) { data, _, error in - if error != nil { - print(error!) - return - } - if let safeData = data { - do { - let x = try JSONSerialization.jsonObject(with: safeData, options: []) as? [String: String] - if let accessToken = x?["accessToken"] { - apiCallback.onSuccess(accessToken) - } - } catch { - print("access token wrong format") - } - } - } - task.resume() - } - } -} diff --git a/Samples/GetSample/GetSample/Info.plist b/Samples/GetSample/GetSample/Info.plist deleted file mode 100644 index dd3c9afd..00000000 --- a/Samples/GetSample/GetSample/Info.plist +++ /dev/null @@ -1,25 +0,0 @@ - - - - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - - diff --git a/Samples/GetSample/GetSample/ResponseStructs.swift b/Samples/GetSample/GetSample/ResponseStructs.swift deleted file mode 100644 index 959044f5..00000000 --- a/Samples/GetSample/GetSample/ResponseStructs.swift +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2022 Skyflow - */ - -import Foundation - -struct SuccessResponse: Codable { - let records: [Records] -} - -struct Records: Codable { - let fields: Fields - let table: String -} - -struct Fields: Codable { - let cardholder_name: String - let card_number: String - let expiry_month: String - let expiry_year: String - let cvv: String - let skyflow_id: String -} diff --git a/Samples/GetSample/GetSample/SceneDelegate.swift b/Samples/GetSample/GetSample/SceneDelegate.swift deleted file mode 100644 index a968b5bc..00000000 --- a/Samples/GetSample/GetSample/SceneDelegate.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// SceneDelegate.swift -// GetSample -// -// Created by Bharti Sagar on 26/06/23. -// - -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - - var window: UIWindow? - - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } - } - - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } - - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } - - -} - diff --git a/Samples/GetSample/GetSample/ViewController.swift b/Samples/GetSample/GetSample/ViewController.swift deleted file mode 100644 index df14f26a..00000000 --- a/Samples/GetSample/GetSample/ViewController.swift +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (c) 2022 Skyflow - */ - -import UIKit -import Skyflow - -class ViewController: UIViewController { - private var skyflow: Skyflow.Client? - private var stackView: UIStackView! - - override func loadView() { - let view = UIView() - view.backgroundColor = .white - view.translatesAutoresizingMaskIntoConstraints = false - self.view = view - } - - override func viewDidLoad() { - super.viewDidLoad() - - let tokenProvider = ExampleTokenProvider() - self.stackView = UIStackView() - - let config = Skyflow.Configuration( - vaultID: "", - vaultURL: "", - tokenProvider: tokenProvider, - options: Skyflow.Options( - logLevel: Skyflow.LogLevel.DEBUG - ) - ) - - self.skyflow = Skyflow.initialize(config) - - if self.skyflow != nil { - let getWithRedactionType = UIButton(frame: CGRect(x: 100, y: 400, width: 100, height: 70)) - getWithRedactionType.backgroundColor = .gray - getWithRedactionType.setTitle("Get Records With Redaction Type", for: .normal) - getWithRedactionType.addTarget(self, action: #selector(getRecordsWithRedactionType), for: .touchUpInside) - - let getWithGetOptionTokens = UIButton(frame: CGRect(x: 100, y: 400, width: 100, height: 70)) - getWithGetOptionTokens.backgroundColor = .gray - getWithGetOptionTokens.setTitle("Get Records with tokens", for: .normal) - getWithGetOptionTokens.addTarget(self, action: #selector(getRecordsWithGetOptionTokens), for: .touchUpInside) - - stackView.addArrangedSubview(getWithRedactionType) - stackView.addArrangedSubview(getWithGetOptionTokens) - - stackView.axis = .vertical - stackView.distribution = .fill - stackView.spacing = 10 - stackView.alignment = .fill - stackView.translatesAutoresizingMaskIntoConstraints = false - let scrollView = UIScrollView(frame: .zero) - scrollView.isScrollEnabled = true - scrollView.backgroundColor = .white - scrollView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(scrollView) - scrollView.topAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.topAnchor, - constant: 20 - ).isActive = true - scrollView.leftAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.leftAnchor, - constant: 10 - ).isActive = true - scrollView.rightAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.rightAnchor, - constant: -10 - ).isActive = true - scrollView.bottomAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.bottomAnchor - ).isActive = true - scrollView.addSubview(stackView) - stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -10).isActive = true - stackView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true - stackView.leftAnchor.constraint(equalTo: scrollView.leftAnchor).isActive = true - stackView.rightAnchor.constraint(equalTo: scrollView.rightAnchor, constant: -10).isActive = true - stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true - } - } - @objc func getRecordsWithRedactionType() { - let exampleAPICallback = ExampleAPICallback(updateSuccess: updateSuccess, updateFailure: updateFailure) - let record = ["ids": ["", ""], "table": "", "redaction": RedactionType.PLAIN_TEXT] as [String : Any] - let recordColumn = ["columnValues": ["", ""],"table": "", "columnName": "","redaction": RedactionType.PLAIN_TEXT] as [String : Any] - - let invalidID = ["invalid skyflow ID"] - let badRecord = ["ids": invalidID, "table": "table", "redaction": RedactionType.PLAIN_TEXT] as [String : Any] - - let records = ["records": [record, recordColumn, badRecord]] - self.skyflow?.get(records: records, callback: exampleAPICallback) - } - - @objc func getRecordsWithGetOptionTokens() { - let exampleAPICallback = ExampleAPICallback(updateSuccess: updateSuccess, updateFailure: updateFailure) - let record = ["ids": ["", ""], "table": ""] as [String : Any] - - let invalidID = ["invalid skyflow ID"] - let badRecord = ["ids": invalidID, "table": "table", "redaction": RedactionType.PLAIN_TEXT] as [String : Any] - - let records = ["records": [record, badRecord]] - self.skyflow?.get(records: records, options: GetOptions(tokens: true), callback: exampleAPICallback) - } - internal func updateSuccess(_ response: SuccessResponse) { - print(response) - print("Successfully got response:", response) - } - internal func updateFailure(error: Any) { - print("Failed Operation", error) - } -} diff --git a/Samples/GetSample/GetSampleTests/GetSampleTests.swift b/Samples/GetSample/GetSampleTests/GetSampleTests.swift deleted file mode 100644 index 4837e722..00000000 --- a/Samples/GetSample/GetSampleTests/GetSampleTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// GetSampleTests.swift -// GetSampleTests -// -// Created by Bharti Sagar on 26/06/23. -// - -import XCTest -@testable import GetSample - -final class GetSampleTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/Samples/GetSample/GetSampleUITests/GetSampleUITests.swift b/Samples/GetSample/GetSampleUITests/GetSampleUITests.swift deleted file mode 100644 index 8b26e9ab..00000000 --- a/Samples/GetSample/GetSampleUITests/GetSampleUITests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// GetSampleUITests.swift -// GetSampleUITests -// -// Created by Bharti Sagar on 26/06/23. -// - -import XCTest - -final class GetSampleUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } - } -} diff --git a/Samples/GetSample/GetSampleUITests/GetSampleUITestsLaunchTests.swift b/Samples/GetSample/GetSampleUITests/GetSampleUITestsLaunchTests.swift deleted file mode 100644 index 75f3db9b..00000000 --- a/Samples/GetSample/GetSampleUITests/GetSampleUITestsLaunchTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// GetSampleUITestsLaunchTests.swift -// GetSampleUITests -// -// Created by Bharti Sagar on 26/06/23. -// - -import XCTest - -final class GetSampleUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/Samples/GetSample/Podfile b/Samples/GetSample/Podfile deleted file mode 100644 index 72b31da4..00000000 --- a/Samples/GetSample/Podfile +++ /dev/null @@ -1,12 +0,0 @@ -# Uncomment the next line to define a global platform for your project -# platform :ios, '9.0' - -source 'https://github.com/CocoaPods/Specs.git' - -target 'GetSample' do - #use_frameworks! - pod 'AEXML', '4.6.1' - # Pods for GetSample - pod 'Skyflow', ">= 1.22.0" - -end diff --git a/Samples/InputFormatting/InputFormatting/ExampleAPICallback.swift b/Samples/InputFormatting/InputFormatting/ExampleAPICallback.swift index 25f84e24..548a8e4c 100644 --- a/Samples/InputFormatting/InputFormatting/ExampleAPICallback.swift +++ b/Samples/InputFormatting/InputFormatting/ExampleAPICallback.swift @@ -1,50 +1,7 @@ /* * Copyright (c) 2022 Skyflow */ -// Created by Bharti Sagar on 26/04/23. -// -import Foundation -import Skyflow - -public class ExampleAPICallback: Skyflow.Callback { - private var updateSuccess: ((SuccessResponse) -> Void)? - private var updateFailure: ((Any) -> Void)? - private var decoder: JSONDecoder? - - internal init(updateSuccess: @escaping (SuccessResponse) -> Void, updateFailure: @escaping (Any) -> Void) { - self.updateSuccess = updateSuccess - self.updateFailure = updateFailure - self.decoder = JSONDecoder() - } - - internal init() { - } - - public func onSuccess(_ responseBody: Any) { - if updateSuccess == nil { - print("success:", responseBody) - return - } - do { - let dataString = String(data: try! JSONSerialization.data(withJSONObject: responseBody), encoding: .utf8) - let responseData = Data(dataString!.utf8) - let jsonResponse = try decoder!.decode(SuccessResponse.self, from: responseData) - - updateSuccess!(jsonResponse) - - print("success:", jsonResponse) - } catch { - print("error deserializing", error) - } - } - - public func onFailure(_ error: Any) { - print(error) - if updateFailure != nil { - print("failure:", error) - return - } - print("onfailure", error) - } -} +// No longer needed - use Skyflow.CollectCallback / Skyflow.RevealCallback directly +// (see ViewController.swift), which already deliver typed responses +// (Skyflow.CollectResponse / Skyflow.RevealResponse) without a custom Callback subclass. diff --git a/Samples/InputFormatting/InputFormatting/ResponseStructs.swift b/Samples/InputFormatting/InputFormatting/ResponseStructs.swift index 2230b92f..67c28811 100644 --- a/Samples/InputFormatting/InputFormatting/ResponseStructs.swift +++ b/Samples/InputFormatting/InputFormatting/ResponseStructs.swift @@ -2,28 +2,6 @@ * Copyright (c) 2022 Skyflow */ -// Created by Bharti Sagar on 26/04/23. -// - -import Foundation - -struct SuccessResponse: Codable { - let records: [Records] -} - -struct Records: Codable { - let fields: Fields - let table: String -} - -struct Fields: Codable { - let cardholder_name: String - let card_number: String - let expiry_month: String - let expiry_year: String - let cvv: String - let ssn: String - let phone_number: String - let license_number: String - let skyflow_id: String -} +// No longer needed - the SDK now provides typed responses directly +// (Skyflow.CollectResponse / Skyflow.RevealResponse), so this sample no longer +// needs to declare its own local response structs. diff --git a/Samples/InputFormatting/InputFormatting/ViewController.swift b/Samples/InputFormatting/InputFormatting/ViewController.swift index fbacc971..c1b5df2d 100644 --- a/Samples/InputFormatting/InputFormatting/ViewController.swift +++ b/Samples/InputFormatting/InputFormatting/ViewController.swift @@ -77,7 +77,7 @@ class ViewController: UIViewController { ) let collectCardNumberInput = Skyflow.CollectElementInput( - table: "table", + tableName: "table", column: "card_number", inputStyles: styles, label: "Card Number", @@ -85,7 +85,7 @@ class ViewController: UIViewController { type: Skyflow.ElementType.CARD_NUMBER ) let collectNameInput = Skyflow.CollectElementInput( - table: "table", + tableName: "table", column: "cardholder_name", inputStyles: styles, label: "Card Holder Name", @@ -93,7 +93,7 @@ class ViewController: UIViewController { type: Skyflow.ElementType.CARDHOLDER_NAME ) let collectCVVInput = Skyflow.CollectElementInput( - table: "table", + tableName: "table", column: "cvv", inputStyles: styles, label: "CVV", @@ -101,7 +101,7 @@ class ViewController: UIViewController { type: .CVV ) let collectExpMonthInput = Skyflow.CollectElementInput( - table: "table", + tableName: "table", column: "expiry_month", inputStyles: styles, label: "Expiration Month", @@ -109,7 +109,7 @@ class ViewController: UIViewController { type: .EXPIRATION_MONTH ) let collectExpYearInput = Skyflow.CollectElementInput( - table: "table", + tableName: "table", column: "expiry_year", inputStyles: styles, label: "Expiration Year", @@ -117,7 +117,7 @@ class ViewController: UIViewController { type: .EXPIRATION_YEAR ) let collectSSNInput = Skyflow.CollectElementInput( - table: "table", + tableName: "table", column: "ssn", inputStyles: styles, label: "SSN", @@ -125,7 +125,7 @@ class ViewController: UIViewController { type: .INPUT_FIELD ) let collectPhoneNumberInput = Skyflow.CollectElementInput( - table: "table", + tableName: "table", column: "phone_number", inputStyles: styles, label: "Phone Number", @@ -133,7 +133,7 @@ class ViewController: UIViewController { type: .INPUT_FIELD ) let collectLicenseNumberInput = Skyflow.CollectElementInput( - table: "table", + tableName: "table", column: "license_number", inputStyles: styles, label: "License Number", @@ -201,21 +201,37 @@ class ViewController: UIViewController { } } @objc func revealForm() { - self.revealContainer?.reveal(callback: ExampleAPICallback()) + let revealCallback = Skyflow.RevealCallback( + onSuccess: { response in print("reveal success:", response) }, + onFailure: { error in print("reveal failure:", error) } + ) + self.revealContainer?.reveal(callback: revealCallback) } @objc func submitForm() { - let exampleAPICallback = ExampleAPICallback(updateSuccess: updateSuccess, updateFailure: updateFailure) - container!.collect(callback: exampleAPICallback, options: Skyflow.CollectOptions(tokens: true)) + let collectCallback = Skyflow.CollectCallback(onSuccess: updateSuccess, onFailure: updateFailure) + container!.collect(callback: collectCallback, options: Skyflow.CollectOptions()) } - internal func updateSuccess(_ response: SuccessResponse) { + internal func updateSuccess(_ response: Skyflow.CollectResponse) { print(response) - updateRevealInputs(tokens: response.records[0].fields) + for result in response.records { + if let error = result.error { + print("Record failed:", error, "httpCode:", result.httpCode) + } + } + if let tokens = response.records.first?.tokens { + updateRevealInputs(tokens: tokens) + } print("Successfully got response:", response) } internal func updateFailure(error: Any) { print("Failed Operation", error) } - internal func updateRevealInputs(tokens: Fields) { + // fields is [String: Any] - column name -> array of {"token","tokenGroupName"} dicts. + internal func firstToken(_ fields: [String: Any], column: String) -> (token: String, tokenGroupName: String?) { + guard let entries = fields[column] as? [[String: Any]], let first = entries.first else { return ("", nil) } + return (first["token"] as? String ?? "", first["tokenGroupName"] as? String) + } + internal func updateRevealInputs(tokens: [String: Any]) { let revealBaseStyle = Skyflow.Style( borderColor: UIColor.black, cornerRadius: 20, @@ -232,7 +248,7 @@ class ViewController: UIViewController { self.revealed = true } let revealCardNumberInput = Skyflow.RevealElementInput( - token: tokens.card_number, + token: firstToken(tokens, column: "card_number").token ?? "", inputStyles: revealStyles, label: "Card Number" ) @@ -241,43 +257,43 @@ class ViewController: UIViewController { options: Skyflow.RevealElementOptions(format: "XXXX-XXXX-XXXX-XXXX-XXX", translation: ["X": "[0-9]"]) ) let revealCVVtInput = Skyflow.RevealElementInput( - token: tokens.cvv, + token: firstToken(tokens, column: "cvv").token ?? "", inputStyles: revealStyles, label: "CVV" ) self.revealCVV = self.revealContainer?.create(input: revealCVVtInput) let revealNameInput = Skyflow.RevealElementInput( - token: tokens.cardholder_name, + token: firstToken(tokens, column: "cardholder_name").token ?? "", inputStyles: revealStyles, label: "Card Holder Name" ) self.revealName = self.revealContainer?.create(input: revealNameInput) let revealExpirationMonthInput = Skyflow.RevealElementInput( - token: tokens.expiry_month, + token: firstToken(tokens, column: "expiry_month").token ?? "", inputStyles: revealStyles, label: "Expiration Month" ) self.revealExpirationMonth = self.revealContainer?.create(input: revealExpirationMonthInput) let revealExpirationYearInput = Skyflow.RevealElementInput( - token: tokens.expiry_year, + token: firstToken(tokens, column: "expiry_year").token ?? "", inputStyles: revealStyles, label: "Expiration Year" ) self.revealExpirationYear = self.revealContainer?.create(input: revealExpirationYearInput) let revealSSNInput = Skyflow.RevealElementInput( - token: tokens.ssn, + token: firstToken(tokens, column: "ssn").token ?? "", inputStyles: revealStyles, label: "SSN" ) self.revealSSN = self.revealContainer?.create(input: revealSSNInput, options: Skyflow.RevealElementOptions(format: "XXX XX XXXX", translation: ["X": "[0-9]"])) let revealPhoneNumberInput = Skyflow.RevealElementInput( - token: tokens.phone_number, + token: firstToken(tokens, column: "phone_number").token ?? "", inputStyles: revealStyles, label: "Phone Number" ) self.revealPhoneNumber = self.revealContainer?.create(input: revealPhoneNumberInput, options: Skyflow.RevealElementOptions(format: "+91 XXXX-XX-XXXX", translation: ["X": "[0-9]"])) let revealLicenseNumberInput = Skyflow.RevealElementInput( - token: tokens.phone_number, + token: firstToken(tokens, column: "phone_number").token ?? "", inputStyles: revealStyles, label: "License Number" ) diff --git a/Samples/InputFormatting/Podfile b/Samples/InputFormatting/Podfile index f4ddb57c..354055ad 100644 --- a/Samples/InputFormatting/Podfile +++ b/Samples/InputFormatting/Podfile @@ -3,9 +3,8 @@ target 'InputFormatting' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! - pod 'AEXML', '4.6.1' - pod 'Skyflow', ">= 1.22.0" + pod 'Skyflow', ">= 1.25.0" # Pods for InputFormatting diff --git a/Samples/InputFormatting/Podfile.lock b/Samples/InputFormatting/Podfile.lock index e9d81271..7592f10a 100644 --- a/Samples/InputFormatting/Podfile.lock +++ b/Samples/InputFormatting/Podfile.lock @@ -1,18 +1,14 @@ PODS: - - AEXML (4.6.1) - Skyflow (1.18.0-beta.1): - - AEXML DEPENDENCIES: - Skyflow (= 1.18.0-beta.1) SPEC REPOS: trunk: - - AEXML - Skyflow SPEC CHECKSUMS: - AEXML: 1e255ecc6597212f97a7454a69ebd3ede64ac1cf Skyflow: d702f8e9beafba05e164a3f74a6f862596f41085 PODFILE CHECKSUM: 4958176a88d5d7c9eb37117625a3937f5da81019 diff --git a/Samples/UpdateDataUsingElements/Podfile b/Samples/UpdateDataUsingElements/Podfile index 288940eb..b8ac5a1c 100644 --- a/Samples/UpdateDataUsingElements/Podfile +++ b/Samples/UpdateDataUsingElements/Podfile @@ -6,20 +6,17 @@ target 'UpdateDataUsingElements' do use_frameworks! # Pods for UpdateDataUsingElements - pod 'AEXML', '4.6.1' - pod 'Skyflow', '1.25.0' + pod 'Skyflow', '>=1.25.0' target 'UpdateDataUsingElementsTests' do inherit! :search_paths # Pods for testing - pod 'AEXML', '4.6.1' - pod 'Skyflow', '1.25.0' + pod 'Skyflow', '>=1.25.0' end target 'UpdateDataUsingElementsUITests' do # Pods for testing - pod 'AEXML', '4.6.1' - pod 'Skyflow', '1.25.0' + pod 'Skyflow', '>=1.25.0' end diff --git a/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ExampleAPICallback.swift b/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ExampleAPICallback.swift index b08d2305..548a8e4c 100644 --- a/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ExampleAPICallback.swift +++ b/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ExampleAPICallback.swift @@ -2,47 +2,6 @@ * Copyright (c) 2022 Skyflow */ -import Foundation -import Skyflow - -public class ExampleAPICallback: Skyflow.Callback { - private var updateSuccess: ((SuccessResponse) -> Void)? - private var updateFailure: ((Any) -> Void)? - private var decoder: JSONDecoder? - - internal init(updateSuccess: @escaping (Any) -> Void, updateFailure: @escaping (Any) -> Void) { - self.updateSuccess = updateSuccess - self.updateFailure = updateFailure - self.decoder = JSONDecoder() - } - - internal init() { - } - - public func onSuccess(_ responseBody: Any) { - if updateSuccess == nil { - print("success:", responseBody) - return - } - do { - let dataString = String(data: try! JSONSerialization.data(withJSONObject: responseBody), encoding: .utf8) - let responseData = Data(dataString!.utf8) - let jsonResponse = try decoder!.decode(SuccessResponse.self, from: responseData) - - updateSuccess!(jsonResponse) - - print("success:", jsonResponse) - } catch { - print("error deserializing", error) - } - } - - public func onFailure(_ error: Any) { - print(error) - if updateFailure != nil { - print("failure:", error) - return - } - print("onfailure", error) - } -} +// No longer needed - use Skyflow.CollectCallback / Skyflow.RevealCallback directly +// (see ViewController.swift), which already deliver typed responses +// (Skyflow.CollectResponse / Skyflow.RevealResponse) without a custom Callback subclass. diff --git a/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ResponseStructs.swift b/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ResponseStructs.swift index 4b948ce6..67c28811 100644 --- a/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ResponseStructs.swift +++ b/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ResponseStructs.swift @@ -2,23 +2,6 @@ * Copyright (c) 2022 Skyflow */ -import Foundation - -struct SuccessResponse: Codable { - let records: [Records] -} - -struct Records: Codable { - let fields: Fields? - let table: String - let skyflow_id: String? -} - -struct Fields: Codable { - let cardholder_name: String? - let card_number: String? - let expiry_month: String? - let expiry_year: String? - let cvv: String? - let skyflow_id: String? -} +// No longer needed - the SDK now provides typed responses directly +// (Skyflow.CollectResponse / Skyflow.RevealResponse), so this sample no longer +// needs to declare its own local response structs. diff --git a/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ViewController.swift b/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ViewController.swift index 6639ec61..d0693f37 100644 --- a/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ViewController.swift +++ b/Samples/UpdateDataUsingElements/UpdateDataUsingElements/ViewController.swift @@ -63,51 +63,51 @@ class ViewController: UIViewController { ) let collectCardNumberInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "card_number", inputStyles: styles, label: "Card Number", placeholder: "4111-1111-1111-1111", type: Skyflow.ElementType.CARD_NUMBER, - skyflowID: "" // replace it with actual skyflowID if you want to test update with elements functionality, otherwise you can remove skyflowID field from input + skyflowId: "" // replace it with actual skyflowId if you want to test update with elements functionality, otherwise you can remove skyflowId field from input ) let collectNameInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "cardholder_name", inputStyles: styles, label: "Card Holder Name", placeholder: "John Doe", type: Skyflow.ElementType.CARDHOLDER_NAME, - skyflowID: "" // replace it with actual skyflowID if you want to test update with elements functionality, otherwise you can remove skyflowID field from input + skyflowId: "" // replace it with actual skyflowId if you want to test update with elements functionality, otherwise you can remove skyflowId field from input ) let collectCVVInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "cvv", inputStyles: styles, label: "CVV", placeholder: "***", type: .CVV, - skyflowID: "" // replace it with actual skyflowID if you want to test update with elements functionality, otherwise you can remove skyflowID field from input + skyflowId: "" // replace it with actual skyflowId if you want to test update with elements functionality, otherwise you can remove skyflowId field from input ) let collectExpMonthInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "expiry_month", inputStyles: styles, label: "Expiration Month", placeholder: "MM", type: .EXPIRATION_MONTH, - skyflowID: "" // replace it with actual skyflowID if you want to test update with elements functionality, otherwise you can remove skyflowID field from input + skyflowId: "" // replace it with actual skyflowId if you want to test update with elements functionality, otherwise you can remove skyflowId field from input ) let collectExpYearInput = Skyflow.CollectElementInput( - table: "credit_cards", + tableName: "credit_cards", column: "expiry_year", inputStyles: styles, label: "Expiration Year", placeholder: "YYYY", type: .EXPIRATION_YEAR, - skyflowID: "" // replace it with actual skyflowID if you want to test update with elements functionality, otherwise you can remove skyflowID field from input + skyflowId: "" // replace it with actual skyflowId if you want to test update with elements functionality, otherwise you can remove skyflowId field from input ) let requiredOption = Skyflow.CollectElementOptions(required: true) @@ -164,12 +164,17 @@ class ViewController: UIViewController { } @objc func submitForm() { - let exampleAPICallback = ExampleAPICallback(updateSuccess: updateSuccess, updateFailure: updateFailure) - container!.collect(callback: exampleAPICallback, options: Skyflow.CollectOptions(tokens: true)) + let collectCallback = Skyflow.CollectCallback(onSuccess: updateSuccess, onFailure: updateFailure) + container!.collect(callback: collectCallback, options: Skyflow.CollectOptions()) } - internal func updateSuccess(_ response: SuccessResponse) { + internal func updateSuccess(_ response: Skyflow.CollectResponse) { print(response) retryCount = 0 + for result in response.records { + if let error = result.error { + print("Record failed:", error, "httpCode:", result.httpCode) + } + } print("Successfully got response:", response) } internal func updateFailure(error: Any) { diff --git a/Samples/UpsertFeature/UpsertFeature.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Samples/UpsertFeature/UpsertFeature.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 040de638..d02f09c6 100644 --- a/Samples/UpsertFeature/UpsertFeature.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Samples/UpsertFeature/UpsertFeature.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { "object": { "pins": [ - { - "package": "AEXML", - "repositoryURL": "https://github.com/tadija/AEXML.git", - "state": { - "branch": null, - "revision": "38f7d00b23ecd891e1ee656fa6aeebd6ba04ecc3", - "version": "4.6.1" - } - }, { "package": "Skyflow", "repositoryURL": "https://github.com/skyflowapi/skyflow-iOS.git", diff --git a/Samples/UpsertFeature/UpsertFeature/ResponseStructs.swift b/Samples/UpsertFeature/UpsertFeature/ResponseStructs.swift index 6ea1a8a6..67c28811 100644 --- a/Samples/UpsertFeature/UpsertFeature/ResponseStructs.swift +++ b/Samples/UpsertFeature/UpsertFeature/ResponseStructs.swift @@ -2,19 +2,6 @@ * Copyright (c) 2022 Skyflow */ -import Foundation - -struct SuccessResponse: Codable { - let records: [Records] -} - -struct Records: Codable { - let fields: Fields - let table: String -} - -struct Fields: Codable { - let cardnumber: String - let cvv: String - let skyflow_id: String -} +// No longer needed - the SDK now provides typed responses directly +// (Skyflow.CollectResponse / Skyflow.RevealResponse), so this sample no longer +// needs to declare its own local response structs. diff --git a/Samples/UpsertFeature/UpsertFeature/ViewController.swift b/Samples/UpsertFeature/UpsertFeature/ViewController.swift index 86f38902..c01a0784 100644 --- a/Samples/UpsertFeature/UpsertFeature/ViewController.swift +++ b/Samples/UpsertFeature/UpsertFeature/ViewController.swift @@ -15,6 +15,7 @@ class ViewController: UIViewController { private var revealCvv: Label? private var revealButton: UIButton! private var revealed = false + private var tokenGroupRedactions: [Skyflow.TokenGroupRedaction] = [] override func loadView() { let view = UIView() @@ -40,7 +41,7 @@ class ViewController: UIViewController { textAlignment: .left, textColor: .blue ) - let focusStyle = Skyflow.Style(borderColor: .blue) ̰ + let focusStyle = Skyflow.Style(borderColor: .blue) let completedStyle = Skyflow.Style(borderColor: UIColor.green, textColor: UIColor.green) let invalidStyle = Skyflow.Style(borderColor: UIColor.red, textColor: UIColor.red) let styles = Skyflow.Styles( @@ -85,7 +86,7 @@ class ViewController: UIViewController { ) // keep card number as unique column for testing upsert feature let collectCardNumberInput = Skyflow.CollectElementInput( - table: "persons", + tableName: "persons", column: "cardnumber", inputStyles: styles, labelStyles: labelStyles, @@ -97,7 +98,7 @@ class ViewController: UIViewController { let requiredOption = Skyflow.CollectElementOptions(required: true) let collectCardNumber = container?.create(input: collectCardNumberInput, options: requiredOption) let collectCvvInput = Skyflow.CollectElementInput( - table: "persons", + tableName: "persons", column: "cvv", inputStyles: styles, label: "Cvv", @@ -150,25 +151,52 @@ class ViewController: UIViewController { } @objc func revealForm() { - self.revealContainer?.reveal(callback: ExampleAPICallback()) + let revealCallback = Skyflow.RevealCallback( + onSuccess: { (response: Skyflow.RevealResponse) in + for record in response.records { + if let error = record.error { + print("reveal failed:", error, "httpCode:", record.httpCode) + } else { + print("revealed:", record.token ?? "", record.tokenGroupName ?? "", record.metadata ?? [:]) + } + } + }, + onFailure: { (error: Skyflow.SkyflowError) in print("reveal failure:", error.httpCode, error.message) } + ) + let revealOptions = Skyflow.RevealOptions(tokenGroupRedactions: self.tokenGroupRedactions.isEmpty ? nil : self.tokenGroupRedactions) + self.revealContainer?.reveal(callback: revealCallback, options: revealOptions) } @objc func submitForm() { - let exampleAPICallback = ExampleAPICallback(updateSuccess: updateSuccess, updateFailure: updateFailure) - let upsertOptions = [["table": "persons", "column": "cardnumber"]] as [[String: Any]] - container!.collect(callback: exampleAPICallback, options: Skyflow.CollectOptions(tokens: true)) + let collectCallback = Skyflow.CollectCallback(onSuccess: updateSuccess, onFailure: updateFailure) + let upsertOptions = [Skyflow.UpsertOption(tableName: "persons", uniqueColumns: ["cardnumber"], updateType: .UPDATE)] + container!.collect(callback: collectCallback, options: Skyflow.CollectOptions(upsert: upsertOptions)) } - internal func updateSuccess(_ response: SuccessResponse) { - updateRevealInputs(tokens: response.records[0].fields) + internal func updateSuccess(_ response: Skyflow.CollectResponse) { + print(response) + for result in response.records { + if let error = result.error { + print("Record failed:", error, "httpCode:", result.httpCode) + } + } + if let tokens = response.records.first?.tokens { + updateRevealInputs(tokens: tokens) + } print("Successfully got response:", response) } - internal func updateFailure() { - print("Failed Operation") + internal func updateFailure(error: Skyflow.SkyflowError) { + print("Failed Operation", error.httpCode, error.message, error.grpcCode ?? "", error.httpStatus ?? "", error.details ?? "") + } + + // fields is [String: Any] - column name -> array of {"token","tokenGroupName"} dicts. + internal func firstToken(_ fields: [String: Any], column: String) -> (token: String, tokenGroupName: String?) { + guard let entries = fields[column] as? [[String: Any]], let first = entries.first else { return ("", nil) } + return (first["token"] as? String ?? "", first["tokenGroupName"] as? String) } - internal func updateRevealInputs(tokens: Fields) { + internal func updateRevealInputs(tokens: [String: Any]) { let revealBaseStyle = Skyflow.Style( borderColor: UIColor.black, cornerRadius: 20, @@ -184,21 +212,30 @@ class ViewController: UIViewController { } else { self.revealed = true } + // tokenGroupRedactions: request-level redaction per token group, applied when + // reveal() is called (see revealForm()) - not per reveal element. + self.tokenGroupRedactions = [] + func addTokenGroupRedaction(_ redaction: String, forColumn column: String) { + if let tokenGroupName = firstToken(tokens, column: column).tokenGroupName { + self.tokenGroupRedactions.append(Skyflow.TokenGroupRedaction(tokenGroupName: tokenGroupName, redaction: redaction)) + } + } + addTokenGroupRedaction("DEFAULT", forColumn: "cardnumber") + addTokenGroupRedaction("DEFAULT", forColumn: "cvv") + let revealCardNumberInput = Skyflow.RevealElementInput( - token: tokens.cardnumber, + token: firstToken(tokens, column: "cardnumber").token, inputStyles: revealStyles, - label: "Card Number", - redaction: .DEFAULT + label: "Card Number" ) self.revealCardNumber = self.revealContainer?.create( input: revealCardNumberInput, options: Skyflow.RevealElementOptions() ) let revealCvvInput = Skyflow.RevealElementInput( - token: tokens.cvv, + token: firstToken(tokens, column: "cvv").token, inputStyles: revealStyles, - label: "Cvv", - redaction: .DEFAULT + label: "Cvv" ) self.revealCvv = self.revealContainer?.create( input: revealCvvInput, diff --git a/Samples/Validations/Podfile b/Samples/Validations/Podfile index cb757a40..e8fecb94 100644 --- a/Samples/Validations/Podfile +++ b/Samples/Validations/Podfile @@ -5,8 +5,7 @@ source 'https://github.com/CocoaPods/Specs.git' target 'Validations' do #use_frameworks! - pod 'AEXML', '4.6.1' # Pods for skyflow-iOS-sample-app - pod 'Skyflow', ">= 1.22.0" + pod 'Skyflow', ">= 1.25.0" end diff --git a/Skyflow.podspec b/Skyflow.podspec index 88887bb6..71c1d811 100644 --- a/Skyflow.podspec +++ b/Skyflow.podspec @@ -2,7 +2,7 @@ Pod::Spec.new do |spec| spec.name = "Skyflow" - spec.version = "1.25.1" + spec.version = "1.26.0-beta.1" spec.summary = "skyflow-iOS" @@ -20,7 +20,7 @@ Pod::Spec.new do |spec| spec.ios.deployment_target = "13.0" - spec.source = { :git => "https://github.com/skyflowapi/skyflow-iOS.git", :tag => "1.25.1" } + spec.source = { :git => "https://github.com/skyflowapi/skyflow-iOS.git", :tag => "1.26.0-beta.1" } spec.source_files = "Sources/Skyflow/**/*.{swift}" diff --git a/Sources/Skyflow/Version.swift b/Sources/Skyflow/Version.swift index a89bf081..2a3a8bdc 100644 --- a/Sources/Skyflow/Version.swift +++ b/Sources/Skyflow/Version.swift @@ -13,4 +13,4 @@ import Foundation var LangAndVersion = "iOS SDK v\(SDK_VERSION)" -var SDK_VERSION = "1.25.1" +var SDK_VERSION = "1.26.0-beta.1" diff --git a/Sources/Skyflow/collect/AdditionalFields.swift b/Sources/Skyflow/collect/AdditionalFields.swift new file mode 100644 index 00000000..659ec242 --- /dev/null +++ b/Sources/Skyflow/collect/AdditionalFields.swift @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Typed replacement for the old [String: Any] additionalFields shape on CollectOptions - +// non-PCI records to insert/update alongside whatever's collected from mounted elements. + +import Foundation + +public struct AdditionalFieldsRecord { + public let tableName: String + public let data: [String: Any] + // Present to update an existing record, absent to insert a new one. + public let skyflowId: String? + + public init(tableName: String, data: [String: Any], skyflowId: String? = nil) { + self.tableName = tableName + self.data = data + self.skyflowId = skyflowId + } +} + +public struct AdditionalFields { + public let records: [AdditionalFieldsRecord] + + public init(records: [AdditionalFieldsRecord]) { + self.records = records + } +} diff --git a/Sources/Skyflow/collect/CVVMockGenerator.swift b/Sources/Skyflow/collect/CVVMockGenerator.swift new file mode 100644 index 00000000..73465dd8 --- /dev/null +++ b/Sources/Skyflow/collect/CVVMockGenerator.swift @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Generates a mock CVV placeholder used to mask the real CVV token in collect responses (FlowDB v2). + +import Foundation + +internal enum CVVMockGenerator { + internal static func generateMockCVV(length: Int, actualValue: String) -> String { + guard length > 0 else { return "" } + var mock: String + repeat { + mock = randomNumericString(length: length) + } while mock == actualValue + return mock + } + + private static func randomNumericString(length: Int) -> String { + var bytes = [UInt8](repeating: 0, count: length) + let status = SecRandomCopyBytes(kSecRandomDefault, length, &bytes) + if status != errSecSuccess { + var generator = SystemRandomNumberGenerator() + for i in 0.. entered value, keyed by table name (inserts) or skyflowId (updates). +internal struct CVVCaptureMap { + var byTable: [String: [String: String]] = [:] + var byRecordId: [String: [String: String]] = [:] + + var isEmpty: Bool { byTable.isEmpty && byRecordId.isEmpty } +} + +internal enum CVVTokenReplacer { + // An optional CVV element left blank still submits "" to the vault and can get a real token + // back for it - always capture the column (even when empty) so that token gets replaced with + // "" too (see applyMock below), rather than leaking the real vault token unmasked. + // + // First element wins per (table/recordId, column) - mirrors the existing dedup behavior in + // FlowVaultCollectRequestBody, where a second element sharing a column (ElementValueMatchRule) + // is dropped rather than merged. + internal static func captureCVVMap(elements: [TextField]) -> CVVCaptureMap { + var map = CVVCaptureMap() + for element in elements { + guard element.fieldType == .CVV, let columnName = element.columnName else { continue } + let value = element.getValue() + + if let skyflowId = element.skyflowId, !skyflowId.isEmpty { + if map.byRecordId[skyflowId]?[columnName] == nil { + map.byRecordId[skyflowId, default: [:]][columnName] = value + } + } else if let tableName = element.tableName { + if map.byTable[tableName]?[columnName] == nil { + map.byTable[tableName, default: [:]][columnName] = value + } + } + } + return map + } + + internal static func replaceCVVTokens(in records: [[String: Any]], cvvMap: CVVCaptureMap) -> [[String: Any]] { + guard !cvvMap.isEmpty else { return records } + + return records.map { record in + guard record["error"] == nil, var fields = record["fields"] as? [String: Any] else { return record } + + let cvvColumns: [String: String]? + if let skyflowId = record["skyflowID"] as? String, let byId = cvvMap.byRecordId[skyflowId] { + cvvColumns = byId + } else if let tableName = record["tableName"] as? String, let byTable = cvvMap.byTable[tableName] { + cvvColumns = byTable + } else { + cvvColumns = nil + } + + guard let columns = cvvColumns else { return record } + + for (capturedColumn, enteredValue) in columns { + applyMock(to: &fields, capturedColumn: capturedColumn, enteredValue: enteredValue) + } + + var record = record + record["fields"] = fields + return record + } + } + + // Splits a (possibly nested) captured column at the first "." into a top-level response key + // and a nested path, then replaces the token(s) matching that key/path in the response's raw + // token-entry list for that column, leaving every other entry (and every other column) intact. + private static func applyMock(to fields: inout [String: Any], capturedColumn: String, enteredValue: String) { + let parts = capturedColumn.split(separator: ".", maxSplits: 1).map(String.init) + let topKey = parts[0] + let nestedPath = parts.count > 1 ? parts[1] : nil + + guard var entries = fields[topKey] as? [[String: Any]] else { return } + + // A blank entered CVV has no length to match, and generateMockCVV(length: 0, ...) would + // never terminate its "regenerate until different" loop (every draw is "", which always + // equals the empty entered value) - never call it here; just replace with "" directly. + let mock = enteredValue.isEmpty ? "" : CVVMockGenerator.generateMockCVV(length: enteredValue.count, actualValue: enteredValue) + + for index in entries.indices { + let entryPath = entries[index]["path"] as? String + if nestedPath == nil { + if entryPath == nil { + entries[index]["token"] = mock + } + } else if entryPath == nestedPath { + entries[index]["token"] = mock + } + } + + fields[topKey] = entries + } +} + +// Wraps another Callback and swaps CVV tokens in "records" (if present) before forwarding the +// response onward. Inserted between the container's collect() and its existing LogCallback, so +// no changes are needed to the network/response-parsing classes or APIClient. +internal class CVVMaskingCallback: Callback { + private let cvvMap: CVVCaptureMap + private let wrapped: Callback + + internal init(cvvMap: CVVCaptureMap, wrapping callback: Callback) { + self.cvvMap = cvvMap + self.wrapped = callback + } + + internal func onSuccess(_ responseBody: Any) { + wrapped.onSuccess(mask(responseBody)) + } + + internal func onFailure(_ error: Any) { + wrapped.onFailure(mask(error)) + } + + // A partial-batch failure can still carry a "records" array alongside "errors", so both + // onSuccess and onFailure bodies need the same masking. + private func mask(_ body: Any) -> Any { + guard cvvMap.isEmpty == false, + var dict = body as? [String: Any], + let records = dict["records"] as? [[String: Any]] else { return body } + + dict["records"] = CVVTokenReplacer.replaceCVVTokens(in: records, cvvMap: cvvMap) + return dict + } +} diff --git a/Sources/Skyflow/collect/CollectContainer.swift b/Sources/Skyflow/collect/CollectContainer.swift index 16bb9035..a5f42d94 100644 --- a/Sources/Skyflow/collect/CollectContainer.swift +++ b/Sources/Skyflow/collect/CollectContainer.swift @@ -22,14 +22,14 @@ public extension Container { return skyflowElement } - func collect(callback: Callback, options: CollectOptions? = CollectOptions()) where T: CollectContainer { + func collect(callback: CollectCallback, options: CollectOptions? = CollectOptions()) where T: CollectContainer { var tempContextOptions = self.skyflow.contextOptions tempContextOptions.interface = .COLLECT_CONTAINER if self.skyflow.vaultID.isEmpty { let errorCode = ErrorCodes.EMPTY_VAULT_ID() return callback.onFailure(errorCode.getErrorObject(contextOptions: tempContextOptions)) } - if self.skyflow.vaultURL == "/v1/vaults/" { + if self.skyflow.vaultURL == "/v2/vaults/" { let errorCode = ErrorCodes.EMPTY_VAULT_URL() return callback.onFailure(errorCode.getErrorObject(contextOptions: tempContextOptions)) } @@ -59,33 +59,23 @@ public extension Container { } } if errors != "" { - callback.onFailure(NSError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: errors])) + callback.onFailure(SkyflowError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: errors])) return } - if options?.additionalFields != nil { - if options?.additionalFields!["records"] == nil { - errorCode = .MISSING_RECORDS_IN_ADDITIONAL_FIELDS() + if let additionalFields = options?.additionalFields { + if additionalFields.records.isEmpty { + errorCode = .EMPTY_RECORDS_OBJECT() return callback.onFailure(errorCode!.getErrorObject(contextOptions: tempContextOptions)) } - if let additionalFieldEntries = options?.additionalFields!["records"] as? [[String: Any]] { - if additionalFieldEntries.isEmpty { - errorCode = .EMPTY_RECORDS_OBJECT() + for (index, record) in additionalFields.records.enumerated() { + errorCode = checkRecord(record: record, index: index) + if errorCode != nil { return callback.onFailure(errorCode!.getErrorObject(contextOptions: tempContextOptions)) } - for (index, record) in additionalFieldEntries.enumerated() { - errorCode = checkRecord(record: record, index: index) - if errorCode != nil { - return callback.onFailure(errorCode!.getErrorObject(contextOptions: tempContextOptions)) - } - } - } else { - errorCode = .INVALID_RECORDS_TYPE() - callback.onFailure(errorCode!.getErrorObject(contextOptions: tempContextOptions)) - return } } - let records = CollectRequestBody.createRequestBody(elements: self.elements, additionalFields: options?.additionalFields, callback: callback, contextOptions: tempContextOptions) - let icOptions = ICOptions(tokens: options!.tokens, additionalFields: options?.additionalFields, upsert: options?.upsert, callback: callback, contextOptions: tempContextOptions) + let records = FlowVaultCollectRequestBody.createRequestBody(elements: self.elements, additionalFields: options?.additionalFields, callback: callback, contextOptions: tempContextOptions) + let icOptions = FlowVaultICOptions(additionalFields: options?.additionalFields, upsert: options?.upsert, callback: callback, contextOptions: tempContextOptions) if options?.upsert != nil { if icOptions.validateUpsert() { return; @@ -99,12 +89,14 @@ public extension Container { onFailureHandler: { } ) - self.skyflow.apiClient.postAndUpdate(records: records!, callback: logCallback, options: icOptions, contextOptions: tempContextOptions) + let cvvMap = CVVTokenReplacer.captureCVVMap(elements: self.elements) + let cvvMaskingCallback = CVVMaskingCallback(cvvMap: cvvMap, wrapping: logCallback) + self.skyflow.apiClient.postAndUpdate(records: records!, callback: cvvMaskingCallback, options: icOptions, contextOptions: tempContextOptions) } } private func checkElement(element: TextField) -> ErrorCodes? { - if element.collectInput.table.isEmpty { + if element.collectInput.tableName.isEmpty { return .EMPTY_TABLE_NAME_IN_COLLECT() } if element.collectInput.column.isEmpty { @@ -117,27 +109,13 @@ public extension Container { return nil } - private func checkRecord(record: [String: Any], index: Int) -> ErrorCodes? { - if record["table"] == nil { - return .TABLE_KEY_ERROR(value: "\(index)") - } - if !(record["table"] is String) { - return .INVALID_TABLE_NAME_TYPE(value: "\(index)") - } - if (record["table"] as? String == "") { + private func checkRecord(record: AdditionalFieldsRecord, index: Int) -> ErrorCodes? { + if record.tableName.isEmpty { return .EMPTY_TABLE_NAME() } - if record["fields"] == nil { - return .FIELDS_KEY_ERROR(value: "\(index)") - } - if !(record["fields"] is [String: Any]) { - return .INVALID_FIELDS_TYPE(value: "\(index)") - } - let fields = record["fields"] as! [String: Any] - if (fields.isEmpty){ + if record.data.isEmpty { return .EMPTY_FIELDS_KEY(value: "\(index)") } - return nil } } diff --git a/Sources/Skyflow/collect/CollectOptions.swift b/Sources/Skyflow/collect/CollectOptions.swift index 369961bb..922b1f44 100644 --- a/Sources/Skyflow/collect/CollectOptions.swift +++ b/Sources/Skyflow/collect/CollectOptions.swift @@ -7,11 +7,9 @@ import Foundation public struct CollectOptions { - var tokens: Bool - var additionalFields: [String: Any]? - var upsert: [[String: Any]]? - public init(tokens: Bool = true, additionalFields: [String: Any]? = nil, upsert: [[String: Any]]? = nil) { - self.tokens = tokens + var additionalFields: AdditionalFields? + var upsert: [UpsertOption]? + public init(additionalFields: AdditionalFields? = nil, upsert: [UpsertOption]? = nil) { self.additionalFields = additionalFields self.upsert = upsert } diff --git a/Sources/Skyflow/collect/CollectRequestBody.swift b/Sources/Skyflow/collect/CollectRequestBody.swift index b687f141..6f3d04a3 100644 --- a/Sources/Skyflow/collect/CollectRequestBody.swift +++ b/Sources/Skyflow/collect/CollectRequestBody.swift @@ -73,11 +73,11 @@ internal class CollectRequestBody { let entryDict = entry let tableName = entryDict["table"] as! String let fields = entryDict["fields"] as! [String: Any] - if entryDict["skyflowID"] != nil { + if entryDict["skyflowId"] != nil { // add to update payload - let skyflowID = entryDict["skyflowID"] as! String - if updatePayload[skyflowID] != nil { - let temp = updatePayload[skyflowID] as! [String: Any] + let skyflowId = entryDict["skyflowId"] as! String + if updatePayload[skyflowId] != nil { + let temp = updatePayload[skyflowId] as! [String: Any] // merge existing fields with new field var existingFields = temp["fields"] as! [String: Any] for (key, val) in fields { @@ -86,14 +86,14 @@ internal class CollectRequestBody { } var updatedTemp = temp updatedTemp["fields"] = existingFields - updatePayload[skyflowID] = updatedTemp + updatePayload[skyflowId] = updatedTemp } else { var temp: [String: Any] = [ "table": tableName, "fields": fields, - "skyflowID": skyflowID + "skyflowId": skyflowId ] - updatePayload[skyflowID] = temp + updatePayload[skyflowId] = temp } } else { if tableMap[tableName] != nil { @@ -126,11 +126,11 @@ internal class CollectRequestBody { let tableName = element.tableName! let columnName = element.columnName! let value = element.getValue() - let skyflowID = element.skyflowID // Assumes TextField has this property + let skyflowId = element.skyflowId // Assumes TextField has this property - if let skyflowID = skyflowID, !skyflowID.isEmpty { - if updatePayload[skyflowID] != nil { - var temp = updatePayload[skyflowID] as! [String: Any] + if let skyflowId = skyflowId, !skyflowId.isEmpty { + if updatePayload[skyflowId] != nil { + var temp = updatePayload[skyflowId] as! [String: Any] // merge existing fields with new field var existingFields = temp["fields"] as! [String: Any] if existingFields[columnName] != nil { @@ -151,14 +151,14 @@ internal class CollectRequestBody { existingFields[columnName] = value } temp["fields"] = existingFields - updatePayload[skyflowID] = temp + updatePayload[skyflowId] = temp } else { var temp: [String: Any] = [ "table": tableName, "fields": [columnName: value], - "skyflowID": skyflowID + "skyflowId": skyflowId ] - updatePayload[skyflowID] = temp + updatePayload[skyflowId] = temp } } else { // Only add to payload diff --git a/Sources/Skyflow/collect/CollectResponse.swift b/Sources/Skyflow/collect/CollectResponse.swift new file mode 100644 index 00000000..4aaea9f2 --- /dev/null +++ b/Sources/Skyflow/collect/CollectResponse.swift @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +import Foundation + +public struct CollectResponse { + public let records: [CollectRecord] + + public init?(_ responseBody: Any) { + guard let dict = responseBody as? [String: Any], + let recordDicts = dict["records"] as? [[String: Any]] else { return nil } + self.records = recordDicts.map { CollectRecord($0) } + } +} + +// Each entry in "records" is either a successfully inserted/updated record (error is nil) or a +// failed one (error is non-nil) - the vault returns both together in the same array, each tagged +// with its own httpCode. +public struct CollectRecord { + public let tableName: String? + public let skyflowId: String? + // Keyed by column name, e.g. "card_number": [{"token": "...", "tokenGroupName": "..."}]. + public let tokens: [String: Any]? + // Keyed by column name, e.g. "card_number": [{"data": "...", "hashName": "..."}]. + public let hashedData: [String: Any]? + public let httpCode: Int + public let error: String? + + init(_ dict: [String: Any]) { + self.tableName = dict["tableName"] as? String + // Wire key is intentionally left as "skyflowID" here - only the public Swift-facing + // property name changed, not the response dict this reads from. + self.skyflowId = dict["skyflowID"] as? String + // Dict key is intentionally left as "fields" here - only the public Swift-facing + // property name changed, not the response dict this reads from. + self.tokens = dict["fields"] as? [String: Any] + self.hashedData = dict["hashedData"] as? [String: Any] + self.httpCode = dict["httpCode"] as? Int ?? 0 + self.error = dict["error"] as? String + } +} + +// A ready-made Skyflow.Callback that unwraps the raw responseBody/error into CollectResponse +// for you. Use it directly with the original callback-based collect(callback:options:). +public class CollectCallback: Callback { + private let successHandler: (CollectResponse) -> Void + private let failureHandler: (SkyflowError) -> Void + + public init(onSuccess: @escaping (CollectResponse) -> Void, onFailure: @escaping (SkyflowError) -> Void) { + self.successHandler = onSuccess + self.failureHandler = onFailure + } + + public func onSuccess(_ responseBody: Any) { + guard let response = CollectResponse(responseBody) else { + failureHandler(SkyflowError.wrap(responseBody)) + return + } + successHandler(response) + } + + // Delivered when the entire request fails (e.g. vault not found, network error) rather + // than a per-record failure inside CollectResponse.records, and for client-side validation + // failures. Always normalized into a Skyflow.SkyflowError - see SkyflowError.wrap. + public func onFailure(_ error: Any) { + failureHandler(SkyflowError.wrap(error)) + } +} diff --git a/Sources/Skyflow/collect/FlowVaultCollectAPICallback.swift b/Sources/Skyflow/collect/FlowVaultCollectAPICallback.swift new file mode 100644 index 00000000..ebfdb0a9 --- /dev/null +++ b/Sources/Skyflow/collect/FlowVaultCollectAPICallback.swift @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Callback used while API callback for Collect the elements (FlowDB v2) + +import Foundation +import UIKit + +internal class FlowVaultCollectAPICallback: Callback { + var apiClient: APIClient + var records: [String: Any] + var callback: Callback + var options: FlowVaultICOptions + var contextOptions: ContextOptions + + internal init(callback: Callback, apiClient: APIClient, records: [String: Any], options: FlowVaultICOptions, contextOptions: ContextOptions) { + self.records = records + self.apiClient = apiClient + self.callback = callback + self.options = options + self.contextOptions = contextOptions + } + + internal func onSuccess(_ responseBody: Any) { + let insertRecords = records["records"] as? [[String: Any]] ?? [] + let updateRecords = self.flattenUpdates(records["update"] as? [String: Any] ?? [:]) + let hasInsert = !insertRecords.isEmpty + let hasUpdate = !updateRecords.isEmpty + + if !hasInsert && !hasUpdate { + self.callback.onSuccess(["records": []]) + return + } + + // No update records: preserve the original single-call insert behavior exactly + // (including passing a full-request-level failure straight through, unwrapped). + if !hasUpdate { + guard let url = URL(string: self.apiClient.vaultURL + "v2/records/insert") else { + self.callback.onFailure(ErrorCodes.INVALID_URL().getErrorObject(contextOptions: self.contextOptions)) + return + } + do { + let (request, session) = try self.getRequestSession(url: url) + let task = session.dataTask(with: request) { data, response, error in + do { + let response = try self.processResponse(data: data, response: response, error: error) + if response["error"] != nil { + self.callback.onFailure(response) + return + } + self.callback.onSuccess(["records": response["records"] as? [[String: Any]] ?? []]) + } catch { + self.callback.onFailure(error) + } + } + task.resume() + } catch let error { + self.callback.onFailure(error) + } + return + } + + guard URL(string: self.apiClient.vaultURL + "v2/records/insert") != nil else { + self.callback.onFailure(ErrorCodes.INVALID_URL().getErrorObject(contextOptions: self.contextOptions)) + return + } + + let group = DispatchGroup() + var mergedRecords: [[String: Any]] = [] + var mergedErrors: [[String: Any]] = [] + + if hasInsert { + group.enter() + let url = URL(string: self.apiClient.vaultURL + "v2/records/insert")! + do { + let (request, session) = try self.getRequestSession(url: url) + let task = session.dataTask(with: request) { data, response, error in + defer { group.leave() } + do { + let response = try self.processResponse(data: data, response: response, error: error) + if response["error"] != nil { + mergedErrors.append(response) + } else { + mergedRecords.append(contentsOf: response["records"] as? [[String: Any]] ?? []) + } + } catch { + mergedErrors.append(["error": error.localizedDescription]) + } + } + task.resume() + } catch let error { + mergedErrors.append(["error": error.localizedDescription]) + group.leave() + } + } + + if hasUpdate { + group.enter() + let url = URL(string: self.apiClient.vaultURL + "v2/records/update")! + do { + let (request, session) = try self.getUpdateRequestSession(url: url, records: updateRecords) + let task = session.dataTask(with: request) { data, response, error in + defer { group.leave() } + do { + let response = try self.processResponse(data: data, response: response, error: error) + if response["error"] != nil { + mergedErrors.append(response) + } else { + mergedRecords.append(contentsOf: response["records"] as? [[String: Any]] ?? []) + } + } catch { + mergedErrors.append(["error": error.localizedDescription]) + } + } + task.resume() + } catch let error { + mergedErrors.append(["error": error.localizedDescription]) + group.leave() + } + } + + group.notify(queue: .main) { + if mergedErrors.isEmpty { + self.callback.onSuccess(["records": mergedRecords]) + } else { + self.callback.onFailure(["records": mergedRecords, "errors": mergedErrors]) + } + } + } + + internal func onFailure(_ error: Any) { + self.callback.onFailure(error) + } + + internal func flattenUpdates(_ updateDict: [String: Any]) -> [[String: Any]] { + var updateRecords: [[String: Any]] = [] + for (skyflowID, value) in updateDict { + guard let entry = value as? [String: Any], + let tableName = entry["table"] as? String, + let fields = entry["fields"] as? [String: Any] else { continue } + updateRecords.append(["skyflowID": skyflowID, "tableName": tableName, "data": fields]) + } + return updateRecords + } + + internal func buildFieldsDict(dict: [String: Any]) -> [String: Any] { + var temp: [String: Any] = [:] + for (key, val) in dict { + if let v = val as? [String: Any] { + temp[key] = buildFieldsDict(dict: v) + } else { + temp[key] = val + } + } + return temp + } + internal func getRequestSession(url: URL) throws -> (URLRequest, URLSession) { + var jsonString = "" + + do { + let deviceDetails = FetchMetrices().getMetrices() + let jsonData = try JSONSerialization.data(withJSONObject: deviceDetails, options: []) + jsonString = String(data: jsonData, encoding: .utf8) ?? "" + } catch { + jsonString = "" + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + + do { + let data = try JSONSerialization.data(withJSONObject: FlowVaultInsertRequestBody.createRequestBody(vaultID: self.apiClient.vaultID, records: self.records, options: options)) + request.httpBody = data + } + + request.setValue(("Bearer " + self.apiClient.token), forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(jsonString, forHTTPHeaderField: "sky-metadata") + + return (request, URLSession(configuration: .default)) + + } + + internal func getUpdateRequestSession(url: URL, records: [[String: Any]]) throws -> (URLRequest, URLSession) { + var jsonString = "" + + do { + let deviceDetails = FetchMetrices().getMetrices() + let jsonData = try JSONSerialization.data(withJSONObject: deviceDetails, options: []) + jsonString = String(data: jsonData, encoding: .utf8) ?? "" + } catch { + jsonString = "" + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + + do { + let data = try JSONSerialization.data(withJSONObject: FlowVaultUpdateRequestBody.createRequestBody(vaultID: self.apiClient.vaultID, records: records)) + request.httpBody = data + } + + request.setValue(("Bearer " + self.apiClient.token), forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(jsonString, forHTTPHeaderField: "sky-metadata") + + return (request, URLSession(configuration: .default)) + } + + func processResponse(data: Data?, response: URLResponse?, error: Error?) throws -> [String: Any] { + if error != nil || response == nil { + return ["error": ["message": (error)?.localizedDescription ?? "Unknown error"]] + } + + if let httpResponse = response as? HTTPURLResponse { + let range = 400...599 + if range ~= httpResponse.statusCode { + // FlowDB returns a non-2xx status when every record in the batch fails, + // but the body still has the same {"records": [...]} shape as a success/partial + // response (each record carrying its own error/httpCode) - parse it as such. + if let safeData = data, + let jsonObject = try? JSONSerialization.jsonObject(with: safeData, options: .allowFragments) as? [String: Any], + jsonObject["records"] != nil { + return try getCollectResponseBody(data: safeData) + } + var description = "Insert call failed with the following status code " + String(httpResponse.statusCode) + if let safeData = data { + guard let errorResponse = try? JSONSerialization.jsonObject(with: safeData, options: .allowFragments) as? [String: Any] else { + return ["error": ["message": String(data: safeData, encoding: .utf8) ?? "Unknown error", "httpCode": httpResponse.statusCode]] + } + // Pass the vault's structured error object (grpcCode/httpStatus/details) through + // as-is, only patching in the request-id and defaulting httpCode when absent. + if var errorDict = errorResponse["error"] as? [String: Any] { + if let message = errorDict["message"] as? String { + description = message + if let requestId = httpResponse.allHeaderFields["x-request-id"] { + description += " - request-id: \(requestId)" + } + errorDict["message"] = description + } + if errorDict["httpCode"] == nil { errorDict["httpCode"] = httpResponse.statusCode } + return ["error": errorDict] + } + if let requestId = httpResponse.allHeaderFields["x-request-id"] { + description += " - request-id: \(requestId)" + } + } + return ["error": ["message": description, "httpCode": httpResponse.statusCode]] + } + } + + guard let safeData = data else { + return ["records": []] + } + + return try getCollectResponseBody(data: safeData) + + } + + func getCollectResponseBody(data: Data) throws -> [String: Any]{ + let jsonData = (try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any]) ?? [:] + var records: [[String: Any]] = [] + + let responseRecords = jsonData["records"] as? [[String: Any]] ?? [] + for entry in responseRecords { + if let error = entry["error"] as? String { + var errorEntry: [String: Any] = ["error": error] + if let skyflowID = entry["skyflowID"] { errorEntry["skyflowID"] = skyflowID } + if let tableName = entry["tableName"] { errorEntry["tableName"] = tableName } + if let httpCode = entry["httpCode"] { errorEntry["httpCode"] = httpCode } + records.append(errorEntry) + } else { + var successEntry: [String: Any] = [:] + if let skyflowID = entry["skyflowID"] { successEntry["skyflowID"] = skyflowID } + if let tableName = entry["tableName"] { successEntry["tableName"] = tableName } + var fields: [String: Any] = [:] + if let tokens = entry["tokens"] as? [String: Any] { + for (column, tokenValue) in self.buildFieldsDict(dict: tokens) { + fields[column] = tokenValue + } + } + successEntry["fields"] = fields + if let hashedData = entry["hashedData"] as? [String: Any] { successEntry["hashedData"] = self.buildFieldsDict(dict: hashedData) } + if let httpCode = entry["httpCode"] { successEntry["httpCode"] = httpCode } + records.append(successEntry) + } + } + + return ["records": records] + } +} diff --git a/Sources/Skyflow/collect/FlowVaultCollectRequestBody.swift b/Sources/Skyflow/collect/FlowVaultCollectRequestBody.swift new file mode 100644 index 00000000..306b3cda --- /dev/null +++ b/Sources/Skyflow/collect/FlowVaultCollectRequestBody.swift @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Class for formatting the request body for collecting the data (FlowDB v2) + +import Foundation + +internal class FlowVaultCollectRequestBody { + static var tableSet: Set = Set() + static var callback: Callback? + static var breakFlag = false + static var mergedDict: [String: Any] = [:] + + internal static func addFieldsToTableSet(tableName: String, prefix: String, fields: [String: Any], contextOptions: ContextOptions) { + if !self.breakFlag { + for (key, val) in fields { + if val is [String: Any] { + addFieldsToTableSet(tableName: tableName, prefix: prefix == "" ? key : prefix + "." + key, fields: val as! [String: Any], contextOptions: contextOptions) + } else { + let tableSetEntry = tableName + "-" + (prefix == "" ? key : prefix + "." + key) + if tableSet.contains(tableSetEntry) { + if !self.breakFlag { + self.callback?.onFailure(ErrorCodes.DUPLICATE_ADDITIONAL_FIELD_FOUND(value: key).getErrorObject(contextOptions: contextOptions)) + self.breakFlag = true + return + } + } else { + self.tableSet.insert(tableSetEntry) + } + } + } + } + } + + internal static func mergeFields(tableName: String, prefix: String, dict: [String: Any], contextOptions: ContextOptions) { + for(key, val) in dict { + let keypath = prefix == "" ? key : prefix + "." + key + if val is [String: Any] { + mergeFields(tableName: tableName, prefix: keypath, dict: val as! [String: Any], contextOptions: contextOptions) + } else { + if mergedDict[keyPath: keypath] == nil { + mergedDict[keyPath: keypath] = val + } else { + if !self.breakFlag { + self.callback?.onFailure(ErrorCodes.DUPLICATE_ADDITIONAL_FIELD_FOUND(value: key).getErrorObject(contextOptions: contextOptions)) + self.breakFlag = true + return + } + } + } + } + } + + internal static func createRequestBody( + elements: [TextField], + additionalFields: AdditionalFields? = nil, + callback: Callback, + contextOptions: ContextOptions + ) -> [String: Any]? { + var tableMap: [String: Int] = [:] + var payload: [[String: Any]] = [] + var updatePayload: [String: Any] = [:] + self.callback = callback + self.breakFlag = false + self.tableSet = Set() + var index: Int = 0 + + if let additionalFields = additionalFields { + for (recordIndex, entry) in additionalFields.records.enumerated() { + let tableName = entry.tableName + let fields = entry.data + // An explicit empty-string skyflowId is a caller bug — reject it rather than + // silently inserting a new row where an update was intended. + if let skyflowId = entry.skyflowId, skyflowId.isEmpty { + self.callback?.onFailure(ErrorCodes.EMPTY_SKYFLOW_ID(value: "additional fields record at index \(recordIndex)").getErrorObject(contextOptions: contextOptions)) + return nil + } + if let skyflowId = entry.skyflowId, !skyflowId.isEmpty { + if updatePayload[skyflowId] != nil { + let temp = updatePayload[skyflowId] as! [String: Any] + var existingFields = temp["fields"] as! [String: Any] + for (key, val) in fields { + existingFields[key] = val + } + var updatedTemp = temp + updatedTemp["fields"] = existingFields + updatePayload[skyflowId] = updatedTemp + } else { + let temp: [String: Any] = [ + "table": tableName, + "fields": fields, + "skyflowId": skyflowId + ] + updatePayload[skyflowId] = temp + } + continue + } + if tableMap[tableName] != nil { + let inputEntry = payload[tableMap[tableName]!] + mergedDict = inputEntry["fields"] as! [String: Any] + self.mergeFields(tableName: tableName, prefix: "", dict: fields, contextOptions: contextOptions) + if self.breakFlag { + return nil + } + payload[tableMap[tableName]!]["fields"] = mergedDict + mergedDict = [:] + } else { + tableMap[tableName] = index + let temp: [String: Any] = [ + "table": tableName, + "fields": fields + ] + self.addFieldsToTableSet(tableName: tableName, prefix: "", fields: fields, contextOptions: contextOptions) + if self.breakFlag { + return nil + } + payload.append(temp) + index += 1 + } + } + } + + for element in elements { + let tableName = element.tableName! + let columnName = element.columnName! + let value = element.getValue() + let skyflowId = element.skyflowId // Assumes TextField has this property + + // Same guard as additionalFields above: CollectElementInput now defaults + // skyflowId to nil, so an empty string can only be an explicit caller mistake. + if let skyflowId = skyflowId, skyflowId.isEmpty { + self.callback?.onFailure(ErrorCodes.EMPTY_SKYFLOW_ID(value: "element with column '\(columnName)'").getErrorObject(contextOptions: contextOptions)) + return nil + } + if let skyflowId = skyflowId, !skyflowId.isEmpty { + if updatePayload[skyflowId] != nil { + var temp = updatePayload[skyflowId] as! [String: Any] + var existingFields = temp["fields"] as! [String: Any] + if existingFields[columnName] != nil { + var hasElementValueMatchRule: Bool = false + for validation in element.userValidationRules.rules { + if validation is ElementValueMatchRule { + hasElementValueMatchRule = true + break; + } + } + if(!hasElementValueMatchRule) + { + self.callback?.onFailure(ErrorCodes.DUPLICATE_ELEMENT_FOUND(values: [ element.columnName, element.tableName!]).getErrorObject(contextOptions: contextOptions)) + return nil + } + continue; + } else { + existingFields[columnName] = value + } + temp["fields"] = existingFields + updatePayload[skyflowId] = temp + } else { + let temp: [String: Any] = [ + "table": tableName, + "fields": [columnName: value], + "skyflowId": skyflowId + ] + updatePayload[skyflowId] = temp + } + } else { + // Only add to payload + if tableMap[(element.tableName)!] != nil { + var temp = payload[tableMap[(element.tableName)!]!] + temp[keyPath: "fields." + (element.columnName)!] = element.getValue() + let tableSetEntry = element.tableName! + "-" + element.columnName + if tableSet.contains(tableSetEntry) { + var hasElementValueMatchRule: Bool = false + for validation in element.userValidationRules.rules { + if validation is ElementValueMatchRule { + hasElementValueMatchRule = true + break; + } + } + if(!hasElementValueMatchRule) + { + self.callback?.onFailure(ErrorCodes.DUPLICATE_ELEMENT_FOUND(values: [ element.columnName, element.tableName!]).getErrorObject(contextOptions: contextOptions)) + return nil + } + continue; + } + self.tableSet.insert(tableSetEntry) + payload[tableMap[(element.tableName)!]!] = temp + } else { + tableMap[(element.tableName)!] = index + index += 1 + var temp: [String: Any] = [ + "table": element.tableName!, + "fields": [:] + ] + temp[keyPath: "fields." + element.columnName!] = element.getValue() + self.tableSet.insert(element.tableName! + "-" + element.columnName) + payload.append(temp) + } + } + } + return ["records": payload, "update": updatePayload] + } +} diff --git a/Sources/Skyflow/collect/FlowVaultICOptions.swift b/Sources/Skyflow/collect/FlowVaultICOptions.swift new file mode 100644 index 00000000..f72f47d9 --- /dev/null +++ b/Sources/Skyflow/collect/FlowVaultICOptions.swift @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Options passed internally for FlowDB v2 collect/insert calls + +import Foundation + +internal struct FlowVaultICOptions { + var additionalFields: AdditionalFields? + var upsert: [UpsertOption]? + var callback: Callback? + var contextOptions: ContextOptions? + + init(additionalFields: AdditionalFields? = nil, upsert: [UpsertOption]? = nil, callback: Callback? = nil, contextOptions: ContextOptions? = nil) { + self.additionalFields = additionalFields + self.upsert = upsert + self.callback = callback + self.contextOptions = contextOptions + } + + public func validateUpsert() -> Bool{ + if self.upsert != nil { + if self.upsert!.count == 0 { + let errorCode = ErrorCodes.UPSERT_OPTION_CANNOT_BE_EMPTY() + self.callback!.onFailure(errorCode.getErrorObject(contextOptions: self.contextOptions!)) + return true + } + + for (index, currUpsertOption) in self.upsert!.enumerated() { + if currUpsertOption.tableName == "" { + let errorCode = ErrorCodes.TABLE_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(value: "\(index)") + self.callback!.onFailure(errorCode.getErrorObject(contextOptions: self.contextOptions!)) + return true + } + if currUpsertOption.uniqueColumns.isEmpty { + let errorCode = ErrorCodes.UNIQUE_COLUMNS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(value: "\(index)") + self.callback!.onFailure(errorCode.getErrorObject(contextOptions: self.contextOptions!)) + return true + } + } + + } + return false; + } +} diff --git a/Sources/Skyflow/collect/FlowVaultInsertAPICallback.swift b/Sources/Skyflow/collect/FlowVaultInsertAPICallback.swift new file mode 100644 index 00000000..22a99ac2 --- /dev/null +++ b/Sources/Skyflow/collect/FlowVaultInsertAPICallback.swift @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Callback used while API callback for Collect the elements (FlowDB v2) + +import Foundation +import UIKit + +internal class FlowVaultInsertAPICallback: Callback { + // Overridable only for tests (e.g. injecting a URLProtocol mock via protocolClasses) - global + // URLProtocol.registerClass(_:) isn't reliably consulted for manually-created URLSession + // instances in every environment, so tests need a real seam here instead. + internal static var urlSessionConfiguration: URLSessionConfiguration = .default + var apiClient: APIClient + var records: [String: Any] + var callback: Callback + var options: FlowVaultICOptions + var contextOptions: ContextOptions + + internal init(callback: Callback, apiClient: APIClient, records: [String: Any], options: FlowVaultICOptions, contextOptions: ContextOptions) { + self.records = records + self.apiClient = apiClient + self.callback = callback + self.options = options + self.contextOptions = contextOptions + } + internal func onSuccess(_ responseBody: Any) { + let insertRecords = records["records"] as? [[String: Any]] ?? [] + let updateRecords = self.flattenUpdates(records["update"] as? [String: Any] ?? [:]) + let hasInsert = !insertRecords.isEmpty + let hasUpdate = !updateRecords.isEmpty + + if !hasInsert && !hasUpdate { + self.callback.onSuccess(["records": []]) + return + } + + // No update records: preserve the original single-call insert behavior exactly + // (including throwing a full-request-level failure straight to onFailure). + if !hasUpdate { + guard let url = URL(string: self.apiClient.vaultURL + "v2/records/insert") else { + self.callback.onFailure(ErrorCodes.INVALID_URL().getErrorObject(contextOptions: self.contextOptions)) + return + } + do { + let (request, session) = try self.getRequestSession(url: url) + let task = session.dataTask(with: request) { data, response, error in + do { + let response = try self.processResponse(data: data, response: response, error: error) + if response["error"] != nil { + self.callback.onFailure(response) + return + } + self.callback.onSuccess(["records": response["records"] as? [[String: Any]] ?? []]) + } catch { + self.callback.onFailure(error) + } + } + task.resume() + } catch let error { + self.callback.onFailure(error) + } + return + } + + guard URL(string: self.apiClient.vaultURL + "v2/records/insert") != nil else { + self.callback.onFailure(ErrorCodes.INVALID_URL().getErrorObject(contextOptions: self.contextOptions)) + return + } + + let group = DispatchGroup() + var mergedRecords: [[String: Any]] = [] + var mergedErrors: [[String: Any]] = [] + + if hasInsert { + group.enter() + let url = URL(string: self.apiClient.vaultURL + "v2/records/insert")! + do { + let (request, session) = try self.getRequestSession(url: url) + let task = session.dataTask(with: request) { data, response, error in + defer { group.leave() } + do { + let response = try self.processResponse(data: data, response: response, error: error) + if response["error"] != nil { + mergedErrors.append(response) + } else { + mergedRecords.append(contentsOf: response["records"] as? [[String: Any]] ?? []) + } + } catch { + mergedErrors.append(["error": error.localizedDescription]) + } + } + task.resume() + } catch let error { + mergedErrors.append(["error": error.localizedDescription]) + group.leave() + } + } + + if hasUpdate { + group.enter() + let url = URL(string: self.apiClient.vaultURL + "v2/records/update")! + do { + let (request, session) = try self.getUpdateRequestSession(url: url, records: updateRecords) + let task = session.dataTask(with: request) { data, response, error in + defer { group.leave() } + do { + let response = try self.processResponse(data: data, response: response, error: error) + if response["error"] != nil { + mergedErrors.append(response) + } else { + mergedRecords.append(contentsOf: response["records"] as? [[String: Any]] ?? []) + } + } catch { + mergedErrors.append(["error": error.localizedDescription]) + } + } + task.resume() + } catch let error { + mergedErrors.append(["error": error.localizedDescription]) + group.leave() + } + } + + group.notify(queue: .main) { + if mergedErrors.isEmpty { + self.callback.onSuccess(["records": mergedRecords]) + } else { + self.callback.onFailure(["records": mergedRecords, "errors": mergedErrors]) + } + } + } + + internal func onFailure(_ error: Any) { + self.callback.onFailure(error) + } + + internal func flattenUpdates(_ updateDict: [String: Any]) -> [[String: Any]] { + var updateRecords: [[String: Any]] = [] + for (skyflowID, value) in updateDict { + guard let entry = value as? [String: Any], + let tableName = entry["table"] as? String, + let fields = entry["fields"] as? [String: Any] else { continue } + updateRecords.append(["skyflowID": skyflowID, "tableName": tableName, "data": fields]) + } + return updateRecords + } + + internal func buildFieldsDict(dict: [String: Any]) -> [String: Any] { + var temp: [String: Any] = [:] + for (key, val) in dict { + if let v = val as? [String: Any] { + temp[key] = buildFieldsDict(dict: v) + } else { + temp[key] = val + } + } + return temp + } + internal func getRequestSession(url: URL) throws -> (URLRequest, URLSession) { + var jsonString = "" + + do { + let deviceDetails = FetchMetrices().getMetrices() + let jsonData = try JSONSerialization.data(withJSONObject: deviceDetails, options: []) + jsonString = String(data: jsonData, encoding: .utf8) ?? "" + } catch { + jsonString = "" + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + + do { + let data = try JSONSerialization.data(withJSONObject: FlowVaultInsertRequestBody.createRequestBody(vaultID: self.apiClient.vaultID, records: self.records, options: options)) + request.httpBody = data + } + + request.setValue(("Bearer " + self.apiClient.token), forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(jsonString, forHTTPHeaderField: "sky-metadata") + + return (request, URLSession(configuration: Self.urlSessionConfiguration)) + + } + + internal func getUpdateRequestSession(url: URL, records: [[String: Any]]) throws -> (URLRequest, URLSession) { + var jsonString = "" + + do { + let deviceDetails = FetchMetrices().getMetrices() + let jsonData = try JSONSerialization.data(withJSONObject: deviceDetails, options: []) + jsonString = String(data: jsonData, encoding: .utf8) ?? "" + } catch { + jsonString = "" + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + + do { + let data = try JSONSerialization.data(withJSONObject: FlowVaultUpdateRequestBody.createRequestBody(vaultID: self.apiClient.vaultID, records: records)) + request.httpBody = data + } + + request.setValue(("Bearer " + self.apiClient.token), forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(jsonString, forHTTPHeaderField: "sky-metadata") + + return (request, URLSession(configuration: Self.urlSessionConfiguration)) + } + + func processResponse(data: Data?, response: URLResponse?, error: Error?) throws -> [String: Any] { + if error != nil || response == nil { + return ["error": ["message": (error)?.localizedDescription ?? "Unknown error"]] + } + + if let httpResponse = response as? HTTPURLResponse { + let range = 400...599 + if range ~= httpResponse.statusCode { + // FlowDB returns a non-2xx status when every record in the batch fails, + // but the body still has the same {"records": [...]} shape as a success/partial + // response (each record carrying its own error/httpCode) - parse it as such. + if let safeData = data, + let jsonObject = try? JSONSerialization.jsonObject(with: safeData, options: .allowFragments) as? [String: Any], + jsonObject["records"] != nil { + return try getCollectResponseBody(data: safeData) + } + var description = "Insert call failed with the following status code " + String(httpResponse.statusCode) + if let safeData = data { + guard let errorResponse = try? JSONSerialization.jsonObject(with: safeData, options: .allowFragments) as? [String: Any] else { + return ["error": ["message": String(data: safeData, encoding: .utf8) ?? "Unknown error", "httpCode": httpResponse.statusCode]] + } + // Pass the vault's structured error object (grpcCode/httpStatus/details) through + // as-is, only patching in the request-id and defaulting httpCode when absent. + if var errorDict = errorResponse["error"] as? [String: Any] { + if let message = errorDict["message"] as? String { + description = message + if let requestId = httpResponse.allHeaderFields["x-request-id"] { + description += " - request-id: \(requestId)" + } + errorDict["message"] = description + } + if errorDict["httpCode"] == nil { errorDict["httpCode"] = httpResponse.statusCode } + return ["error": errorDict] + } + if let requestId = httpResponse.allHeaderFields["x-request-id"] { + description += " - request-id: \(requestId)" + } + } + return ["error": ["message": description, "httpCode": httpResponse.statusCode]] + } + } + + guard let safeData = data else { + return ["records": []] + } + + return try getCollectResponseBody(data: safeData) + + } + + func getCollectResponseBody(data: Data) throws -> [String: Any]{ + let jsonData = (try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any]) ?? [:] + var records: [[String: Any]] = [] + + let responseRecords = jsonData["records"] as? [[String: Any]] ?? [] + for entry in responseRecords { + if let error = entry["error"] as? String { + var errorEntry: [String: Any] = ["error": error] + if let skyflowID = entry["skyflowID"] { errorEntry["skyflowID"] = skyflowID } + if let tableName = entry["tableName"] { errorEntry["tableName"] = tableName } + if let httpCode = entry["httpCode"] { errorEntry["httpCode"] = httpCode } + records.append(errorEntry) + } else { + var successEntry: [String: Any] = [:] + if let skyflowID = entry["skyflowID"] { successEntry["skyflowID"] = skyflowID } + if let tableName = entry["tableName"] { successEntry["tableName"] = tableName } + var fields: [String: Any] = [:] + if let tokens = entry["tokens"] as? [String: Any] { + for (column, tokenValue) in self.buildFieldsDict(dict: tokens) { + fields[column] = tokenValue + } + } + successEntry["fields"] = fields + if let hashedData = entry["hashedData"] as? [String: Any] { successEntry["hashedData"] = self.buildFieldsDict(dict: hashedData) } + if let httpCode = entry["httpCode"] { successEntry["httpCode"] = httpCode } + records.append(successEntry) + } + } + + return ["records": records] + } +} diff --git a/Sources/Skyflow/collect/FlowVaultInsertRequestBody.swift b/Sources/Skyflow/collect/FlowVaultInsertRequestBody.swift new file mode 100644 index 00000000..0c35872e --- /dev/null +++ b/Sources/Skyflow/collect/FlowVaultInsertRequestBody.swift @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Used for generating request body for FlowDB v2 insert api call + +import Foundation + +internal class FlowVaultInsertRequestBody { + internal static func createRequestBody(vaultID: String, records: [String: Any], options: FlowVaultICOptions) -> [String: Any] { + var recordsPayload: [[String: Any]] = [] + for record in (records["records"] as! [[String: Any]]) { + var temp: [String: Any] = [:] + temp["data"] = record["fields"] + if let tableName = record["table"] as? String { + temp["tableName"] = tableName + if let upsertOptions = options.upsert, let match = upsertOptions.first(where: { $0.tableName == tableName }) { + var upsertPayload: [String: Any] = ["uniqueColumns": match.uniqueColumns] + if let updateType = match.updateType { + upsertPayload["updateType"] = updateType.rawValue + } + temp["upsert"] = upsertPayload + } + } + recordsPayload.append(temp) + } + return ["vaultID": vaultID, "records": recordsPayload] + } +} diff --git a/Sources/Skyflow/collect/FlowVaultUpdateRequestBody.swift b/Sources/Skyflow/collect/FlowVaultUpdateRequestBody.swift new file mode 100644 index 00000000..54187810 --- /dev/null +++ b/Sources/Skyflow/collect/FlowVaultUpdateRequestBody.swift @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Used for generating request body for FlowDB v2 update-by-skyflowID api call + +import Foundation + +internal class FlowVaultUpdateRequestBody { + internal static func createRequestBody(vaultID: String, records: [[String: Any]]) -> [String: Any] { + return ["vaultID": vaultID, "records": records] + } +} diff --git a/Sources/Skyflow/collect/InsertOptions.swift b/Sources/Skyflow/collect/InsertOptions.swift index e57737df..9f4f4301 100644 --- a/Sources/Skyflow/collect/InsertOptions.swift +++ b/Sources/Skyflow/collect/InsertOptions.swift @@ -7,10 +7,8 @@ import Foundation public struct InsertOptions { - var tokens: Bool - var upsert: [[String: Any]]? - public init(tokens: Bool = true, upsert: [[String: Any]]? = nil) { - self.tokens = tokens + var upsert: [UpsertOption]? + public init(upsert: [UpsertOption]? = nil) { self.upsert = upsert } } diff --git a/Sources/Skyflow/collect/UpsertOption.swift b/Sources/Skyflow/collect/UpsertOption.swift new file mode 100644 index 00000000..63d2d4db --- /dev/null +++ b/Sources/Skyflow/collect/UpsertOption.swift @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Object that describes an upsert configuration for insert/collect calls + +import Foundation + +public enum UpdateType: String { + case UPDATE + case REPLACE +} + +public struct UpsertOption { + public let tableName: String + public let uniqueColumns: [String] + public let updateType: UpdateType? + + public init(tableName: String, uniqueColumns: [String], updateType: UpdateType? = nil) { + self.tableName = tableName + self.uniqueColumns = uniqueColumns + self.updateType = updateType + } +} diff --git a/Sources/Skyflow/composable/ComposableContainer.swift b/Sources/Skyflow/composable/ComposableContainer.swift index 5da7784c..53b6ca1c 100644 --- a/Sources/Skyflow/composable/ComposableContainer.swift +++ b/Sources/Skyflow/composable/ComposableContainer.swift @@ -211,7 +211,7 @@ public extension Container { let errorCode = ErrorCodes.EMPTY_VAULT_ID() return callback.onFailure(errorCode.getErrorObject(contextOptions: tempContextOptions)) } - if self.skyflow.vaultURL == "/v1/vaults/" { + if self.skyflow.vaultURL == "/v2/vaults/" { let errorCode = ErrorCodes.EMPTY_VAULT_URL() return callback.onFailure(errorCode.getErrorObject(contextOptions: tempContextOptions)) } @@ -246,30 +246,20 @@ public extension Container { return } - if options?.additionalFields != nil { - if options?.additionalFields!["records"] == nil { - errorCode = .MISSING_RECORDS_IN_ADDITIONAL_FIELDS() + if let additionalFields = options?.additionalFields { + if additionalFields.records.isEmpty { + errorCode = .EMPTY_RECORDS_OBJECT() return callback.onFailure(errorCode!.getErrorObject(contextOptions: tempContextOptions)) } - if let additionalFieldEntries = options?.additionalFields!["records"] as? [[String: Any]] { - if additionalFieldEntries.isEmpty { - errorCode = .EMPTY_RECORDS_OBJECT() + for (index, record) in additionalFields.records.enumerated() { + errorCode = checkRecord(record: record, index: index) + if errorCode != nil { return callback.onFailure(errorCode!.getErrorObject(contextOptions: tempContextOptions)) } - for (index, record) in additionalFieldEntries.enumerated() { - errorCode = checkRecord(record: record, index: index) - if errorCode != nil { - return callback.onFailure(errorCode!.getErrorObject(contextOptions: tempContextOptions)) - } - } - } else { - errorCode = .INVALID_RECORDS_TYPE() - callback.onFailure(errorCode!.getErrorObject(contextOptions: tempContextOptions)) - return } } - let records = CollectRequestBody.createRequestBody(elements: self.elements, additionalFields: options?.additionalFields, callback: callback, contextOptions: tempContextOptions) - let icOptions = ICOptions(tokens: options!.tokens, additionalFields: options?.additionalFields, upsert: options?.upsert, callback: callback, contextOptions: tempContextOptions) + let records = FlowVaultCollectRequestBody.createRequestBody(elements: self.elements, additionalFields: options?.additionalFields, callback: callback, contextOptions: tempContextOptions) + let icOptions = FlowVaultICOptions(additionalFields: options?.additionalFields, upsert: options?.upsert, callback: callback, contextOptions: tempContextOptions) if options?.upsert != nil { if icOptions.validateUpsert() { return; @@ -283,12 +273,14 @@ public extension Container { onFailureHandler: { } ) - self.skyflow.apiClient.postAndUpdate(records: records!, callback: logCallback, options: icOptions, contextOptions: tempContextOptions) + let cvvMap = CVVTokenReplacer.captureCVVMap(elements: self.elements) + let cvvMaskingCallback = CVVMaskingCallback(cvvMap: cvvMap, wrapping: logCallback) + self.skyflow.apiClient.postAndUpdate(records: records!, callback: cvvMaskingCallback, options: icOptions, contextOptions: tempContextOptions) } } private func checkElement(element: TextField) -> ErrorCodes? { - if element.collectInput.table.isEmpty { + if element.collectInput.tableName.isEmpty { return .EMPTY_TABLE_NAME_IN_COLLECT() } if element.collectInput.column.isEmpty { @@ -301,29 +293,14 @@ public extension Container { return nil } - private func checkRecord(record: [String: Any], index: Int) -> ErrorCodes? { - if record["table"] == nil { - return .TABLE_KEY_ERROR(value: "\(index)") - } - if !(record["table"] is String) { - return .INVALID_TABLE_NAME_TYPE(value: "\(index)") - } - if (record["table"] as? String == "") { - return .EMPTY_TABLE_NAME() - } - if record["fields"] == nil { - return .FIELDS_KEY_ERROR(value: "\(index)") - } - if !(record["fields"] is [String: Any]) { - return .INVALID_FIELDS_TYPE(value: "\(index)") - } - let fields = record["fields"] as! [String: Any] - if (fields.isEmpty){ - return .EMPTY_FIELDS_KEY(value: "\(index)") - } - - return nil + private func checkRecord(record: AdditionalFieldsRecord, index: Int) -> ErrorCodes? { + if record.tableName.isEmpty { + return .EMPTY_TABLE_NAME() } - + if record.data.isEmpty { + return .EMPTY_FIELDS_KEY(value: "\(index)") + } + return nil + } } diff --git a/Sources/Skyflow/core/APIClient.swift b/Sources/Skyflow/core/APIClient.swift index 3cfd6ef9..f5c19512 100644 --- a/Sources/Skyflow/core/APIClient.swift +++ b/Sources/Skyflow/core/APIClient.swift @@ -59,12 +59,12 @@ internal class APIClient { } } - internal func postAndUpdate(records: [String: Any], callback: Callback, options: ICOptions, contextOptions: ContextOptions) { - let collectApiCallback = CollectAPICallback(callback: callback, apiClient: self, records: records, options: options, contextOptions: contextOptions) + internal func postAndUpdate(records: [String: Any], callback: Callback, options: FlowVaultICOptions, contextOptions: ContextOptions) { + let collectApiCallback = FlowVaultCollectAPICallback(callback: callback, apiClient: self, records: records, options: options, contextOptions: contextOptions) self.getAccessToken(callback: collectApiCallback, contextOptions: contextOptions) } - internal func post(records: [String: Any], callback: Callback, options: ICOptions, contextOptions: ContextOptions) { - let insertApiCallback = InsertAPICallback(callback: callback, apiClient: self, records: records, options: options, contextOptions: contextOptions) + internal func post(records: [String: Any], callback: Callback, options: FlowVaultICOptions, contextOptions: ContextOptions) { + let insertApiCallback = FlowVaultInsertAPICallback(callback: callback, apiClient: self, records: records, options: options, contextOptions: contextOptions) self.getAccessToken(callback: insertApiCallback, contextOptions: contextOptions) } internal func constructUpdateRequestBody(records: [String: Any], options: ICOptions) -> [String: Any] { @@ -85,7 +85,7 @@ internal class APIClient { temp["tableName"] = record["table"] temp["method"] = "POST" temp["quorum"] = true - + if options.tokens { var temp2: [String: Any] = [:] temp2["method"] = "GET" @@ -114,18 +114,18 @@ internal class APIClient { } return uniqueColumn; } - - internal func get(records: [RevealRequestRecord], callback: Callback, contextOptions: ContextOptions) { - let revealApiCallback = RevealAPICallback(callback: callback, apiClient: self, connectionUrl: (vaultURL + vaultID), records: records, contextOptions: contextOptions) + + internal func get(records: [RevealRequestRecord], tokenGroupRedactions: [TokenGroupRedaction]? = nil, callback: Callback, contextOptions: ContextOptions) { + let revealApiCallback = FlowVaultRevealAPICallback(callback: callback, apiClient: self, connectionUrl: (vaultURL + "v2/tokens/detokenize"), records: records, tokenGroupRedactions: tokenGroupRedactions, contextOptions: contextOptions) self.getAccessToken(callback: revealApiCallback, contextOptions: contextOptions) } internal func getById(records: [GetByIdRecord], callback: Callback, contextOptions: ContextOptions) { - let revealByIdApiCallback = RevealByIDAPICallback(callback: callback, apiClient: self, connectionUrl: (vaultURL + vaultID), records: records, contextOptions: contextOptions) + let revealByIdApiCallback = RevealByIDAPICallback(callback: callback, apiClient: self, connectionUrl: (vaultURL + "v1/vaults/" + vaultID), records: records, contextOptions: contextOptions) self.getAccessToken(callback: revealByIdApiCallback, contextOptions: contextOptions) } - internal func getRecord(records: [GetRecord], callback: Callback,getOptions: GetOptions, contextOptions: ContextOptions) { - let getApiCallback = GetAPICallback(callback: callback, apiClient: self, connectionUrl: (vaultURL + vaultID), records: records, getOptions: getOptions,contextOptions: contextOptions) + internal func getRecord(records: [GetRecord], callback: Callback, getOptions: GetOptions, contextOptions: ContextOptions) { + let getApiCallback = GetAPICallback(callback: callback, apiClient: self, connectionUrl: (vaultURL + "v1/vaults/" + vaultID), records: records, getOptions: getOptions, contextOptions: contextOptions) self.getAccessToken(callback: getApiCallback, contextOptions: contextOptions) } } diff --git a/Sources/Skyflow/core/Client.swift b/Sources/Skyflow/core/Client.swift index c9e51761..d956f3cc 100644 --- a/Sources/Skyflow/core/Client.swift +++ b/Sources/Skyflow/core/Client.swift @@ -15,13 +15,14 @@ public class Client { public init(_ skyflowConfig: Configuration) { self.vaultID = skyflowConfig.vaultID - self.vaultURL = skyflowConfig.vaultURL.hasSuffix("/") ? skyflowConfig.vaultURL + "v1/vaults/" : skyflowConfig.vaultURL + "/v1/vaults/" - self.apiClient = APIClient(vaultID: skyflowConfig.vaultID, vaultURL: self.vaultURL, tokenProvider: skyflowConfig.tokenProvider) + let normalizedBaseURL = skyflowConfig.vaultURL.hasSuffix("/") ? skyflowConfig.vaultURL : skyflowConfig.vaultURL + "/" + self.vaultURL = normalizedBaseURL + "v2/vaults/" + self.apiClient = APIClient(vaultID: skyflowConfig.vaultID, vaultURL: normalizedBaseURL, tokenProvider: skyflowConfig.tokenProvider) self.contextOptions = ContextOptions(logLevel: skyflowConfig.options!.logLevel, env: skyflowConfig.options!.env, interface: .CLIENT) Log.info(message: .CLIENT_INITIALIZED, contextOptions: self.contextOptions) } - public func insert(records: [String: Any], options: InsertOptions = InsertOptions(), callback: Callback) { + internal func insert(records: [String: Any], options: InsertOptions = InsertOptions(), callback: Callback) { var tempContextOptions = self.contextOptions tempContextOptions.interface = .INSERT Log.info(message: .INSERT_TRIGGERED, contextOptions: tempContextOptions) @@ -29,11 +30,11 @@ public class Client { let errorCode = ErrorCodes.EMPTY_VAULT_ID() return callback.onFailure(errorCode.getErrorObject(contextOptions: tempContextOptions)) } - if self.vaultURL == "/v1/vaults/" { + if self.vaultURL == "/v2/vaults/" { let errorCode = ErrorCodes.EMPTY_VAULT_URL() return callback.onFailure(errorCode.getErrorObject(contextOptions: tempContextOptions)) } - let icOptions = ICOptions(tokens: options.tokens, upsert: options.upsert, callback: callback, contextOptions: tempContextOptions) + let icOptions = FlowVaultICOptions(upsert: options.upsert, callback: callback, contextOptions: tempContextOptions) var errorCode: ErrorCodes? if records["records"] == nil { @@ -114,15 +115,10 @@ public class Client { return nil } - public func detokenize(records: [String: Any], options: RevealOptions? = RevealOptions(), callback: Callback) { + internal func detokenize(records: [String: Any], options: RevealOptions? = RevealOptions(), callback: Callback) { var tempContextOptions = self.contextOptions tempContextOptions.interface = .DETOKENIZE func checkRecord(token: [String: Any], index: Int) -> ErrorCodes? { - if token["redaction"] != nil { - guard let _ = token["redaction"] as? RedactionType else { - return .INVALID_REDACTION_TYPE() - } - } if token["token"] == nil { return .ID_KEY_ERROR() } else { @@ -138,7 +134,7 @@ public class Client { let errorCode = ErrorCodes.EMPTY_VAULT_ID() return callRevealOnFailure(callback: callback, errorObject: errorCode.getErrorObject(contextOptions: tempContextOptions)) } - if self.vaultURL == "/v1/vaults/" { + if self.vaultURL == "/v2/vaults/" { let errorCode = ErrorCodes.EMPTY_VAULT_URL() return callRevealOnFailure(callback: callback, errorObject: errorCode.getErrorObject(contextOptions: tempContextOptions)) } @@ -156,11 +152,7 @@ public class Client { for (index,token) in tokens.enumerated() { let errorCode = checkRecord(token: token, index: index) if errorCode == nil, let id = token["token"] as? String { - if token["redaction"] == nil{ - list.append(RevealRequestRecord(token: id, redaction: RedactionType.PLAIN_TEXT.rawValue)) - } else if let redaction = token["redaction"] as? RedactionType{ - list.append(RevealRequestRecord(token: id, redaction: redaction.rawValue)) - } + list.append(RevealRequestRecord(token: id)) } else { return callRevealOnFailure(callback: callback, errorObject: errorCode!.getErrorObject(contextOptions: tempContextOptions)) } @@ -172,13 +164,13 @@ public class Client { onFailureHandler: { } ) - self.apiClient.get(records: list, callback: logCallback, contextOptions: tempContextOptions) + self.apiClient.get(records: list, tokenGroupRedactions: options?.tokenGroupRedactions, callback: logCallback, contextOptions: tempContextOptions) } else { callRevealOnFailure(callback: callback, errorObject: ErrorCodes.INVALID_RECORDS_TYPE().getErrorObject(contextOptions: tempContextOptions)) } } - public func getById(records: [String: Any], callback: Callback) { + internal func getById(records: [String: Any], callback: Callback) { var tempContextOptions = self.contextOptions tempContextOptions.interface = .GETBYID Log.info(message: .GET_BY_ID_TRIGGERED, contextOptions: tempContextOptions) @@ -186,7 +178,7 @@ public class Client { let errorCode = ErrorCodes.EMPTY_VAULT_ID() return callRevealOnFailure(callback: callback, errorObject: errorCode.getErrorObject(contextOptions: tempContextOptions)) } - if self.vaultURL == "/v1/vaults/" { + if self.vaultURL == "/v2/vaults/" { let errorCode = ErrorCodes.EMPTY_VAULT_URL() return callRevealOnFailure(callback: callback, errorObject: errorCode.getErrorObject(contextOptions: tempContextOptions)) } @@ -261,7 +253,7 @@ public class Client { callRevealOnFailure(callback: callback, errorObject: ErrorCodes.INVALID_RECORDS_TYPE().getErrorObject(contextOptions: tempContextOptions)) } } - public func get(records: [String: Any], options: GetOptions = GetOptions(), callback: Callback){ + internal func get(records: [String: Any], options: GetOptions = GetOptions(), callback: Callback){ var tempContextOptions = self.contextOptions tempContextOptions.interface = .GET Log.info(message: .GET_TRIGGERED, contextOptions: tempContextOptions) @@ -269,12 +261,12 @@ public class Client { let errorCode = ErrorCodes.EMPTY_VAULT_ID() return callRevealOnFailure(callback: callback, errorObject: errorCode.getErrorObject(contextOptions: tempContextOptions)) } - if self.vaultURL == "/v1/vaults/" { + if self.vaultURL == "/v2/vaults/" { let errorCode = ErrorCodes.EMPTY_VAULT_URL() return callRevealOnFailure(callback: callback, errorObject: errorCode.getErrorObject(contextOptions: tempContextOptions)) } Log.info(message: .VALIDATE_GET_INPUT, contextOptions: tempContextOptions) - + if records["records"] == nil { return callRevealOnFailure(callback: callback, errorObject: ErrorCodes.EMPTY_RECORDS_OBJECT().getErrorObject(contextOptions: tempContextOptions)) } @@ -305,7 +297,7 @@ public class Client { let logCallback = LogCallback(clientCallback: callback, contextOptions: tempContextOptions, onSuccessHandler: { Log.info(message: .GET_SUCCESS, contextOptions: tempContextOptions) }, onFailureHandler: { - + }) self.apiClient.getRecord(records: list, callback: logCallback, getOptions: options, contextOptions: tempContextOptions) } else { @@ -343,7 +335,7 @@ public class Client { if( getOptions.tokens == true ){ if (entry["columnName"] != nil || entry["columnValues"] != nil){ return .TOKENS_GET_COLUMN_NOT_SUPPPORTED() - + } if (entry["redaction"] as? RedactionType) != nil { return .REDACTION_WITH_TOKEN_NOT_SUPPORTED() @@ -355,7 +347,7 @@ public class Client { return .INVALID_REDACTION_TYPE() } } - + if(entry["columnName"] == nil){ if ((entry["ids"] == nil) && (entry["columnValues"] == nil)){ return .MISSING_IDS_OR_COLUMN_VALUES_IN_GET() @@ -389,7 +381,7 @@ public class Client { return .EMPTY_COLUMN_NAME() } } - + return nil } @@ -397,17 +389,6 @@ public class Client { let result = ["errors": [errorObject]] callback.onFailure(result) } - - internal func createDetokenizeRecords(_ IDsToTokens: [String: String]) -> [String: [[String: String]]]{ - var records = [] as [[String : String]] - var index = 0 - for (_, token) in IDsToTokens { - records.append(["token": token]) - index += 1 - } - - return ["records": records] - } } internal class LogCallback: Callback { diff --git a/Sources/Skyflow/core/InterfaceName.swift b/Sources/Skyflow/core/InterfaceName.swift index 54534af7..4afd2dee 100644 --- a/Sources/Skyflow/core/InterfaceName.swift +++ b/Sources/Skyflow/core/InterfaceName.swift @@ -4,7 +4,7 @@ // // File.swift -// +// // // Created by Akhil Anil Mangala on 29/11/21. // @@ -21,7 +21,7 @@ internal enum InterfaceName { case GETBYID case GET case EMPTY - + var description: String { switch self { case .COLLECT_CONTAINER: return "collect container" diff --git a/Sources/Skyflow/core/Message.swift b/Sources/Skyflow/core/Message.swift index e7c7b918..33796934 100644 --- a/Sources/Skyflow/core/Message.swift +++ b/Sources/Skyflow/core/Message.swift @@ -76,7 +76,7 @@ internal enum Message { case .INSERT_TRIGGERED: return "Insert method triggered." case .DETOKENIZE_TRIGGERED: return "Detokenize method triggered." case .GET_BY_ID_TRIGGERED: return "Get by ID triggered." - + //Used in tests case .CLIENT_CONNECTION: return "client connection not established" // A case .CANNOT_CHANGE_ELEMENT: return "Element can't be changed" // A diff --git a/Sources/Skyflow/elements/SkyflowElement.swift b/Sources/Skyflow/elements/SkyflowElement.swift index 0ac78fef..909996c3 100644 --- a/Sources/Skyflow/elements/SkyflowElement.swift +++ b/Sources/Skyflow/elements/SkyflowElement.swift @@ -16,7 +16,7 @@ public class SkyflowElement: UIView { internal var fieldType: ElementType! internal var columnName: String! internal var tableName: String? - internal var skyflowID: String? + internal var skyflowId: String? internal var horizontalConstraints = [NSLayoutConstraint]() internal var verticalConstraint = [NSLayoutConstraint]() internal var collectInput: CollectElementInput! @@ -60,11 +60,11 @@ public class SkyflowElement: UIView { /// Field Configuration internal func setupField() { - tableName = collectInput.table + tableName = collectInput.tableName columnName = collectInput.column fieldType = collectInput.type isRequired = options.required - skyflowID = collectInput.skyflowID + skyflowId = collectInput.skyflowId } internal func getOutput() -> String? { diff --git a/Sources/Skyflow/elements/TextField.swift b/Sources/Skyflow/elements/TextField.swift index 4401b4bf..b353eb69 100644 --- a/Sources/Skyflow/elements/TextField.swift +++ b/Sources/Skyflow/elements/TextField.swift @@ -208,8 +208,8 @@ public class TextField: SkyflowElement, Element, BaseElement { if update.column.isEmpty != true { collectInput.column = update.column } - if update.table.isEmpty != true { - collectInput.table = update.table + if update.tableName.isEmpty != true { + collectInput.tableName = update.tableName } if update.column.isEmpty != true { collectInput.column = update.column diff --git a/Sources/Skyflow/elements/core/CollectElementInput.swift b/Sources/Skyflow/elements/core/CollectElementInput.swift index 419d6b8d..1e201efa 100644 --- a/Sources/Skyflow/elements/core/CollectElementInput.swift +++ b/Sources/Skyflow/elements/core/CollectElementInput.swift @@ -7,7 +7,7 @@ import Foundation public struct CollectElementInput { - var table: String + var tableName: String var column: String var inputStyles: Styles var labelStyles: Styles @@ -17,12 +17,12 @@ public struct CollectElementInput { var placeholder: String var type: ElementType? var validations: ValidationSet - var skyflowID: String + var skyflowId: String? - public init(table: String = "", column: String = "", + public init(tableName: String = "", column: String = "", inputStyles: Styles? = Styles(), labelStyles: Styles? = Styles(), errorTextStyles: Styles? = Styles(), iconStyles: Styles? = Styles(), label: String? = "", - placeholder: String? = "", validations: ValidationSet=ValidationSet(), skyflowID: String? = "") { - self.table = table + placeholder: String? = "", validations: ValidationSet=ValidationSet(), skyflowId: String? = nil) { + self.tableName = tableName self.column = column self.inputStyles = inputStyles! self.labelStyles = labelStyles! @@ -31,13 +31,13 @@ public struct CollectElementInput { self.label = label! self.placeholder = placeholder! self.validations = validations - self.skyflowID = skyflowID! + self.skyflowId = skyflowId } - public init(table: String = "", column: String = "", + public init(tableName: String = "", column: String = "", inputStyles: Styles? = Styles(), labelStyles: Styles? = Styles(), errorTextStyles: Styles? = Styles(), iconStyles: Styles? = Styles(), label: String? = "", - placeholder: String? = "", type: ElementType?, validations: ValidationSet=ValidationSet(), skyflowID: String? = "") { - self.table = table + placeholder: String? = "", type: ElementType?, validations: ValidationSet=ValidationSet(), skyflowId: String? = nil) { + self.tableName = tableName self.column = column self.inputStyles = inputStyles! self.labelStyles = labelStyles! @@ -47,14 +47,14 @@ public struct CollectElementInput { self.placeholder = placeholder! self.type = type self.validations = validations - self.skyflowID = skyflowID! + self.skyflowId = skyflowId } @available(*, deprecated, message: "altText param is deprecated") - public init(table: String = "", column: String = "", + public init(tableName: String = "", column: String = "", inputStyles: Styles? = Styles(), labelStyles: Styles? = Styles(), errorTextStyles: Styles? = Styles(), iconStyles: Styles? = Styles(), label: String? = "", - placeholder: String? = "", altText: String? = "", type: ElementType?, validations: ValidationSet=ValidationSet(), skyflowID: String? = "") { - self.table = table + placeholder: String? = "", altText: String? = "", type: ElementType?, validations: ValidationSet=ValidationSet(), skyflowId: String? = nil) { + self.tableName = tableName self.column = column self.inputStyles = inputStyles! self.labelStyles = labelStyles! @@ -64,6 +64,6 @@ public struct CollectElementInput { self.placeholder = placeholder! self.type = type self.validations = validations - self.skyflowID = skyflowID! + self.skyflowId = skyflowId } } diff --git a/Sources/Skyflow/errors/ErrorCodes.swift b/Sources/Skyflow/errors/ErrorCodes.swift index d117f4bc..5b28c735 100644 --- a/Sources/Skyflow/errors/ErrorCodes.swift +++ b/Sources/Skyflow/errors/ErrorCodes.swift @@ -17,6 +17,8 @@ internal enum ErrorCodes: CustomStringConvertible { case EMPTY_VAULT_URL(code: Int = 400, message: String = "\(LangAndVersion) Initialization failed. Invalid credentials. Specify a valid 'vaultURL'.") case EMPTY_IDS(code: Int = 400, message: String = "\(LangAndVersion) Validation error. 'ids' key cannot be an array of empty strings in records at index . Specify non-empty values instead.", value: String) case EMPTY_ID_VALUE(code: Int = 400, message: String = "\(LangAndVersion) Validation error. 'id' cannot be empty in 'ids' array in 'records' at index . Specify non-empty values instead.", value: String) + case EMPTY_SKYFLOW_ID(code: Int = 400, message: String = "\(LangAndVersion) Validation error. 'skyflowId' cannot be an empty string for . Provide a valid 'skyflowId' or remove it.", value: String) + case INVALID_TOKEN_GROUP_REDACTION_ENTRY(code: Int = 400, message: String = "\(LangAndVersion) Validation error. Invalid 'tokenGroupRedactions' entry at index . 'tokenGroupName' and 'redaction' must be non-empty strings.", value: String) case RECORDS_KEY_ERROR(code: Int = 400, message: String = "\(LangAndVersion) Validation error. Missing 'records' key. Provide a valid 'records' key.") case TABLE_KEY_ERROR(code: Int = 400, message: String = "\(LangAndVersion) Validation error. Missing 'table' key in records at index . Provide a valid 'table' key.", value: String) case FIELDS_KEY_ERROR(code: Int = 400, message: String = "\(LangAndVersion) Validation error. Missing 'fields' key in records at index . Provide a valid 'fields' key.", value: String) @@ -30,7 +32,6 @@ internal enum ErrorCodes: CustomStringConvertible { case INVALID_FIELDS_TYPE(code: Int = 400, message: String = "\(LangAndVersion) Validation error.invaid 'fields' key value in record at index . Specify a value of type array for 'fields' key.", value: String) case INVALID_RECORDS_TYPE(code: Int = 400, message: String = "\(LangAndVersion) Validation error. Invalid 'records' key found. Specify a value of type array instead.") case INVALID_BEARER_TOKEN_FORMAT(code: Int = 400, message: String = "\(LangAndVersion) Token generated from 'getBearerToken' callback function is invalid. Make sure the implementation of 'getBearerToken' is correct.") - case MISSING_RECORDS_ARRAY(code: Int = 404, message: String = "\(LangAndVersion) Validation error.'records' key not found in additionalFields. Specify a 'records' key in addtionalFields.") case MISSING_RECORDS_IN_ADDITIONAL_FIELDS(code: Int = 404, message: String = "\(LangAndVersion) Validation error.'records' object cannot be empty within additionalFields. Specify a non-empty value instead.") case EMPTY_RECORDS_OBJECT(code: Int = 404, message: String = "\(LangAndVersion) Validation error. 'records' key cannot be empty. Provide a non-empty value instead.") case MISSING_RECORDS_IN_GETBYID(code: Int = 404, message: String = "\(LangAndVersion) Validation error. 'records' key cannot be empty. Provide a non-empty value instead.") @@ -69,18 +70,19 @@ internal enum ErrorCodes: CustomStringConvertible { case UPSERT_OPTION_CANNOT_BE_EMPTY(code: Int = 400, message: String = "\(LangAndVersion) Validation error. 'upsert' key cannot be an empty array in insert options. Make sure to add atleast one table column object in upsert array.") case MISSING_COLUMN_NAME_IN_USERT_OPTION(code: Int = 400, message: String = "\(LangAndVersion) Validation error. Missing 'column' key in upsert array at index . Provide a valid 'column' key.", value: String) case COLUMN_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(code: Int = 400, message: String = "\(LangAndVersion) Validation error. Invalid 'table' key in upsert array at index . Specify a value of type string instead.", value: String) + case UNIQUE_COLUMNS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(code: Int = 400, message: String = "\(LangAndVersion) Validation error. 'uniqueColumns' cannot be empty in upsert option at index . Specify at least one column.", value: String) case TABLE_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(code: Int = 400, message: String = "\(LangAndVersion) Validation error. Invalid 'table' key in upsert array at index . Specify a value of type string instead.", value: String) case MISMATCH_ELEMENT_COUNT_LAYOUT_SUM(code: Int = 400, message: String = "\(LangAndVersion) Mount failed. Invalid layout array values. Make sure all values in the layout array are positive numbers.") var code: Int { switch self { // No Formatting required // swiftlint:disable:next line_length - case .EMPTY_TABLE_NAME(let code, _), .EMPTY_VAULT_ID(let code, _), .EMPTY_VAULT_URL(let code, _),.RECORDS_KEY_ERROR( let code, _),.EMPTY_TOKEN_ID(let code, _), .ID_KEY_ERROR(let code, _), .INVALID_RECORDS_TYPE(let code, _), .EMPTY_COLUMN_NAME(let code, _), .INVALID_BEARER_TOKEN_FORMAT(let code, _), .MISSING_RECORDS_ARRAY(let code, _), .MISSING_RECORDS_IN_ADDITIONAL_FIELDS(let code, _), .EMPTY_RECORDS_OBJECT(let code, _), .MISSING_RECORDS_IN_GETBYID(let code, _),.INVALID_URL(let code, _), .INVALID_IDS_TYPE(let code, _),.REDACTION_WITH_TOKEN_NOT_SUPPORTED(let code, _), .TOKENS_GET_COLUMN_NOT_SUPPPORTED(let code, _), .MISSING_COLUMN_NAME(let code,_), .UPSERT_OPTION_CANNOT_BE_EMPTY(let code, _),.INVALID_REDACTION_TYPE(let code, _),.EMPTY_COLUMN_NAME_IN_COLLECT(let code, _), .EMPTY_TABLE_NAME_IN_COLLECT(let code, _), .REGEX_MATCH_FAILED(let code, _),.SKYFLOW_IDS_AND_COLUMN_NAME_BOTH_SPECIFIED(let code, _),.MISSING_IDS_OR_COLUMN_VALUES_IN_GET(let code, _), .MISSING_RECORD_COLUMN_VALUE(let code, _), .INVALID_COLUMN_VALUES_IN_GET(let code, _), .EMPTY_COMPOSABLE_LAYOUT_ARRAY(let code, _), .MISSING_COMPOSABLE_LAYOUT_KEY(let code, _), .MISMATCH_ELEMENT_COUNT_LAYOUT_SUM(let code, _),.MISSING_COMPOSABLE_CONTAINER_OPTIONS(code: let code, message: _): + case .EMPTY_TABLE_NAME(let code, _), .EMPTY_VAULT_ID(let code, _), .EMPTY_VAULT_URL(let code, _),.RECORDS_KEY_ERROR( let code, _),.EMPTY_TOKEN_ID(let code, _), .ID_KEY_ERROR(let code, _), .INVALID_RECORDS_TYPE(let code, _), .EMPTY_COLUMN_NAME(let code, _), .INVALID_BEARER_TOKEN_FORMAT(let code, _), .MISSING_RECORDS_IN_ADDITIONAL_FIELDS(let code, _), .EMPTY_RECORDS_OBJECT(let code, _), .MISSING_RECORDS_IN_GETBYID(let code, _),.INVALID_URL(let code, _), .INVALID_IDS_TYPE(let code, _),.REDACTION_WITH_TOKEN_NOT_SUPPORTED(let code, _), .TOKENS_GET_COLUMN_NOT_SUPPPORTED(let code, _), .MISSING_COLUMN_NAME(let code,_), .UPSERT_OPTION_CANNOT_BE_EMPTY(let code, _),.INVALID_REDACTION_TYPE(let code, _),.EMPTY_COLUMN_NAME_IN_COLLECT(let code, _), .EMPTY_TABLE_NAME_IN_COLLECT(let code, _), .REGEX_MATCH_FAILED(let code, _),.SKYFLOW_IDS_AND_COLUMN_NAME_BOTH_SPECIFIED(let code, _),.MISSING_IDS_OR_COLUMN_VALUES_IN_GET(let code, _), .MISSING_RECORD_COLUMN_VALUE(let code, _), .INVALID_COLUMN_VALUES_IN_GET(let code, _), .EMPTY_COMPOSABLE_LAYOUT_ARRAY(let code, _), .MISSING_COMPOSABLE_LAYOUT_KEY(let code, _), .MISMATCH_ELEMENT_COUNT_LAYOUT_SUM(let code, _),.MISSING_COMPOSABLE_CONTAINER_OPTIONS(code: let code, message: _): return code // Single value formatting // swiftlint:disable:next line_length // Multi value formatting - case .EMPTY_IDS(let code, _, _), .EMPTY_ID_VALUE(let code, _, _), .TABLE_KEY_ERROR(let code, _, _), .FIELDS_KEY_ERROR(let code, _, _), .EMPTY_FIELDS_KEY(let code, _, _), .REDACTION_KEY_ERROR(let code, _, _), .MISSING_KEY_IDS(let code, _, _), .INVALID_TABLE_NAME_TYPE(let code, _, _), .INVALID_FIELDS_TYPE(let code, _, _), .INVALID_TOKEN_TYPE(let code, _, _), .MISSING_TABLE_NAME_IN_USERT_OPTION(let code, _, _),.MISSING_COLUMN_NAME_IN_USERT_OPTION(let code, _, _), .COLUMN_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(let code, _, _), .TABLE_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(let code, _, _), .APIError(let code, _), .INVALID_COLUMN_NAME(let code, _, _), .UNMOUNTED_COLLECT_ELEMENT(let code, _, _), .UNMOUNTED_REVEAL_ELEMENT(let code, _, _), .ERROR_TRIGGERED(let code, _, _), .DUPLICATE_ADDITIONAL_FIELD_FOUND(let code, _, _), .EMPTY_RECORD_COLUMN_VALUES(code: let code, message: _, value: _), .EMPTY_COLUMN_VALUE(code: let code, message: _, value: _),.DUPLICATE_ELEMENT_FOUND(let code, _, _) : + case .EMPTY_IDS(let code, _, _), .EMPTY_ID_VALUE(let code, _, _), .EMPTY_SKYFLOW_ID(let code, _, _), .INVALID_TOKEN_GROUP_REDACTION_ENTRY(let code, _, _), .TABLE_KEY_ERROR(let code, _, _), .FIELDS_KEY_ERROR(let code, _, _), .EMPTY_FIELDS_KEY(let code, _, _), .REDACTION_KEY_ERROR(let code, _, _), .MISSING_KEY_IDS(let code, _, _), .INVALID_TABLE_NAME_TYPE(let code, _, _), .INVALID_FIELDS_TYPE(let code, _, _), .INVALID_TOKEN_TYPE(let code, _, _), .MISSING_TABLE_NAME_IN_USERT_OPTION(let code, _, _), .MISSING_COLUMN_NAME_IN_USERT_OPTION(let code, _, _), .COLUMN_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(let code, _, _), .UNIQUE_COLUMNS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(let code, _, _), .TABLE_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(let code, _, _), .APIError(let code, _), .INVALID_COLUMN_NAME(let code, _, _), .UNMOUNTED_COLLECT_ELEMENT(let code, _, _), .UNMOUNTED_REVEAL_ELEMENT(let code, _, _), .ERROR_TRIGGERED(let code, _, _), .DUPLICATE_ADDITIONAL_FIELD_FOUND(let code, _, _), .EMPTY_RECORD_COLUMN_VALUES(code: let code, message: _, value: _), .EMPTY_COLUMN_VALUE(code: let code, message: _, value: _),.DUPLICATE_ELEMENT_FOUND(let code, _, _) : return code } @@ -90,11 +92,11 @@ internal enum ErrorCodes: CustomStringConvertible { switch self { // No Formatting required // swiftlint:disable:next line_length - case .APIError(_, let message), .INVALID_REDACTION_TYPE( _, let message), .EMPTY_COLUMN_NAME_IN_COLLECT( _, let message), .EMPTY_TABLE_NAME_IN_COLLECT( _, let message), .REGEX_MATCH_FAILED( _, let message), .EMPTY_TABLE_NAME( _, let message), .EMPTY_VAULT_ID( _, let message), .EMPTY_VAULT_URL( _, let message), .RECORDS_KEY_ERROR( _, let message),.INVALID_RECORDS_TYPE( _, let message), .EMPTY_COLUMN_NAME( _, let message), .INVALID_BEARER_TOKEN_FORMAT( _, let message), .MISSING_RECORDS_ARRAY( _, let message), .MISSING_RECORDS_IN_ADDITIONAL_FIELDS( _, let message), .EMPTY_RECORDS_OBJECT( _, let message), .MISSING_RECORDS_IN_GETBYID( _, let message),.INVALID_URL( _, let message),.SKYFLOW_IDS_AND_COLUMN_NAME_BOTH_SPECIFIED( _, let message), .MISSING_IDS_OR_COLUMN_VALUES_IN_GET( _, let message), .MISSING_RECORD_COLUMN_VALUE( _, let message), .UPSERT_OPTION_CANNOT_BE_EMPTY( _, let message),.INVALID_COLUMN_VALUES_IN_GET( _, let message),.MISSING_COMPOSABLE_LAYOUT_KEY(_, let message), .MISMATCH_ELEMENT_COUNT_LAYOUT_SUM(_, let message),.EMPTY_TOKEN_ID( _, let message), .ID_KEY_ERROR( _, let message),.REDACTION_WITH_TOKEN_NOT_SUPPORTED( _, let message), .TOKENS_GET_COLUMN_NOT_SUPPPORTED( _, let message),.MISSING_COLUMN_NAME( _, let message),.EMPTY_COMPOSABLE_LAYOUT_ARRAY(_, let message), .INVALID_IDS_TYPE( _, let message),.MISSING_COMPOSABLE_CONTAINER_OPTIONS(code: _, message: let message): + case .APIError(_, let message), .INVALID_REDACTION_TYPE( _, let message), .EMPTY_COLUMN_NAME_IN_COLLECT( _, let message), .EMPTY_TABLE_NAME_IN_COLLECT( _, let message), .REGEX_MATCH_FAILED( _, let message), .EMPTY_TABLE_NAME( _, let message), .EMPTY_VAULT_ID( _, let message), .EMPTY_VAULT_URL( _, let message), .RECORDS_KEY_ERROR( _, let message),.INVALID_RECORDS_TYPE( _, let message), .EMPTY_COLUMN_NAME( _, let message), .INVALID_BEARER_TOKEN_FORMAT( _, let message), .MISSING_RECORDS_IN_ADDITIONAL_FIELDS( _, let message), .EMPTY_RECORDS_OBJECT( _, let message), .MISSING_RECORDS_IN_GETBYID( _, let message),.INVALID_URL( _, let message),.SKYFLOW_IDS_AND_COLUMN_NAME_BOTH_SPECIFIED( _, let message), .MISSING_IDS_OR_COLUMN_VALUES_IN_GET( _, let message), .MISSING_RECORD_COLUMN_VALUE( _, let message), .UPSERT_OPTION_CANNOT_BE_EMPTY( _, let message),.INVALID_COLUMN_VALUES_IN_GET( _, let message),.MISSING_COMPOSABLE_LAYOUT_KEY(_, let message), .MISMATCH_ELEMENT_COUNT_LAYOUT_SUM(_, let message),.EMPTY_TOKEN_ID( _, let message), .ID_KEY_ERROR( _, let message),.REDACTION_WITH_TOKEN_NOT_SUPPORTED( _, let message), .TOKENS_GET_COLUMN_NOT_SUPPPORTED( _, let message),.MISSING_COLUMN_NAME( _, let message),.EMPTY_COMPOSABLE_LAYOUT_ARRAY(_, let message), .INVALID_IDS_TYPE( _, let message),.MISSING_COMPOSABLE_CONTAINER_OPTIONS(code: _, message: let message): return message // Single value formatting // swiftlint:disable:next line_length - case .UNMOUNTED_COLLECT_ELEMENT( _, let message, let value), .UNMOUNTED_REVEAL_ELEMENT( _, let message, let value), .EMPTY_IDS( _, let message, let value), .EMPTY_ID_VALUE( _, let message, let value), .TABLE_KEY_ERROR( _, let message, let value), .FIELDS_KEY_ERROR( _, let message, let value), .EMPTY_FIELDS_KEY( _, let message, let value), .REDACTION_KEY_ERROR( _, let message, let value), .MISSING_KEY_IDS( _, let message, let value), .INVALID_TABLE_NAME_TYPE( _, let message, let value), .INVALID_FIELDS_TYPE( _, let message, let value), .INVALID_TOKEN_TYPE( _, let message, let value), .MISSING_TABLE_NAME_IN_USERT_OPTION( _, let message, let value), .MISSING_COLUMN_NAME_IN_USERT_OPTION( _, let message, let value), .COLUMN_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(_, let message, let value), .TABLE_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION( _, let message, let value), .EMPTY_RECORD_COLUMN_VALUES( _, let message, let value), .EMPTY_COLUMN_VALUE( _, let message, let value), .INVALID_COLUMN_NAME( _, let message, let value), .DUPLICATE_ADDITIONAL_FIELD_FOUND( _, let message, let value), .ERROR_TRIGGERED( _, let message, let value): + case .UNMOUNTED_COLLECT_ELEMENT( _, let message, let value), .UNMOUNTED_REVEAL_ELEMENT( _, let message, let value), .EMPTY_IDS( _, let message, let value), .EMPTY_ID_VALUE( _, let message, let value), .EMPTY_SKYFLOW_ID( _, let message, let value), .INVALID_TOKEN_GROUP_REDACTION_ENTRY( _, let message, let value), .TABLE_KEY_ERROR( _, let message, let value), .FIELDS_KEY_ERROR( _, let message, let value), .EMPTY_FIELDS_KEY( _, let message, let value), .REDACTION_KEY_ERROR( _, let message, let value), .MISSING_KEY_IDS( _, let message, let value), .INVALID_TABLE_NAME_TYPE( _, let message, let value), .INVALID_FIELDS_TYPE( _, let message, let value), .INVALID_TOKEN_TYPE( _, let message, let value), .MISSING_TABLE_NAME_IN_USERT_OPTION( _, let message, let value), .MISSING_COLUMN_NAME_IN_USERT_OPTION( _, let message, let value), .COLUMN_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(_, let message, let value), .UNIQUE_COLUMNS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(_, let message, let value), .TABLE_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION( _, let message, let value), .EMPTY_RECORD_COLUMN_VALUES( _, let message, let value), .EMPTY_COLUMN_VALUE( _, let message, let value), .INVALID_COLUMN_NAME( _, let message, let value), .DUPLICATE_ADDITIONAL_FIELD_FOUND( _, let message, let value), .ERROR_TRIGGERED( _, let message, let value): return formatMessage(message, [value]) // Multi value formatting case .DUPLICATE_ELEMENT_FOUND( _, let message, let values): @@ -121,7 +123,7 @@ internal enum ErrorCodes: CustomStringConvertible { } else { result += values[valuesIndex] } - + valuesIndex += 1 } else { result += word @@ -134,13 +136,91 @@ internal enum ErrorCodes: CustomStringConvertible { } public class SkyflowError: NSError { - var xml: String = "" - - func setXML(xml: String) { - self.xml = xml + // Only populated for a whole-request API failure (see init(apiError:)) - nil for + // client-side validation failures, which only have httpCode/message. + public let grpcCode: Int? + public let httpStatus: String? + public let details: [Any]? + + // Aliases for NSError's own code/localizedDescription, matching the API error JSON's key names. + public var httpCode: Int { self.code } + public var message: String { self.localizedDescription } + + public init(domain: String, code: Int, userInfo: [String: Any]? = nil, + grpcCode: Int? = nil, httpStatus: String? = nil, details: [Any]? = nil) { + self.grpcCode = grpcCode + self.httpStatus = httpStatus + self.details = details + super.init(domain: domain, code: code, userInfo: userInfo) + } + + public required init?(coder: NSCoder) { + self.grpcCode = coder.decodeObject(forKey: "grpcCode") as? Int + self.httpStatus = coder.decodeObject(forKey: "httpStatus") as? String + self.details = coder.decodeObject(forKey: "details") as? [Any] + super.init(coder: coder) + } + + // Builds a SkyflowError out of a whole-request API failure of the shape + // {"error": {"grpcCode", "httpCode", "message", "httpStatus", "details"}} - returns nil if + // apiError isn't in that shape (e.g. it's already a SkyflowError from client-side validation). + // httpCode maps to NSError's own `code`, and message maps to localizedDescription/NSLocalizedDescriptionKey. + public convenience init?(apiError: Any) { + guard let dict = apiError as? [String: Any], + let detail = dict["error"] as? [String: Any] else { return nil } + self.init( + domain: "", + code: detail["httpCode"] as? Int ?? 0, + userInfo: [NSLocalizedDescriptionKey: detail["message"] as? String ?? ""], + grpcCode: detail["grpcCode"] as? Int, + httpStatus: detail["httpStatus"] as? String, + details: detail["details"] as? [Any] + ) } - - public func getXML() -> String { - return self.xml + + // Normalizes any Callback.onFailure payload into a SkyflowError, so CollectCallback/RevealCallback + // can type onFailure as (SkyflowError) -> Void instead of (Any) -> Void. + internal static func wrap(_ error: Any) -> SkyflowError { + if let skyflowError = error as? SkyflowError { + return skyflowError + } + if let apiError = SkyflowError(apiError: error) { + return apiError + } + if let nsError = error as? NSError { + return SkyflowError(domain: nsError.domain, code: nsError.code, userInfo: nsError.userInfo) + } + // Client.detokenize() bypasses RevealValueCallback entirely, so whole-request failures + // arrive here still wrapped in an "errors" array instead of a raw NSError. Two shapes are + // possible depending on where the failure originated: + // - Client.swift's own pre-network validation (empty vaultID/vaultURL/records key): each + // entry in "errors" IS the NSError directly. + // - FlowVaultRevealAPICallback's post-dispatch failures (invalid bearer token, network + // error, invalid URL, etc.): each entry is {"error": }. + // Recover the underlying NSError instead of stringifying the whole container dict. + // (Container.reveal()'s equivalent failures are already unwrapped earlier, + // by RevealValueCallback.onFailure.) + // + // A third shape reaches here too: FlowVaultInsertAPICallback's insert+update merge + // (group.notify in onSuccess) builds mergedErrors entries as {"error": {"message", + // "httpCode", ...}} - a JSON-decoded API error detail dict, not an NSError - so it's + // handled by trying SkyflowError(apiError:) on the entry itself, which expects exactly + // that {"error": {...}} shape. + if let dict = error as? [String: Any], let errorsArray = dict["errors"] as? [Any] { + for entry in errorsArray { + if let nsError = entry as? NSError { + return SkyflowError(domain: nsError.domain, code: nsError.code, userInfo: nsError.userInfo) + } + if let nested = entry as? [String: Any] { + if let nsError = nested["error"] as? NSError { + return SkyflowError(domain: nsError.domain, code: nsError.code, userInfo: nsError.userInfo) + } + if let apiError = SkyflowError(apiError: entry) { + return apiError + } + } + } + } + return SkyflowError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "\(error)"]) } } diff --git a/Sources/Skyflow/reveal/FlowVaultDetokenizeRequestBody.swift b/Sources/Skyflow/reveal/FlowVaultDetokenizeRequestBody.swift new file mode 100644 index 00000000..0a689f8d --- /dev/null +++ b/Sources/Skyflow/reveal/FlowVaultDetokenizeRequestBody.swift @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Used for generating request body for FlowDB v2 detokenize api call + +import Foundation + +internal class FlowVaultDetokenizeRequestBody { + internal static func createRequestBody(vaultID: String, records: [RevealRequestRecord], tokenGroupRedactions: [TokenGroupRedaction]? = nil) -> [String: Any] { + let tokens = records.map { $0.token } + var body: [String: Any] = ["vaultID": vaultID, "tokens": tokens] + if let tokenGroupRedactions = tokenGroupRedactions, !tokenGroupRedactions.isEmpty { + // Pass through exactly what was collected from the mounted elements, duplicates + // and all - the API validates tokenGroupRedactions, not the SDK. + body["tokenGroupRedactions"] = tokenGroupRedactions.map { ["tokenGroupName": $0.tokenGroupName, "redaction": $0.redaction] } + } + return body + } +} diff --git a/Sources/Skyflow/reveal/FlowVaultRevealApiCallback.swift b/Sources/Skyflow/reveal/FlowVaultRevealApiCallback.swift new file mode 100644 index 00000000..7932295e --- /dev/null +++ b/Sources/Skyflow/reveal/FlowVaultRevealApiCallback.swift @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Implementation of callback for Reveal api (FlowDB v2) + +import Foundation + +class FlowVaultRevealAPICallback: Callback { + var apiClient: APIClient + var callback: Callback + var connectionUrl: String + var records: [RevealRequestRecord] + var tokenGroupRedactions: [TokenGroupRedaction]? + var contextOptions: ContextOptions + + + internal init(callback: Callback, apiClient: APIClient, connectionUrl: String, + records: [RevealRequestRecord], tokenGroupRedactions: [TokenGroupRedaction]? = nil, contextOptions: ContextOptions) { + self.apiClient = apiClient + self.callback = callback + self.connectionUrl = connectionUrl + self.records = records + self.tokenGroupRedactions = tokenGroupRedactions + self.contextOptions = contextOptions + } + + internal func onSuccess(_ token: Any) { + guard let url = URL(string: connectionUrl) else { + let errorCode = ErrorCodes.INVALID_URL() + self.callRevealOnFailure(callback: self.callback, errorObject: errorCode.getErrorObject(contextOptions: self.contextOptions)) + return + } + + do { + let (request, session) = try getRequestSession(url: url) + let task = session.dataTask(with: request) { data, response, error in + do { + let response = try self.processResponse(data: data, response: response, error: error) + self.callback.onSuccess(response) + } catch { + self.callRevealOnFailure(callback: self.callback, errorObject: error) + } + } + task.resume() + } catch let error { + self.callRevealOnFailure(callback: self.callback, errorObject: error) + } + } + + internal func onFailure(_ error: Any) { + if error is Error { + callRevealOnFailure(callback: self.callback, errorObject: error as! Error) + } else { + self.callback.onFailure(error) + } + } + + private func callRevealOnFailure(callback: Callback, errorObject: Error) { + let result = ["errors": [["error": errorObject]]] + callback.onFailure(result) + } + + internal func getRequestSession(url: URL) throws -> (URLRequest, URLSession) { + var jsonString = "" + + do { + let deviceDetails = FetchMetrices().getMetrices() + let jsonData = try JSONSerialization.data(withJSONObject: deviceDetails, options: []) + jsonString = String(data: jsonData, encoding: .utf8) ?? "" + } catch { + jsonString = "" + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + + do { + let data = try JSONSerialization.data(withJSONObject: FlowVaultDetokenizeRequestBody.createRequestBody(vaultID: self.apiClient.vaultID, records: records, tokenGroupRedactions: tokenGroupRedactions)) + request.httpBody = data + } + + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(("Bearer " + self.apiClient.token), forHTTPHeaderField: "Authorization") + request.setValue(jsonString, forHTTPHeaderField: "sky-metadata") + + return (request, URLSession(configuration: .default)) + } + + func processResponse(data: Data?, response: URLResponse?, error: Error?) throws -> [String: Any] { + if error != nil || response == nil { + throw error! + } + + if let httpResponse = response as? HTTPURLResponse { + let range = 400...599 + if range ~= httpResponse.statusCode { + // FlowDB returns a non-2xx status when every token in the batch fails to + // detokenize, but the body still has the same {"response": [...]} shape as a + // success/partial response (each entry carrying its own error/httpCode) - parse + // it as such instead of collapsing into a generic top-level error. + if let safeData = data, + let jsonObject = try? JSONSerialization.jsonObject(with: safeData, options: .allowFragments) as? [String: Any], + jsonObject["response"] != nil { + return try getDetokenizeResponseBody(data: safeData) + } + var description = "Detokenize call failed with the following status code " + String(httpResponse.statusCode) + if let safeData = data { + do { + let errorResponse = try JSONSerialization.jsonObject(with: safeData, options: .allowFragments) as! [String: Any] + if let errorDetails = errorResponse["error"] as? [String: Any], + let message = errorDetails["message"] as? String { + description = message + } + if let requestId = httpResponse.allHeaderFields["x-request-id"] { + description += " - request-id: \(requestId)" + } + } catch { + throw ErrorCodes.APIError(code: httpResponse.statusCode, message: String(data: safeData, encoding: .utf8) ?? "Unknown error").getErrorObject(contextOptions: self.contextOptions) + } + } + throw ErrorCodes.APIError(code: httpResponse.statusCode, message: description).getErrorObject(contextOptions: self.contextOptions) + } + } + + guard let safeData = data else { + return ["records": []] + } + + return try getDetokenizeResponseBody(data: safeData) + } + + func getDetokenizeResponseBody(data: Data) throws -> [String: Any] { + let jsonData = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as! [String: Any] + var records: [[String: Any]] = [] + + let responseRecords = jsonData["response"] as? [[String: Any]] ?? [] + for entry in responseRecords { + if let error = entry["error"] as? String { + var errorEntry: [String: Any] = ["error": error] + if let token = entry["token"] { errorEntry["token"] = token } + if let httpCode = entry["httpCode"] { errorEntry["httpCode"] = httpCode } + records.append(errorEntry) + } else { + var successEntry: [String: Any] = [:] + if let token = entry["token"] { successEntry["token"] = token } + if let value = entry["value"] { successEntry["value"] = value } + if let tokenGroupName = entry["tokenGroupName"] { successEntry["tokenGroupName"] = tokenGroupName } + if let httpCode = entry["httpCode"] { successEntry["httpCode"] = httpCode } + if let metadata = entry["metadata"] as? [String: Any] { successEntry["metadata"] = metadata } + records.append(successEntry) + } + } + + return ["records": records] + } +} diff --git a/Sources/Skyflow/reveal/RevealApiCallback.swift b/Sources/Skyflow/reveal/RevealApiCallback.swift index 424ea0f6..ad1ec260 100644 --- a/Sources/Skyflow/reveal/RevealApiCallback.swift +++ b/Sources/Skyflow/reveal/RevealApiCallback.swift @@ -54,10 +54,10 @@ class RevealAPICallback: Callback { defer { revealRequestGroup.leave() } - + do { let (success, failure) = try self.processResponse(record: record, data: data, response: response, error: error) - + if success != nil { list_success.append(success!) } @@ -89,7 +89,7 @@ class RevealAPICallback: Callback { let result = ["errors": [["error": errorObject]]] callback.onFailure(result) } - + internal func getRequestSession() -> (URLRequest, URLSession){ var jsonString = "" @@ -107,29 +107,29 @@ class RevealAPICallback: Callback { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(("Bearer " + self.apiClient.token), forHTTPHeaderField: "Authorization") request.setValue(jsonString, forHTTPHeaderField: "sky-metadata") - + return (request, URLSession(configuration: .default)) } - + internal func getRevealRequestBody(record: RevealRequestRecord) throws -> Data { let bodyObject: [String: Any] = [ "detokenizationParameters": [ [ "token": record.token, - "redaction": record.redaction + "redaction": "PLAIN_TEXT" ] ] ] return try JSONSerialization.data(withJSONObject: bodyObject) } - + internal func processResponse(record: RevealRequestRecord, data: Data?, response: URLResponse?, error: Error?) throws -> (RevealSuccessRecord?, RevealErrorRecord?){ - + if error != nil || response == nil { throw error! } - + if let httpResponse = response as? HTTPURLResponse { let range = 400...599 if range ~= httpResponse.statusCode { @@ -154,13 +154,13 @@ class RevealAPICallback: Callback { let receivedResponseArray: [Any] = (jsonData[keyPath: "records"] as! [Any]) let records: [String: Any] = receivedResponseArray[0] as! [String: Any] let successRecord = RevealSuccessRecord(token_id: records["token"] as! String, value: records["value"] as! String) - + return (successRecord, nil) } - + return (nil, nil) } - + func handleCallbacks(success: [RevealSuccessRecord], failure: [RevealErrorRecord], isSuccess: Bool, errorObject: Error!) { var records: [Any] = [] for record in success { @@ -183,7 +183,7 @@ class RevealAPICallback: Callback { if errors.count != 0 { modifiedResponse["errors"] = errors } - + if isSuccess { if errors.isEmpty { self.callback.onSuccess(modifiedResponse) diff --git a/Sources/Skyflow/reveal/RevealContainer.swift b/Sources/Skyflow/reveal/RevealContainer.swift index 57f97bc9..bf678474 100644 --- a/Sources/Skyflow/reveal/RevealContainer.swift +++ b/Sources/Skyflow/reveal/RevealContainer.swift @@ -25,14 +25,14 @@ public extension Container { return revealElement } - func reveal(callback: Callback, options: RevealOptions? = RevealOptions()) where T: RevealContainer { + func reveal(callback: RevealCallback, options: RevealOptions? = RevealOptions()) where T: RevealContainer { var tempContextOptions = self.skyflow.contextOptions tempContextOptions.interface = .REVEAL_CONTAINER if self.skyflow.vaultID.isEmpty { let errorCode = ErrorCodes.EMPTY_VAULT_ID() return callback.onFailure(errorCode.getErrorObject(contextOptions: tempContextOptions)) } - if self.skyflow.vaultURL == "/v1/vaults/" { + if self.skyflow.vaultURL == "/v2/vaults/" { let errorCode = ErrorCodes.EMPTY_VAULT_URL() return callback.onFailure(errorCode.getErrorObject(contextOptions: tempContextOptions)) } @@ -55,14 +55,28 @@ public extension Container { return } } + // Mirrors the JS SDK's validateRevealOptions: every redaction entry must have a + // non-empty tokenGroupName and redaction. Whitespace-only values are rejected + // too — stricter than JS, which only checks !== ''. (The JS array/string type + // checks are enforced by Swift's type system and don't need porting.) + if let redactions = options?.tokenGroupRedactions { + for (index, entry) in redactions.enumerated() { + if entry.tokenGroupName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || entry.redaction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + errorCode = .INVALID_TOKEN_GROUP_REDACTION_ENTRY(value: String(index)) + callback.onFailure(errorCode!.getErrorObject(contextOptions: tempContextOptions)) + return + } + } + } let revealValueCallback = RevealValueCallback(callback: callback, revealElements: self.revealElements, contextOptions: tempContextOptions) let records = RevealRequestBody.createRequestBody(elements: self.revealElements) if let tokens = records["records"] as? [[String: Any]] { var list: [RevealRequestRecord] = [] for token in tokens { - if let redaction = token["redaction"] as? RedactionType, let id = token["token"] as? String { - list.append(RevealRequestRecord(token: id, redaction: redaction.rawValue)) + if let id = token["token"] as? String { + list.append(RevealRequestRecord(token: id)) } } let logCallback = LogCallback(clientCallback: revealValueCallback, contextOptions: tempContextOptions, @@ -72,7 +86,7 @@ public extension Container { onFailureHandler: { } ) - self.skyflow.apiClient.get(records: list, callback: logCallback, contextOptions: tempContextOptions) + self.skyflow.apiClient.get(records: list, tokenGroupRedactions: options?.tokenGroupRedactions, callback: logCallback, contextOptions: tempContextOptions) } } } diff --git a/Sources/Skyflow/reveal/RevealElementInput.swift b/Sources/Skyflow/reveal/RevealElementInput.swift index d0da3195..3bf4d7f3 100644 --- a/Sources/Skyflow/reveal/RevealElementInput.swift +++ b/Sources/Skyflow/reveal/RevealElementInput.swift @@ -12,26 +12,14 @@ public struct RevealElementInput { internal var labelStyles: Styles? internal var errorTextStyles: Styles? internal var label: String - internal var redaction: RedactionType internal var altText: String - public init(token: String = "", inputStyles: Styles = Styles(), labelStyles: Styles = Styles(), errorTextStyles: Styles = Styles(), label: String, redaction: RedactionType = .PLAIN_TEXT, altText: String = "") { - self.token = token - self.inputStyles = inputStyles - self.labelStyles = labelStyles - self.errorTextStyles = errorTextStyles - self.label = label - self.redaction = redaction - self.altText = altText - } - public init(token: String = "", inputStyles: Styles = Styles(), labelStyles: Styles = Styles(), errorTextStyles: Styles = Styles(), label: String, altText: String = "") { self.token = token self.inputStyles = inputStyles self.labelStyles = labelStyles self.errorTextStyles = errorTextStyles self.label = label - self.redaction = .PLAIN_TEXT self.altText = altText } } diff --git a/Sources/Skyflow/reveal/RevealOptions.swift b/Sources/Skyflow/reveal/RevealOptions.swift index 0857e940..eaf1de74 100644 --- a/Sources/Skyflow/reveal/RevealOptions.swift +++ b/Sources/Skyflow/reveal/RevealOptions.swift @@ -4,7 +4,7 @@ // // File.swift -// +// // // Created by Akhil Anil Mangala on 11/08/21. // @@ -12,5 +12,9 @@ import Foundation public struct RevealOptions { - public init() {} + public let tokenGroupRedactions: [TokenGroupRedaction]? + + public init(tokenGroupRedactions: [TokenGroupRedaction]? = nil) { + self.tokenGroupRedactions = tokenGroupRedactions + } } diff --git a/Sources/Skyflow/reveal/RevealRequestBody.swift b/Sources/Skyflow/reveal/RevealRequestBody.swift index 72d6d919..8b0230e1 100644 --- a/Sources/Skyflow/reveal/RevealRequestBody.swift +++ b/Sources/Skyflow/reveal/RevealRequestBody.swift @@ -12,7 +12,6 @@ internal class RevealRequestBody { for element in elements { var entry: [String: Any] = [:] entry["token"] = element.revealInput.token - entry["redaction"] = element.revealInput.redaction payload.append(entry) } diff --git a/Sources/Skyflow/reveal/RevealRequestRecord.swift b/Sources/Skyflow/reveal/RevealRequestRecord.swift index 6b1b0b01..2652b247 100644 --- a/Sources/Skyflow/reveal/RevealRequestRecord.swift +++ b/Sources/Skyflow/reveal/RevealRequestRecord.swift @@ -8,9 +8,7 @@ import Foundation struct RevealRequestRecord { var token: String - var redaction: String - init(token: String, redaction: String) { + init(token: String) { self.token = token - self.redaction = redaction } } diff --git a/Sources/Skyflow/reveal/RevealResponse.swift b/Sources/Skyflow/reveal/RevealResponse.swift new file mode 100644 index 00000000..f9baa0c6 --- /dev/null +++ b/Sources/Skyflow/reveal/RevealResponse.swift @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Public typed wrappers around the dictionary delivered via Callback.onSuccess for both +// Client.detokenize() and Container.reveal() - both deliver the same merged +// {"records": [...]} shape (success and per-token failures share one array, matching Collect's +// convention). The Callback protocol still delivers raw [String: Any] as before - these types +// don't change that, they just wrap it directly (no JSONDecoder round-trip). Usage: +// +// public func onSuccess(_ responseBody: Any) { +// guard let response = Skyflow.RevealResponse(responseBody) else { return } +// ... +// } + +import Foundation + +public struct RevealResponse { + public let records: [RevealRecord] + + public init?(_ responseBody: Any) { + guard let dict = responseBody as? [String: Any], + let recordDicts = dict["records"] as? [[String: Any]] else { return nil } + self.records = recordDicts.map { RevealRecord($0) } + } +} + +// Each entry in "records" is either a successfully revealed token (error is nil) or a failed one +// (error is non-nil) - the vault returns both together in the same array, each tagged with its +// own httpCode. +public struct RevealRecord { + public let token: String? + public let tokenGroupName: String? + // Keyed by whatever fields the vault includes (e.g. "skyflowId", "tableName"). + public let metadata: [String: Any]? + public let httpCode: Int + public let error: String? + + init(_ dict: [String: Any]) { + self.token = dict["token"] as? String + self.tokenGroupName = dict["tokenGroupName"] as? String + self.metadata = RevealRecord.normalizeMetadataKeys(dict["metadata"] as? [String: Any]) + self.httpCode = dict["httpCode"] as? Int ?? 0 + self.error = dict["error"] as? String + } + + // The vault sends this key as "skyflowID" - normalized here to "skyflowId" so metadata's + // casing is consistent with Skyflow.CollectRecord.skyflowId rather than exposing the raw + // wire casing directly. + private static func normalizeMetadataKeys(_ metadata: [String: Any]?) -> [String: Any]? { + guard let metadata = metadata else { return nil } + var normalized: [String: Any] = [:] + for (key, value) in metadata { + normalized[key == "skyflowID" ? "skyflowId" : key] = value + } + return normalized + } +} + +// A ready-made Skyflow.Callback that unwraps the raw responseBody/error into RevealResponse +// for you. Use it directly with the callback-based reveal(callback:options:) - RevealValueCallback +// merges success/failure into one "records" array (matching Collect's convention), so the same +// RevealResponse type used for Client.detokenize() applies here too. +public class RevealCallback: Callback { + private let successHandler: (RevealResponse) -> Void + private let failureHandler: (SkyflowError) -> Void + + public init(onSuccess: @escaping (RevealResponse) -> Void, onFailure: @escaping (SkyflowError) -> Void) { + self.successHandler = onSuccess + self.failureHandler = onFailure + } + + public func onSuccess(_ responseBody: Any) { + guard let response = RevealResponse(responseBody) else { + failureHandler(SkyflowError.wrap(responseBody)) + return + } + successHandler(response) + } + + // Delivered when the entire request fails (e.g. vault not found, network error) rather + // than a per-token failure inside RevealResponse.records, and for client-side validation + // failures (empty vaultID, unmounted element, etc.). Always normalized into a + // Skyflow.SkyflowError - see SkyflowError.wrap. + public func onFailure(_ error: Any) { + failureHandler(SkyflowError.wrap(error)) + } +} diff --git a/Sources/Skyflow/reveal/RevealValueCallback.swift b/Sources/Skyflow/reveal/RevealValueCallback.swift index a32438fc..4c7e1d2b 100644 --- a/Sources/Skyflow/reveal/RevealValueCallback.swift +++ b/Sources/Skyflow/reveal/RevealValueCallback.swift @@ -18,42 +18,31 @@ internal class RevealValueCallback: Callback { func onSuccess(_ responseBody: Any) { var tokens: [String: String] = [:] - - let responseJson = responseBody as! [String: Any] + let responseJson = responseBody as? [String: Any] ?? [:] var response: [String: Any] = [:] - var tempSuccessResponses: [[String: String]] = [] + var records: [[String: Any]] = [] + var errors: [[String: Any]] = [] - if let records = responseJson["records"] as? [Any] { - for record in records { - let dict = record as! [String: Any] - let token = dict["token"] as! String + // Success and per-token failure entries arrive together in one "records" array (matching + // Collect's convention and the JS SDK's shape), each distinguished by an "error" key. + if let responseRecords = responseJson["records"] as? [Any] { + for record in responseRecords { + guard let dict = record as? [String: Any] else { continue } + if dict["error"] != nil { + errors.append(dict) + records.append(dict) + continue + } + guard let token = dict["token"] as? String else { continue } let value = dict["value"] as? String tokens[token] = value ?? token - - var successEntry: [String: String] = [:] - successEntry["token"] = token - tempSuccessResponses.append(successEntry) - } - } - - var successResponses: [[String: String]] = [] - for entry in tempSuccessResponses { - if let token = entry["token"] { - successResponses.append(entry) + + records.append(dict) } } - if successResponses.count != 0 { - response["success"] = successResponses - } - var errors = [] as [[String: Any]] - if let responseErrors = responseJson["errors"] as? [[String: Any]] { - errors = responseErrors - } + response["records"] = records let tokensToErrors = getTokensToErrors(errors) - if errors.count != 0 { - response["errors"] = errors - } DispatchQueue.main.async { for revealElement in self.revealElements { @@ -74,63 +63,75 @@ internal class RevealValueCallback: Callback { self.clientCallback.onSuccess(response) } + // Reserved for whole-request failures: a genuinely opaque error (can't be parsed as a + // dictionary at all), or a network/API-level error not scoped to any specific token (no + // "token" key - e.g. an invalid bearer token, a connection failure). Only PER-TOKEN failures + // (each tagged with the "token" they belong to) are routed through onSuccess instead, matching + // Collect's convention of surfacing per-record issues via the record's own "error" field. func onFailure(_ error: Any) { - var response: [String: Any] = [:] + guard let responseJson = error as? [String: Any] else { + self.clientCallback.onFailure(error) + return + } - if error is [String: Any] { - var tokens: [String: String] = [:] - - let responseJson = error as! [String: Any] - var tempSuccessResponses: [[String: String]] = [] - - if let records = responseJson["records"] as? [Any] { - for record in records { - let dict = record as! [String: Any] - let token = dict["token"] as! String - let value = dict["value"] as? String - tokens[token] = value ?? token - - var successEntry: [String: String] = [:] - successEntry["token"] = token - tempSuccessResponses.append(successEntry) - } - } - - var successResponses: [[String: String]] = [] - for entry in tempSuccessResponses { - if let token = entry["token"] { - successResponses.append(entry) + var errors = [] as [[String: Any]] + if let responseErrors = responseJson["errors"] as? [[String: Any]] { + errors = responseErrors + } + + // Not scoped to any specific reveal element - a genuine network/API-level failure, not a + // per-record issue, so it's delivered via onFailure rather than folded into the records array. + if let networkError = errors.first(where: { $0["token"] == nil }) { + DispatchQueue.main.async { + for revealElement in self.revealElements { + revealElement.hideError() } } + self.clientCallback.onFailure(networkError["error"] ?? error) + return + } - if successResponses.count != 0 { - response["success"] = successResponses - } - var errors = [[:]] as [[String: Any]] - if let responseErrors = responseJson["errors"] as? [[String: Any]] { - errors = responseErrors + var tokens: [String: String] = [:] + var records: [[String: Any]] = [] + + if let responseRecords = responseJson["records"] as? [Any] { + for record in responseRecords { + guard let dict = record as? [String: Any], let token = dict["token"] as? String else { continue } + let value = dict["value"] as? String + tokens[token] = value ?? token + + records.append(dict) } - - let tokensToErrors = getTokensToErrors(errors) - if errors.count != 0 { - response["errors"] = errors + } + + // Each entry's "error" arrives as an NSError here (built internally, not deserialized from + // JSON) - convert to a plain message string so it matches the "records" array's normal + // shape (each entry's "error" is always a String, whether from onSuccess or here). + let stringifiedErrors = errors.map { entry -> [String: Any] in + var updated = entry + if let nsError = entry["error"] as? NSError { + updated["error"] = nsError.localizedDescription } - - DispatchQueue.main.async { - for revealElement in self.revealElements { - if let v = tokens[revealElement.revealInput.token]{ - revealElement.updateVal(value: v) - } - - let inputToken = revealElement.revealInput.token - revealElement.hideError() - if let errorMessage = tokensToErrors[inputToken] { - revealElement.showError(message: errorMessage) - } + return updated + } + records.append(contentsOf: stringifiedErrors) + + let tokensToErrors = getTokensToErrors(errors) + + DispatchQueue.main.async { + for revealElement in self.revealElements { + if let v = tokens[revealElement.revealInput.token]{ + revealElement.updateVal(value: v) + } + + let inputToken = revealElement.revealInput.token + revealElement.hideError() + if let errorMessage = tokensToErrors[inputToken] { + revealElement.showError(message: errorMessage) } } } - self.clientCallback.onFailure(response) + self.clientCallback.onSuccess(["records": records]) } func getTokensToErrors(_ errors: [[String: Any]]?) -> [String: String] { diff --git a/Sources/Skyflow/reveal/TokenGroupRedaction.swift b/Sources/Skyflow/reveal/TokenGroupRedaction.swift new file mode 100644 index 00000000..c5b3cd51 --- /dev/null +++ b/Sources/Skyflow/reveal/TokenGroupRedaction.swift @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Object that describes a request-level, per-token-group redaction for detokenize + +import Foundation + +public struct TokenGroupRedaction { + public let tokenGroupName: String + public let redaction: String + + public init(tokenGroupName: String, redaction: String) { + self.tokenGroupName = tokenGroupName + self.redaction = redaction + } +} diff --git a/Sources/Skyflow/skyflow_iOS.swift b/Sources/Skyflow/skyflow_iOS.swift deleted file mode 100644 index 226327f6..00000000 --- a/Sources/Skyflow/skyflow_iOS.swift +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright (c) 2022 Skyflow -*/ - -struct skyflow_iOS { - var text = "Hello, World!" -} diff --git a/Tests/skyflow-iOS-collectTests/DemoImpl.swift b/Tests/skyflow-iOS-collectTests/DemoImpl.swift index 7d3e3f85..e89f3a1f 100644 --- a/Tests/skyflow-iOS-collectTests/DemoImpl.swift +++ b/Tests/skyflow-iOS-collectTests/DemoImpl.swift @@ -42,12 +42,24 @@ public class DemoAPICallback: Callback { var receivedResponse: String = "default" var expectation: XCTestExpectation var data: [String: Any] = [:] + var collectResponse: Skyflow.CollectResponse? + var revealResponse: Skyflow.RevealResponse? public init(expectation: XCTestExpectation) { self.expectation = expectation } public func onSuccess(_ responseBody: Any) { + if let response = responseBody as? Skyflow.CollectResponse { + self.collectResponse = response + expectation.fulfill() + return + } + if let response = responseBody as? Skyflow.RevealResponse { + self.revealResponse = response + expectation.fulfill() + return + } do { let dataString = String(data: try JSONSerialization.data(withJSONObject: responseBody), encoding: .utf8) if let unwrapped = dataString { @@ -68,4 +80,15 @@ public class DemoAPICallback: Callback { } expectation.fulfill() } + + // CollectContainer.collect(callback:)/RevealContainer.reveal(callback:) require the + // concrete Skyflow.CollectCallback/RevealCallback types - these wrap self so existing + // DemoAPICallback call sites only need a one-word change (.asCollectCallback/.asRevealCallback). + var asCollectCallback: Skyflow.CollectCallback { + Skyflow.CollectCallback(onSuccess: { self.onSuccess($0) }, onFailure: { self.onFailure($0) }) + } + + var asRevealCallback: Skyflow.RevealCallback { + Skyflow.RevealCallback(onSuccess: { self.onSuccess($0) }, onFailure: { self.onFailure($0) }) + } } diff --git a/Tests/skyflow-iOS-collectTests/skyflow_iOS_collectTests.swift b/Tests/skyflow-iOS-collectTests/skyflow_iOS_collectTests.swift index 78e42f2c..456e64bf 100644 --- a/Tests/skyflow-iOS-collectTests/skyflow_iOS_collectTests.swift +++ b/Tests/skyflow-iOS-collectTests/skyflow_iOS_collectTests.swift @@ -33,7 +33,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) @@ -57,7 +57,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) @@ -85,7 +85,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) @@ -107,7 +107,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) @@ -127,7 +127,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) @@ -144,7 +144,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) @@ -160,7 +160,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) @@ -177,7 +177,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) _ = container?.create(input: collectInput, options: options) @@ -193,7 +193,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let collectElement = container?.create(input: collectInput, options: options) @@ -225,7 +225,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) @@ -237,7 +237,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback) + container?.collect(callback: callback.asCollectCallback) wait(for: [expectation], timeout: 10.0) @@ -254,7 +254,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "cardnumber", label: "Card Number", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "cardnumber", label: "Card Number", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) @@ -264,7 +264,7 @@ final class skyflow_iOS_collectTests: XCTestCase { window.addSubview(cardNumber!) - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) + let collectInput2 = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "cvv", type: .CVV) let cvv = container?.create(input: collectInput2, options: options) @@ -277,7 +277,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback) + container?.collect(callback: callback.asCollectCallback) wait(for: [expectation], timeout: 10.0) @@ -294,7 +294,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) @@ -302,7 +302,7 @@ final class skyflow_iOS_collectTests: XCTestCase { window.addSubview(cardNumber!) - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) + let collectInput2 = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "cvv", type: .CVV) let cvv = container?.create(input: collectInput2, options: options) @@ -314,7 +314,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback) + container?.collect(callback: callback.asCollectCallback) wait(for: [expectation], timeout: 10.0) @@ -329,7 +329,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: true) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) @@ -339,7 +339,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback) + container?.collect(callback: callback.asCollectCallback) wait(for: [expectation], timeout: 10.0) @@ -386,7 +386,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let expectation = XCTestExpectation(description: "Pure insert with invalid token") let callback = DemoAPICallback(expectation: expectation) - skyflow.insert(records: ["records": records], options: InsertOptions(tokens: true), callback: callback) + skyflow.insert(records: ["records": records], options: InsertOptions(), callback: callback) wait(for: [expectation], timeout: 10.0) @@ -405,7 +405,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) @@ -428,7 +428,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let mycontainer = skyflow.container(type: ContainerType.COLLECT, options: nil) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) let textField = mycontainer?.create(input: collectInput) let window = UIWindow() @@ -439,7 +439,7 @@ final class skyflow_iOS_collectTests: XCTestCase { textField?.textFieldDidEndEditing(textField!.textField) let expectFailure = XCTestExpectation(description: "Should fail") let myCallback = DemoAPICallback(expectation: expectFailure) - mycontainer?.collect(callback: myCallback) + mycontainer?.collect(callback: myCallback.asCollectCallback) wait(for: [expectFailure], timeout: 10.0) XCTAssertEqual(myCallback.receivedResponse, "for cardnumber INVALID_CARD_NUMBER\n") @@ -451,7 +451,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let mycontainer = skyflow.container(type: ContainerType.COLLECT, options: nil) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) let textField = mycontainer?.create(input: collectInput) let window = UIWindow() @@ -462,7 +462,7 @@ final class skyflow_iOS_collectTests: XCTestCase { textField?.textFieldDidEndEditing(textField!.textField) let expectFailure = XCTestExpectation(description: "Should fail") let myCallback = DemoAPICallback(expectation: expectFailure) - mycontainer?.collect(callback: myCallback) + mycontainer?.collect(callback: myCallback.asCollectCallback) wait(for: [expectFailure], timeout: 10.0) XCTAssertEqual(myCallback.receivedResponse, "for cardnumber Regex match failed\n") @@ -474,7 +474,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let collectOptions = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "pin", placeholder: "pin", type: .PIN) + let collectInput = CollectElementInput(tableName: "persons", column: "pin", placeholder: "pin", type: .PIN) let pinElement = container?.create(input: collectInput, options: collectOptions) @@ -500,7 +500,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let mycontainer = skyflow.container(type: ContainerType.COLLECT, options: nil) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) let textField = mycontainer?.create(input: collectInput) let window = UIWindow() @@ -513,7 +513,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let expectFailure = XCTestExpectation(description: "Should fail") let myCallback = DemoAPICallback(expectation: expectFailure) - mycontainer?.collect(callback: myCallback) + mycontainer?.collect(callback: myCallback.asCollectCallback) wait(for: [expectFailure], timeout: 10.0) XCTAssertEqual(myCallback.receivedResponse, "for cardnumber triggered error\n") @@ -523,14 +523,14 @@ final class skyflow_iOS_collectTests: XCTestCase { let collectOptions = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "pin", placeholder: "pin", type: .PIN) + let collectInput = CollectElementInput(tableName: "persons", column: "pin", placeholder: "pin", type: .PIN) let pinElement = container?.create(input: collectInput, options: collectOptions) var vs = ValidationSet() vs.add(rule: ElementValueMatchRule(element: pinElement!, error: "ELEMENT NOT MATCHING")) - let collectInput2 = CollectElementInput(table: "persons", column: "", placeholder: "pin", type: .PIN, validations: vs) + let collectInput2 = CollectElementInput(tableName: "persons", column: "", placeholder: "pin", type: .PIN, validations: vs) let confirmPinElement = container?.create(input: collectInput2, options: collectOptions) @@ -554,14 +554,14 @@ final class skyflow_iOS_collectTests: XCTestCase { let collectOptions = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "pin", placeholder: "pin", type: .PIN) + let collectInput = CollectElementInput(tableName: "persons", column: "pin", placeholder: "pin", type: .PIN) let pinElement = container?.create(input: collectInput, options: collectOptions) var vs = ValidationSet() vs.add(rule: ElementValueMatchRule(element: pinElement!, error: "ELEMENT NOT MATCHING")) - let collectInput2 = CollectElementInput(table: "persons", column: "pin", placeholder: "pin", type: .PIN, validations: vs) + let collectInput2 = CollectElementInput(tableName: "persons", column: "pin", placeholder: "pin", type: .PIN, validations: vs) let confirmPinElement = container?.create(input: collectInput2, options: collectOptions) @@ -579,7 +579,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let expectSuccess = XCTestExpectation(description: "Should succeed") let myCallback = DemoAPICallback(expectation: expectSuccess) - let records = CollectRequestBody.createRequestBody(elements: elements, callback: myCallback, contextOptions: ContextOptions()) + let records = FlowVaultCollectRequestBody.createRequestBody(elements: elements, callback: myCallback, contextOptions: ContextOptions()) let recordElement = (records?["records"] as? [[String: Any]])?[0] let fields = recordElement?["fields"] as? [String: Any] @@ -591,7 +591,7 @@ final class skyflow_iOS_collectTests: XCTestCase { func testCollectElementSetValueAndClearValue(){ let container = skyflow.container(type: ContainerType.COLLECT, options: nil) - let collectInput = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) + let collectInput = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "cvv", type: .CVV) let cvvElement = container?.create(input: collectInput) @@ -611,7 +611,7 @@ final class skyflow_iOS_collectTests: XCTestCase { func testCollectElementSetValueAndClearValueWithCustomFormatting(){ let container = skyflow.container(type: ContainerType.COLLECT, options: nil) - let collectInputfieldInput = Skyflow.CollectElementInput(table: "pii_fields", column: "cardholder_name", label: "input field", placeholder: "card input field", type: Skyflow.ElementType.INPUT_FIELD ) + let collectInputfieldInput = Skyflow.CollectElementInput(tableName: "pii_fields", column: "cardholder_name", label: "input field", placeholder: "card input field", type: Skyflow.ElementType.INPUT_FIELD ) let requiredOption = Skyflow.CollectElementOptions(required: true, format: "+91 YYYY-YYYY-YYYY YYYY", translation: ["Y": "[0-9]"]) let collectInputField = container?.create(input: collectInputfieldInput, options: requiredOption ) @@ -633,7 +633,7 @@ final class skyflow_iOS_collectTests: XCTestCase { func testCollectElementSetValueAndClearValueWithCustomFormattingWithEmptyTsanslation(){ let container = skyflow.container(type: ContainerType.COLLECT, options: nil) - let collectInputfieldInput = Skyflow.CollectElementInput(table: "pii_fields", column: "cardholder_name", label: "input field", placeholder: "card input field", type: Skyflow.ElementType.INPUT_FIELD ) + let collectInputfieldInput = Skyflow.CollectElementInput(tableName: "pii_fields", column: "cardholder_name", label: "input field", placeholder: "card input field", type: Skyflow.ElementType.INPUT_FIELD ) let requiredOption = Skyflow.CollectElementOptions(required: true, format: "+91 YYYY-YYYY-YYYY YYYY", translation: ["Y": ""]) let collectInputField = container?.create(input: collectInputfieldInput, options: requiredOption ) @@ -655,7 +655,7 @@ final class skyflow_iOS_collectTests: XCTestCase { func testCollectElementSetValueAndClearValueWithCustomFormattingWithoutTranslation(){ let container = skyflow.container(type: ContainerType.COLLECT, options: nil) - let collectInputfieldInput = Skyflow.CollectElementInput(table: "pii_fields", column: "cardholder_name", label: "input field", placeholder: "card input field", type: Skyflow.ElementType.INPUT_FIELD ) + let collectInputfieldInput = Skyflow.CollectElementInput(tableName: "pii_fields", column: "cardholder_name", label: "input field", placeholder: "card input field", type: Skyflow.ElementType.INPUT_FIELD ) let requiredOption = Skyflow.CollectElementOptions(required: true, format: "+91 YYYY-YYYY-YYYY YYYY") let collectInputField = container?.create(input: collectInputfieldInput, options: requiredOption ) @@ -677,7 +677,7 @@ final class skyflow_iOS_collectTests: XCTestCase { func testCollectElementSetValueAndClearValueWithoutCustomFormatting(){ let container = skyflow.container(type: ContainerType.COLLECT, options: nil) - let collectInputfieldInput = Skyflow.CollectElementInput(table: "pii_fields", column: "cardholder_name", label: "input field", placeholder: "card input field", type: Skyflow.ElementType.INPUT_FIELD ) + let collectInputfieldInput = Skyflow.CollectElementInput(tableName: "pii_fields", column: "cardholder_name", label: "input field", placeholder: "card input field", type: Skyflow.ElementType.INPUT_FIELD ) let requiredOption = Skyflow.CollectElementOptions(required: true) let collectInputField = container?.create(input: collectInputfieldInput, options: requiredOption ) @@ -699,7 +699,7 @@ final class skyflow_iOS_collectTests: XCTestCase { func testCollectElementSetValueAndClearValueWithoutCustomFormattingForCardNumber(){ let container = skyflow.container(type: ContainerType.COLLECT, options: nil) - let collectInputfieldInput = Skyflow.CollectElementInput(table: "pii_fields", column: "cardholder_name", label: "input field", placeholder: "card input field", type: Skyflow.ElementType.CARD_NUMBER ) + let collectInputfieldInput = Skyflow.CollectElementInput(tableName: "pii_fields", column: "cardholder_name", label: "input field", placeholder: "card input field", type: Skyflow.ElementType.CARD_NUMBER ) let requiredOption = Skyflow.CollectElementOptions(required: true, format: "XXXX-XXXX-XXXX-XXXX") let collectInputField = container?.create(input: collectInputfieldInput, options: requiredOption ) @@ -725,251 +725,98 @@ final class skyflow_iOS_collectTests: XCTestCase { let expectation = XCTestExpectation() let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback) + container?.collect(callback: callback.asCollectCallback) wait(for: [expectation], timeout: 20.0) XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_VAULT_ID().getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) } func testCollectNoVaultURL() { - skyflow.vaultURL = "/v1/vaults/" + skyflow.vaultURL = "/v2/vaults/" let container = skyflow.container(type: ContainerType.COLLECT, options: nil) let expectation = XCTestExpectation() let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback) + container?.collect(callback: callback.asCollectCallback) wait(for: [expectation], timeout: 20.0) XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_VAULT_URL().getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) } - func testCollectBadTypeAddionalFields() { - let additionalFields = ["records": "records"] - let container = skyflow.container(type: ContainerType.COLLECT) - - let expectation = XCTestExpectation() - let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, additionalFields: additionalFields)) - - wait(for: [expectation], timeout: 20.0) - - XCTAssertEqual(callback.receivedResponse, ErrorCodes.INVALID_RECORDS_TYPE().getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) - } - - func testCollectNoRecordsInAddionalFields() { - let additionalFields = ["typo": []] - let container = skyflow.container(type: ContainerType.COLLECT) - - let expectation = XCTestExpectation() - let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, additionalFields: additionalFields)) - - wait(for: [expectation], timeout: 20.0) - - XCTAssertEqual(callback.receivedResponse, - ErrorCodes.MISSING_RECORDS_IN_ADDITIONAL_FIELDS() - .getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) - } - func testCollectEmptyRecordsAddionalFields() { - let additionalFields = ["records": []] + let additionalFields = AdditionalFields(records: []) let container = skyflow.container(type: ContainerType.COLLECT) - + let expectation = XCTestExpectation() let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, additionalFields: additionalFields)) - + container?.collect(callback: callback.asCollectCallback, options: CollectOptions(additionalFields: additionalFields)) + wait(for: [expectation], timeout: 20.0) - + XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_RECORDS_OBJECT().getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) } - - func testCollectNoTableKeyAddionalFields() { - let additionalFields = ["records": [[:]]] - let container = skyflow.container(type: ContainerType.COLLECT) - - let expectation = XCTestExpectation() - let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, additionalFields: additionalFields)) - - wait(for: [expectation], timeout: 20.0) - - XCTAssertEqual(callback.receivedResponse, ErrorCodes.TABLE_KEY_ERROR(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) - } - - func testCollectBadTableKeyAddionalFields() { - let additionalFields = ["records": [["table": []]]] - let container = skyflow.container(type: ContainerType.COLLECT) - - let expectation = XCTestExpectation() - let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, additionalFields: additionalFields)) - - wait(for: [expectation], timeout: 20.0) - - XCTAssertEqual(callback.receivedResponse, ErrorCodes.INVALID_TABLE_NAME_TYPE(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) - } - + func testCollectEmptyTableAddionalFields() { - let additionalFields = ["records": [["table": ""]]] + let additionalFields = AdditionalFields(records: [AdditionalFieldsRecord(tableName: "", data: ["field": "value"])]) let container = skyflow.container(type: ContainerType.COLLECT) - + let expectation = XCTestExpectation() let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, additionalFields: additionalFields)) - + container?.collect(callback: callback.asCollectCallback, options: CollectOptions(additionalFields: additionalFields)) + wait(for: [expectation], timeout: 20.0) - + XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_TABLE_NAME().getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) } - - func testCollectNoFieldsKeyAddionalFields() { - let additionalFields = ["records": [["table": "table"]]] - let container = skyflow.container(type: ContainerType.COLLECT) - - let expectation = XCTestExpectation() - let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, additionalFields: additionalFields)) - - wait(for: [expectation], timeout: 20.0) - - XCTAssertEqual(callback.receivedResponse, ErrorCodes.FIELDS_KEY_ERROR(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) - } - - func testCollectInvalidFieldsAddionalFields() { - let additionalFields = ["records": [["table": "table", "fields": "fields"]]] - let container = skyflow.container(type: ContainerType.COLLECT) - - let expectation = XCTestExpectation() - let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, additionalFields: additionalFields)) - - wait(for: [expectation], timeout: 20.0) - - XCTAssertEqual(callback.receivedResponse, ErrorCodes.INVALID_FIELDS_TYPE(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) - } - + func testCollectEmptyFieldsAddionalFields() { - let additionalFields = ["records": [["table": "table", "fields": [:]]]] - let container = skyflow.container(type: ContainerType.COLLECT) - - let expectation = XCTestExpectation() - let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, additionalFields: additionalFields)) - - wait(for: [expectation], timeout: 20.0) - - XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_FIELDS_KEY(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) - } - - func testCollectEmptyColumnNameForUpsertOption() { - let container = skyflow.container(type: ContainerType.COLLECT) - let upsertOptions = [["table": "card1"]] - let expectation = XCTestExpectation() - let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, upsert:upsertOptions)) - - wait(for: [expectation], timeout: 20.0) - - XCTAssertEqual(callback.receivedResponse, ErrorCodes.MISSING_COLUMN_NAME_IN_USERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) - } - - - func testCollectEmptyTableNameForUpsertOption() { + let additionalFields = AdditionalFields(records: [AdditionalFieldsRecord(tableName: "table", data: [:])]) let container = skyflow.container(type: ContainerType.COLLECT) - let upsertOptions = [["column": "person"]] + let expectation = XCTestExpectation() let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, upsert:upsertOptions)) + container?.collect(callback: callback.asCollectCallback, options: CollectOptions(additionalFields: additionalFields)) wait(for: [expectation], timeout: 20.0) - XCTAssertEqual(callback.receivedResponse, ErrorCodes.MISSING_TABLE_NAME_IN_USERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_FIELDS_KEY(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) } - - + func testCollectEmptyUpsertOption() { let container = skyflow.container(type: ContainerType.COLLECT) let expectation = XCTestExpectation() let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, upsert:[])) + container?.collect(callback: callback.asCollectCallback, options: CollectOptions(upsert:[])) wait(for: [expectation], timeout: 20.0) XCTAssertEqual(callback.receivedResponse, ErrorCodes.UPSERT_OPTION_CANNOT_BE_EMPTY().getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) } - func testCollectEmptyColumnNameUpsertOption() { + func testCollectEmptyUniqueColumnsUpsertOption() { let container = skyflow.container(type: ContainerType.COLLECT) - let upsertOptions = [["table": "card1", "column": ""]] + let upsertOptions = [UpsertOption(tableName: "card1", uniqueColumns: [])] let expectation = XCTestExpectation() let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, upsert:upsertOptions)) + container?.collect(callback: callback.asCollectCallback, options: CollectOptions(upsert:upsertOptions)) wait(for: [expectation], timeout: 20.0) - XCTAssertEqual(callback.receivedResponse, ErrorCodes.COLUMN_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.UNIQUE_COLUMNS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) } func testCollectEmptyTableNameUpsertOption() { let container = skyflow.container(type: ContainerType.COLLECT) - let upsertOptions = [["table": "", "column": "person"]] + let upsertOptions = [UpsertOption(tableName: "", uniqueColumns: ["person"])] let expectation = XCTestExpectation() let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, upsert:upsertOptions)) + container?.collect(callback: callback.asCollectCallback, options: CollectOptions(upsert:upsertOptions)) wait(for: [expectation], timeout: 20.0) XCTAssertEqual(callback.receivedResponse, ErrorCodes.TABLE_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COLLECT_CONTAINER)).localizedDescription) } - - - - func testInsertEmptyColumnNameForUpsertOption() { - let container = skyflow.container(type: ContainerType.COLLECT) - let upsertOptions = [["table": "card1"]] - let expectation = XCTestExpectation() - let records = [ - "records" : [[ - "table": "card1", - "fields": [ - "person" : "abcfgdyt", - "cvv" : "567" - ] - ]] - ] - let callback = DemoAPICallback(expectation: expectation) - let insertOptions = Skyflow.InsertOptions(tokens: false, upsert: upsertOptions) - self.skyflow?.insert(records: records, options: insertOptions, callback: callback) - wait(for: [expectation], timeout: 20.0) - - XCTAssertEqual(callback.receivedResponse, ErrorCodes.MISSING_COLUMN_NAME_IN_USERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.INSERT)).localizedDescription) - } - - - func testInsertEmptyTableNameForUpsertOption() { - let container = skyflow.container(type: ContainerType.COLLECT) - let upsertOptions = [["column": "person"]] - let expectation = XCTestExpectation() - let records = [ - "records" : [[ - "table": "card1", - "fields": [ - "person" : "abcfgdyt", - "cvv" : "567" - ] - ]] - ] - let callback = DemoAPICallback(expectation: expectation) - let insertOptions = Skyflow.InsertOptions(tokens: false, upsert: upsertOptions) - self.skyflow?.insert(records: records, options: insertOptions, callback: callback) - wait(for: [expectation], timeout: 20.0) - - XCTAssertEqual(callback.receivedResponse, ErrorCodes.MISSING_TABLE_NAME_IN_USERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.INSERT)).localizedDescription) - } - - func testInsertEmptyUpsertOption() { let container = skyflow.container(type: ContainerType.COLLECT) let expectation = XCTestExpectation() @@ -983,16 +830,16 @@ final class skyflow_iOS_collectTests: XCTestCase { ]] ] let callback = DemoAPICallback(expectation: expectation) - let insertOptions = Skyflow.InsertOptions(tokens: false, upsert: []) + let insertOptions = Skyflow.InsertOptions(upsert: []) self.skyflow?.insert(records: records, options: insertOptions, callback: callback) wait(for: [expectation], timeout: 20.0) XCTAssertEqual(callback.receivedResponse, ErrorCodes.UPSERT_OPTION_CANNOT_BE_EMPTY().getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.INSERT)).localizedDescription) } - func testInsertEmptyColumnNameUpsertOption() { + func testInsertEmptyUniqueColumnsUpsertOption() { let container = skyflow.container(type: ContainerType.COLLECT) - let upsertOptions = [["table": "card1", "column": ""]] + let upsertOptions = [UpsertOption(tableName: "card1", uniqueColumns: [])] let expectation = XCTestExpectation() let records = [ "records" : [[ @@ -1004,16 +851,16 @@ final class skyflow_iOS_collectTests: XCTestCase { ]] ] let callback = DemoAPICallback(expectation: expectation) - let insertOptions = Skyflow.InsertOptions(tokens: false, upsert: upsertOptions) + let insertOptions = Skyflow.InsertOptions(upsert: upsertOptions) self.skyflow?.insert(records: records, options: insertOptions, callback: callback) wait(for: [expectation], timeout: 20.0) - XCTAssertEqual(callback.receivedResponse, ErrorCodes.COLUMN_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.INSERT)).localizedDescription) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.UNIQUE_COLUMNS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.INSERT)).localizedDescription) } func testInsertEmptyTableNameUpsertOption() { let container = skyflow.container(type: ContainerType.COLLECT) - let upsertOptions = [["table": "", "column": "person"]] + let upsertOptions = [UpsertOption(tableName: "", uniqueColumns: ["person"])] let expectation = XCTestExpectation() let records = [ "records" : [[ @@ -1025,13 +872,13 @@ final class skyflow_iOS_collectTests: XCTestCase { ]] ] let callback = DemoAPICallback(expectation: expectation) - let insertOptions = Skyflow.InsertOptions(tokens: false, upsert: upsertOptions) + let insertOptions = Skyflow.InsertOptions(upsert: upsertOptions) self.skyflow?.insert(records: records, options: insertOptions, callback: callback) wait(for: [expectation], timeout: 20.0) XCTAssertEqual(callback.receivedResponse, ErrorCodes.TABLE_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.INSERT)).localizedDescription) } - + func testUnmount() { let container = skyflow.container(type: ContainerType.COLLECT) let date = container?.create(input: CollectElementInput(type: .EXPIRATION_DATE), options: CollectElementOptions(format: "test")) @@ -1106,7 +953,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: Styles(base: Style(cursorColor: .orange)), placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: Styles(base: Style(cursorColor: .orange)), placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) @@ -1119,7 +966,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false, enableCopy: true) - let collectInput1 = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) @@ -1142,7 +989,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false, enableCopy: false) - let collectInput1 = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) @@ -1163,7 +1010,7 @@ final class skyflow_iOS_collectTests: XCTestCase { let options = CollectElementOptions(required: false, enableCopy: true) - let collectInput1 = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) diff --git a/Tests/skyflow-iOS-collectTests/skyflow_iOS_emptySkyflowIdValidationTests.swift b/Tests/skyflow-iOS-collectTests/skyflow_iOS_emptySkyflowIdValidationTests.swift new file mode 100644 index 00000000..a65c16a6 --- /dev/null +++ b/Tests/skyflow-iOS-collectTests/skyflow_iOS_emptySkyflowIdValidationTests.swift @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Unit tests for the EMPTY_SKYFLOW_ID validation in FlowVaultCollectRequestBody: +// an explicit empty-string skyflowId (on an element or an additionalFields record) +// is rejected client-side instead of silently falling back to an insert. + +import XCTest +@testable import Skyflow + +final class skyflow_iOS_emptySkyflowIdValidationTests: XCTestCase { + var skyflow: Client! + + override func setUp() { + self.skyflow = Client(Configuration(vaultID: "id", vaultURL: "http://demo.com", tokenProvider: DemoTokenProvider(), options: Options(logLevel: .DEBUG))) + } + + override func tearDown() { + skyflow = nil + } + + private func makeNameElement(skyflowId: String? = nil) -> TextField { + let container = skyflow.container(type: ContainerType.COLLECT, options: nil) + let input = CollectElementInput(tableName: "persons", column: "name", type: .CARDHOLDER_NAME, skyflowId: skyflowId) + let element = container!.create(input: input, options: CollectElementOptions(required: false)) + element.textField.secureText = "John Doe" + // secureText's setter doesn't sync actualValue on its own - that normally happens via + // the textFieldDidChange delegate hook, which a direct assignment here bypasses. + element.updateActualValue() + return element + } + + func testAdditionalFieldsEmptySkyflowIdRejected() { + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Empty skyflowId in additionalFields")) + let additionalFields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "persons", data: ["name": "demo"], skyflowId: "") + ]) + + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + + XCTAssertNil(requestBody) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_SKYFLOW_ID(value: "additional fields record at index 0").description) + } + + func testAdditionalFieldsEmptySkyflowIdReportsRecordIndex() { + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Index of offending record")) + let additionalFields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "persons", data: ["name": "demo"], skyflowId: "valid-id"), + AdditionalFieldsRecord(tableName: "persons", data: ["age": "30"], skyflowId: "") + ]) + + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + + XCTAssertNil(requestBody) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_SKYFLOW_ID(value: "additional fields record at index 1").description) + } + + func testElementEmptySkyflowIdRejected() { + let element = makeNameElement(skyflowId: "") + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Empty skyflowId on element")) + + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [element], callback: callback, contextOptions: ContextOptions()) + + XCTAssertNil(requestBody) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_SKYFLOW_ID(value: "element with column 'name'").description) + } + + func testNilSkyflowIdDefaultsToInsert() { + let element = makeNameElement() + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Nil skyflowId inserts")) + let additionalFields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "table1", data: ["email": "a@b.com"]) + ]) + + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [element], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + + XCTAssertNotNil(requestBody) + let records = requestBody?["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 2) + let update = requestBody?["update"] as! [String: Any] + XCTAssertTrue(update.isEmpty) + } + + func testCollectElementInputSkyflowIdDefaultsToNil() { + let input = CollectElementInput(tableName: "persons", column: "name", type: .CARDHOLDER_NAME) + XCTAssertNil(input.skyflowId) + } + + func testValidSkyflowIdStillCreatesUpdate() { + let element = makeNameElement(skyflowId: "row-1") + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Valid skyflowId updates")) + + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [element], callback: callback, contextOptions: ContextOptions()) + + XCTAssertNotNil(requestBody) + XCTAssertEqual((requestBody?["records"] as! [[String: Any]]).count, 0) + let update = requestBody?["update"] as! [String: Any] + let entry = update["row-1"] as! [String: Any] + XCTAssertEqual(entry["table"] as? String, "persons") + XCTAssertEqual((entry["fields"] as! [String: Any])["name"] as? String, "John Doe") + } +} diff --git a/Tests/skyflow-iOS-composableTests/skyflow_iOS_composableEelementsTests.swift b/Tests/skyflow-iOS-composableTests/skyflow_iOS_composableEelementsTests.swift index a94cd297..0e72f7b7 100644 --- a/Tests/skyflow-iOS-composableTests/skyflow_iOS_composableEelementsTests.swift +++ b/Tests/skyflow-iOS-composableTests/skyflow_iOS_composableEelementsTests.swift @@ -33,7 +33,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) _ = container?.create(input: collectInput, options: options) @@ -54,7 +54,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) _ = container?.create(input: collectInput, options: options) @@ -74,7 +74,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) _ = container?.create(input: collectInput, options: options) @@ -94,7 +94,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) _ = container?.create(input: collectInput, options: options) @@ -114,7 +114,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) cardNumber?.actualValue = "4111 1111 1111 1111" @@ -137,7 +137,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let collectElement = container?.create(input: collectInput, options: options) @@ -177,7 +177,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) do { @@ -213,7 +213,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "cardnumber", label: "Card Number", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "cardnumber", label: "Card Number", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) @@ -221,7 +221,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { cardNumber?.textFieldDidEndEditing(cardNumber!.textField) - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) + let collectInput2 = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "cvv", type: .CVV) let cvv = container?.create(input: collectInput2, options: options) @@ -257,7 +257,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: true) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) _ = container?.create(input: collectInput, options: options) do { @@ -283,7 +283,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let mycontainer = skyflow.container(type: ContainerType.COMPOSABLE, options: ContainerOptions(layout: [2])) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) let textField = mycontainer?.create(input: collectInput) do { let view = try mycontainer?.getComposableView() @@ -311,7 +311,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let mycontainer = skyflow.container(type: ContainerType.COMPOSABLE, options: ContainerOptions(layout: [1])) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) let textField = mycontainer?.create(input: collectInput) do { let view = try mycontainer?.getComposableView() @@ -338,14 +338,14 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let collectOptions = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "pin", placeholder: "pin", type: .PIN) + let collectInput = CollectElementInput(tableName: "persons", column: "pin", placeholder: "pin", type: .PIN) let pinElement = container?.create(input: collectInput, options: collectOptions) var vs = ValidationSet() vs.add(rule: ElementValueMatchRule(element: pinElement!, error: "ELEMENT NOT MATCHING")) - let collectInput2 = CollectElementInput(table: "persons", column: "", placeholder: "pin", type: .PIN, validations: vs) + let collectInput2 = CollectElementInput(tableName: "persons", column: "", placeholder: "pin", type: .PIN, validations: vs) let confirmPinElement = container?.create(input: collectInput2, options: collectOptions) @@ -371,7 +371,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) cardNumber?.actualValue = "4111 1111 1111 1111" @@ -398,7 +398,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", inputStyles: styles, placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) cardNumber?.actualValue = "4111 1111 1111 1111" @@ -427,10 +427,10 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "cardnumber",inputStyles: styles, label: "Card Number", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "cardnumber",inputStyles: styles, label: "Card Number", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) + let collectInput2 = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "cvv", type: .CVV) let cvv = container?.create(input: collectInput2, options: options) @@ -455,7 +455,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { } func testInsertEmptyTableNameForUpsertOption() { _ = skyflow.container(type: ContainerType.COMPOSABLE) - let upsertOptions = [["column": "person"]] + let upsertOptions = [UpsertOption(tableName: "", uniqueColumns: ["person"])] let expectation = XCTestExpectation() let records = [ "records" : [[ @@ -467,23 +467,24 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { ]] ] let callback = DemoAPICallback(expectation: expectation) - let insertOptions = Skyflow.InsertOptions(tokens: false, upsert: upsertOptions) + let insertOptions = Skyflow.InsertOptions(upsert: upsertOptions) self.skyflow?.insert(records: records, options: insertOptions, callback: callback) wait(for: [expectation], timeout: 20.0) - XCTAssertEqual(callback.receivedResponse, ErrorCodes.MISSING_TABLE_NAME_IN_USERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.INSERT)).localizedDescription) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.TABLE_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.INSERT)).localizedDescription) } - func testCollectBadTableKeyAddionalFields() { - let additionalFields = ["records": [["table": []]]] - let container = skyflow.container(type: ContainerType.COMPOSABLE) - + + func testComposableCollectEmptyVaultURL() { + let clientWithEmptyURL = Client(Configuration(vaultID: "id", vaultURL: "", tokenProvider: DemoTokenProvider())) + let container = clientWithEmptyURL.container(type: ContainerType.COMPOSABLE) + let expectation = XCTestExpectation() let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback, options: CollectOptions(tokens: true, additionalFields: additionalFields)) - + container?.collect(callback: callback) + wait(for: [expectation], timeout: 20.0) - - XCTAssertEqual(callback.receivedResponse, ErrorCodes.INVALID_TABLE_NAME_TYPE(value: "0").getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COMPOSABLE_CONTAINER)).localizedDescription) + + XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_VAULT_URL().getErrorObject(contextOptions: ContextOptions(interface: InterfaceName.COMPOSABLE_CONTAINER)).localizedDescription) } func testCreateRows() { let elements = [1, 2] @@ -587,7 +588,7 @@ final class skyflow_iOS_composableEelementsTests: XCTestCase { let options = CollectElementOptions(required: true) - let collectInput = CollectElementInput(table: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardnumber", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput, options: options) do { diff --git a/Tests/skyflow-iOS-elementTests/InputFormattingTests.swift b/Tests/skyflow-iOS-elementTests/InputFormattingTests.swift index 0975792f..de94f083 100644 --- a/Tests/skyflow-iOS-elementTests/InputFormattingTests.swift +++ b/Tests/skyflow-iOS-elementTests/InputFormattingTests.swift @@ -27,7 +27,7 @@ class InputFormattingTests: XCTestCase { let options = CollectElementOptions(required: false) - let cardNumberInput = CollectElementInput(table: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) + let cardNumberInput = CollectElementInput(tableName: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: cardNumberInput, options: options) window.addSubview(cardNumber!) @@ -43,7 +43,7 @@ class InputFormattingTests: XCTestCase { let options = CollectElementOptions(required: false) - let expiryDateInput = CollectElementInput(table: "persons", column: "cvv", placeholder: "expiryDate", type: .EXPIRATION_DATE) + let expiryDateInput = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "expiryDate", type: .EXPIRATION_DATE) let expiryDate = container?.create(input: expiryDateInput, options: options) window.addSubview(expiryDate!) @@ -59,7 +59,7 @@ class InputFormattingTests: XCTestCase { let options = CollectElementOptions(required: false) - let expiryDateInput = CollectElementInput(table: "persons", column: "cvv", placeholder: "expiryDate", type: .EXPIRATION_MONTH) + let expiryDateInput = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "expiryDate", type: .EXPIRATION_MONTH) let expiryDate = container?.create(input: expiryDateInput, options: options) window.addSubview(expiryDate!) diff --git a/Tests/skyflow-iOS-elementTests/TextFieldDelegateTests.swift b/Tests/skyflow-iOS-elementTests/TextFieldDelegateTests.swift index 03bb05c1..370d5a6f 100644 --- a/Tests/skyflow-iOS-elementTests/TextFieldDelegateTests.swift +++ b/Tests/skyflow-iOS-elementTests/TextFieldDelegateTests.swift @@ -30,7 +30,7 @@ class TextFieldDelegateTests: XCTestCase { let collectOptions = CollectElementOptions(required: false, format: "+91 YYYY-YYYY-YYYY YYYY XXXX", translation: ["X": "[A-Z]", "Y": "[0-9]", "Z": "[A-Za-z0-9]"]) - let inputFieldInput = CollectElementInput(table: "persons", column: "input_field", placeholder: "input_field", type: .INPUT_FIELD) + let inputFieldInput = CollectElementInput(tableName: "persons", column: "input_field", placeholder: "input_field", type: .INPUT_FIELD) let inputField = container?.create(input: inputFieldInput, options: collectOptions) window.addSubview(inputField!) @@ -45,7 +45,7 @@ class TextFieldDelegateTests: XCTestCase { let collectOptions = CollectElementOptions(required: false, format: "+91 YYYY-YYYY-YYYY YYYY XXXX") - let inputFieldInput = CollectElementInput(table: "persons", column: "input_field", placeholder: "input_field", type: .INPUT_FIELD) + let inputFieldInput = CollectElementInput(tableName: "persons", column: "input_field", placeholder: "input_field", type: .INPUT_FIELD) let inputField = container?.create(input: inputFieldInput, options: collectOptions) window.addSubview(inputField!) @@ -60,7 +60,7 @@ class TextFieldDelegateTests: XCTestCase { let collectOptions = CollectElementOptions(required: false, format: "+91 XX XXXX XXXX", translation: ["X": "[0-9]"]) - let inputFieldInput = CollectElementInput(table: "persons", column: "input_field", placeholder: "phone Number", type: .INPUT_FIELD) + let inputFieldInput = CollectElementInput(tableName: "persons", column: "input_field", placeholder: "phone Number", type: .INPUT_FIELD) let inputField = container?.create(input: inputFieldInput, options: collectOptions) window.addSubview(inputField!) @@ -75,7 +75,7 @@ class TextFieldDelegateTests: XCTestCase { let collectOptions = CollectElementOptions(required: false, format: "(XXX) XXX-XXXX", translation: ["X": "[0-9]"]) - let inputFieldInput = CollectElementInput(table: "persons", column: "input_field", placeholder: "phone Number", type: .INPUT_FIELD) + let inputFieldInput = CollectElementInput(tableName: "persons", column: "input_field", placeholder: "phone Number", type: .INPUT_FIELD) let inputField = container?.create(input: inputFieldInput, options: collectOptions) window.addSubview(inputField!) @@ -90,7 +90,7 @@ class TextFieldDelegateTests: XCTestCase { let collectOptions = CollectElementOptions(required: false, format: "XXX-XX-XXXX", translation: ["X": "[0-9]"]) - let inputFieldInput = CollectElementInput(table: "persons", column: "input_field", placeholder: "phone Number", type: .INPUT_FIELD) + let inputFieldInput = CollectElementInput(tableName: "persons", column: "input_field", placeholder: "phone Number", type: .INPUT_FIELD) let inputField = container?.create(input: inputFieldInput, options: collectOptions) window.addSubview(inputField!) @@ -105,7 +105,7 @@ class TextFieldDelegateTests: XCTestCase { let collectOptions = CollectElementOptions(required: false, format: "X-YYYYYYY", translation: ["X": "[A-Z]", "Y": "[0-9]"]) - let inputFieldInput = CollectElementInput(table: "persons", column: "input_field", placeholder: "phone Number", type: .INPUT_FIELD) + let inputFieldInput = CollectElementInput(tableName: "persons", column: "input_field", placeholder: "phone Number", type: .INPUT_FIELD) let inputField = container?.create(input: inputFieldInput, options: collectOptions) window.addSubview(inputField!) diff --git a/Tests/skyflow-iOS-elementTests/skyflow_iOS_elementTests.swift b/Tests/skyflow-iOS-elementTests/skyflow_iOS_elementTests.swift index b6e84385..b54896a9 100644 --- a/Tests/skyflow-iOS-elementTests/skyflow_iOS_elementTests.swift +++ b/Tests/skyflow-iOS-elementTests/skyflow_iOS_elementTests.swift @@ -20,13 +20,13 @@ class skyflow_iOS_elementTests: XCTestCase { self.collectOptions = CollectElementOptions(required: false) - self.collectInput = CollectElementInput(table: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER) + self.collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER) skyflowElement = SkyflowElement(input: collectInput!, options: collectOptions!, contextOptions: ContextOptions(), elements: [] ) textField = TextField(input: collectInput, options: collectOptions, contextOptions: ContextOptions(), elements: []) - let revealElementInput = RevealElementInput(token: "token", label: "RevealElement", redaction: .DEFAULT) + let revealElementInput = RevealElementInput(token: "token", label: "RevealElement") label = Label(input: revealElementInput, options: RevealElementOptions()) } @@ -131,7 +131,7 @@ class skyflow_iOS_elementTests: XCTestCase { let myRules = ValidationSet(rules: [myRegexRule]) - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) let textField = TextField(input: collectInput, options: collectOptions, contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "invalid" @@ -146,7 +146,7 @@ class skyflow_iOS_elementTests: XCTestCase { let myRules = ValidationSet(rules: [myRandomRule, myRegexRule]) - let collectInput = CollectElementInput(table: "tablename", column: "column", placeholder: "John Doe", type: .INPUT_FIELD, validations: myRules) + let collectInput = CollectElementInput(tableName: "tablename", column: "column", placeholder: "John Doe", type: .INPUT_FIELD, validations: myRules) let textField = TextField(input: collectInput, options: collectOptions, contextOptions: ContextOptions(), elements: []) @@ -169,7 +169,7 @@ class skyflow_iOS_elementTests: XCTestCase { let myRules = ValidationSet(rules: [myRegexRule]) - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER, validations: myRules) let textField = TextField(input: collectInput, options: collectOptions, contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "4111111111111111" @@ -180,7 +180,7 @@ class skyflow_iOS_elementTests: XCTestCase { func testTriggerError() { let errorStyle = Style(textColor: .red) let inputStyle = Styles(invalid: errorStyle) - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", inputStyles: inputStyle, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", inputStyles: inputStyle, placeholder: "card number", type: .CARD_NUMBER) let textField = TextField(input: collectInput, options: collectOptions, contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "invalid" @@ -195,7 +195,7 @@ class skyflow_iOS_elementTests: XCTestCase { func testResetError() { let errorStyle = Style(textColor: .red) let inputStyle = Styles(invalid: errorStyle) - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", inputStyles: inputStyle, placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", inputStyles: inputStyle, placeholder: "card number", type: .CARD_NUMBER) let textField = TextField(input: collectInput, options: collectOptions, contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "invalid" @@ -318,20 +318,20 @@ class skyflow_iOS_elementTests: XCTestCase { } func getElementForDropDownTesting()-> TextField { - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER) let textField = TextField(input: collectInput, options: collectOptions, contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "4111111111111111" return textField } func getElementForDropDownTestingRightIcon()-> TextField { - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", iconStyles: Styles(base: Style(cardIconAlignment: .right)),placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", iconStyles: Styles(base: Style(cardIconAlignment: .right)),placeholder: "card number", type: .CARD_NUMBER) let textField = TextField(input: collectInput, options: CollectElementOptions(enableCardIcon: true, enableCopy: true), contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "4111111111111111" return textField } func getElementOfOtherType()-> TextField { - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", iconStyles: Styles(base: Style(cardIconAlignment: .right)),placeholder: "card number", type: .CVV) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", iconStyles: Styles(base: Style(cardIconAlignment: .right)),placeholder: "card number", type: .CVV) let textField = TextField(input: collectInput, options: CollectElementOptions(enableCopy: true), contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "1234" return textField @@ -442,7 +442,7 @@ class skyflow_iOS_elementTests: XCTestCase { } func getElementForDropDownTestingWindow() -> TextField { - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER) let textField = TextField(input: collectInput, options: collectOptions, contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "4111111111111111" return textField @@ -471,14 +471,14 @@ class skyflow_iOS_elementTests: XCTestCase { } func testDisableCardIconWhenEnableCopyTrue(){ - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER) let textField = TextField(input: collectInput, options: CollectElementOptions(enableCardIcon: false, enableCopy: true), contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "4111111111111111" textField.textFieldDidEndEditing(textField.textField) XCTAssertTrue(textField.cardIconContainerView.isHidden) } func testDisableCardIconWhenEnableCopyFalse(){ - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", placeholder: "card number", type: .CARD_NUMBER) let textField = TextField(input: collectInput, options: CollectElementOptions(enableCardIcon: false, enableCopy: false), contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "4111111111111111" textField.textFieldDidEndEditing(textField.textField) @@ -552,7 +552,7 @@ class skyflow_iOS_elementTests: XCTestCase { } } func testGetDropDownIconSetsUpButtonLeftView() { - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", iconStyles: Styles(base: Style(cardIconAlignment: .left)),placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", iconStyles: Styles(base: Style(cardIconAlignment: .left)),placeholder: "card number", type: .CARD_NUMBER) let textField = TextField(input: collectInput, options: CollectElementOptions(enableCardIcon: true, enableCopy: true), contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "4111111111111111" @@ -571,7 +571,7 @@ class skyflow_iOS_elementTests: XCTestCase { } } func testGetDropDownIconSetsUpButtonRightViewEnableCopyFalse() { - let collectInput = CollectElementInput(table: "persons", column: "cardNumber", iconStyles: Styles(base: Style(cardIconAlignment: .right)),placeholder: "card number", type: .CARD_NUMBER) + let collectInput = CollectElementInput(tableName: "persons", column: "cardNumber", iconStyles: Styles(base: Style(cardIconAlignment: .right)),placeholder: "card number", type: .CARD_NUMBER) let textField = TextField(input: collectInput, options: CollectElementOptions(enableCardIcon: true, enableCopy: false), contextOptions: ContextOptions(), elements: []) textField.textField.secureText = "4111111111111111"; textField.textFieldDidEndEditing(textField.textField) textField.update(updateOptions: CollectElementOptions(cardMetaData: ["scheme": [CardType.AMEX]])) diff --git a/Tests/skyflow-iOS-errorTests/DemoImpl.swift b/Tests/skyflow-iOS-errorTests/DemoImpl.swift index 71cb4d32..444ad3c3 100644 --- a/Tests/skyflow-iOS-errorTests/DemoImpl.swift +++ b/Tests/skyflow-iOS-errorTests/DemoImpl.swift @@ -41,13 +41,19 @@ public class DemoAPICallback: Callback { var receivedResponse: String = "" var expectation: XCTestExpectation var data: [String: Any] = [:] + var collectResponse: Skyflow.CollectResponse? + var revealResponse: Skyflow.RevealResponse? public init(expectation: XCTestExpectation) { self.expectation = expectation } public func onSuccess(_ responseBody: Any) { - if responseBody is String { + if let response = responseBody as? Skyflow.CollectResponse { + self.collectResponse = response + } else if let response = responseBody as? Skyflow.RevealResponse { + self.revealResponse = response + } else if responseBody is String { self.receivedResponse = responseBody as! String } else { @@ -66,4 +72,15 @@ public class DemoAPICallback: Callback { } expectation.fulfill() } + + // CollectContainer.collect(callback:)/RevealContainer.reveal(callback:) require the + // concrete Skyflow.CollectCallback/RevealCallback types - these wrap self so existing + // DemoAPICallback call sites only need a one-word change (.asCollectCallback/.asRevealCallback). + var asCollectCallback: Skyflow.CollectCallback { + Skyflow.CollectCallback(onSuccess: { self.onSuccess($0) }, onFailure: { self.onFailure($0) }) + } + + var asRevealCallback: Skyflow.RevealCallback { + Skyflow.RevealCallback(onSuccess: { self.onSuccess($0) }, onFailure: { self.onFailure($0) }) + } } diff --git a/Tests/skyflow-iOS-errorTests/Skyflow_iOS_collectErrorTests.swift b/Tests/skyflow-iOS-errorTests/Skyflow_iOS_collectErrorTests.swift index 3df67f16..84ede272 100644 --- a/Tests/skyflow-iOS-errorTests/Skyflow_iOS_collectErrorTests.swift +++ b/Tests/skyflow-iOS-errorTests/Skyflow_iOS_collectErrorTests.swift @@ -60,7 +60,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let expectation = XCTestExpectation(description: "Pure insert call") let callback = DemoAPICallback(expectation: expectation) - skyflow.insert(records: payload, options: InsertOptions(tokens: true), callback: callback) + skyflow.insert(records: payload, options: InsertOptions(), callback: callback) wait(for: [expectation], timeout: 10.0) let responseData = callback.receivedResponse.utf8 @@ -74,7 +74,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let expectation = XCTestExpectation(description: "Pure insert call") let callback = DemoAPICallback(expectation: expectation) - skyflow.insert(records: payload, options: InsertOptions(tokens: true), callback: callback) + skyflow.insert(records: payload, options: InsertOptions(), callback: callback) wait(for: [expectation], timeout: 10.0) let responseData = callback.receivedResponse.utf8 @@ -92,7 +92,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let expectation = XCTestExpectation(description: "Pure insert call") let callback = DemoAPICallback(expectation: expectation) - skyflow.insert(records: payload, options: InsertOptions(tokens: true), callback: callback) + skyflow.insert(records: payload, options: InsertOptions(), callback: callback) wait(for: [expectation], timeout: 10.0) let responseData = callback.receivedResponse.utf8 @@ -112,7 +112,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let expectation = XCTestExpectation(description: "Pure insert call") let callback = DemoAPICallback(expectation: expectation) - skyflow.insert(records: payload, options: InsertOptions(tokens: true), callback: callback) + skyflow.insert(records: payload, options: InsertOptions(), callback: callback) wait(for: [expectation], timeout: 10.0) let responseData = callback.receivedResponse.utf8 @@ -131,7 +131,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let expectation = XCTestExpectation(description: "Pure insert call") let callback = DemoAPICallback(expectation: expectation) - skyflow.insert(records: payload, options: InsertOptions(tokens: true), callback: callback) + skyflow.insert(records: payload, options: InsertOptions(), callback: callback) wait(for: [expectation], timeout: 10.0) let responseData = callback.receivedResponse.utf8 @@ -151,7 +151,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let expectation = XCTestExpectation(description: "Pure insert call") let callback = DemoAPICallback(expectation: expectation) - skyflow.insert(records: payload, options: InsertOptions(tokens: true), callback: callback) + skyflow.insert(records: payload, options: InsertOptions(), callback: callback) wait(for: [expectation], timeout: 10.0) let responseData = callback.receivedResponse.utf8 @@ -173,7 +173,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { window.addSubview(cardNumber!) - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) + let collectInput2 = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "cvv", type: .CVV) let cvv = container?.create(input: collectInput2, options: options) @@ -184,7 +184,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback) + container?.collect(callback: callback.asCollectCallback) wait(for: [expectation], timeout: 10.0) @@ -200,7 +200,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) @@ -208,7 +208,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { window.addSubview(cardNumber!) - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) + let collectInput2 = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "cvv", type: .CVV) let cvv = container?.create(input: collectInput2, options: options) @@ -219,7 +219,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback) + container?.collect(callback: callback.asCollectCallback) wait(for: [expectation], timeout: 10.0) @@ -233,7 +233,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) @@ -244,7 +244,7 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let callback = DemoAPICallback(expectation: expectation) - container?.collect(callback: callback) + container?.collect(callback: callback.asCollectCallback) wait(for: [expectation], timeout: 10.0) @@ -256,18 +256,18 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let window = UIWindow() let container = skyflow.container(type: ContainerType.COLLECT, options: nil) let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) cardNumber?.textField.secureText = "4111 1111 1111 1111" window.addSubview(cardNumber!) - let collectInput2 = CollectElementInput(table: "persons", column: "card_number", placeholder: "cvv", type: .CVV) + let collectInput2 = CollectElementInput(tableName: "persons", column: "card_number", placeholder: "cvv", type: .CVV) let cvv = container?.create(input: collectInput2, options: options) cvv?.textField.secureText = "211" window.addSubview(cvv!) let expectation = XCTestExpectation(description: "Container insert call - Duplicate Elements") let callback = DemoAPICallback(expectation: expectation) - CollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], callback: callback, contextOptions: ContextOptions(interface: .COLLECT_CONTAINER)) + FlowVaultCollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], callback: callback, contextOptions: ContextOptions(interface: .COLLECT_CONTAINER)) wait(for: [expectation], timeout: 10.0) let responseData = callback.receivedResponse @@ -278,65 +278,51 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let window = UIWindow() let container = skyflow.container(type: ContainerType.COLLECT, options: nil) let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) cardNumber?.textField.secureText = "4111 1111 1111 1111" window.addSubview(cardNumber!) - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) + let collectInput2 = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "cvv", type: .CVV) let cvv = container?.create(input: collectInput2, options: options) cvv?.textField.secureText = "211" window.addSubview(cvv!) let expectation = XCTestExpectation(description: "Container insert call - All valid") let callback = DemoAPICallback(expectation: expectation) - let fields: [String: Any] = [ - "records": [[ - "table": "persons", - "fields": [ - "cvv": "123", - "name": "John Doe" - ]] - ]] - CollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], additionalFields: fields,callback: callback, contextOptions: ContextOptions(interface: .COLLECT_CONTAINER)) + let fields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "persons", data: ["cvv": "123", "name": "John Doe"]) + ]) + FlowVaultCollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], additionalFields: fields,callback: callback, contextOptions: ContextOptions(interface: .COLLECT_CONTAINER)) wait(for: [expectation], timeout: 10.0) - + let responseData = callback.receivedResponse XCTAssertEqual(responseData, ErrorCodes.DUPLICATE_ELEMENT_FOUND(values: ["cvv", "persons"]).description) } - + func testCreateRequestBodyDuplicateInAdditionalFields() { let window = UIWindow() let container = skyflow.container(type: ContainerType.COLLECT, options: nil) let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) cardNumber?.textField.secureText = "4111 1111 1111 1111" window.addSubview(cardNumber!) - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) + let collectInput2 = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "cvv", type: .CVV) let cvv = container?.create(input: collectInput2, options: options) cvv?.textField.secureText = "211" window.addSubview(cvv!) let expectation = XCTestExpectation(description: "Container insert call - All valid") let callback = DemoAPICallback(expectation: expectation) - let fields: [String: Any] = [ - "records": [[ - "table": "persons", - "fields": [ - "duplicate": "123", - "name": "John Doe" - ]], - [ - "table": "persons", - "fields": [ - "duplicate": "123", - ]] - ]] - CollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], additionalFields: fields,callback: callback, contextOptions: ContextOptions(interface: .COLLECT_CONTAINER)) + let fields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "persons", data: ["duplicate": "123", "name": "John Doe"]), + AdditionalFieldsRecord(tableName: "persons", data: ["duplicate": "123"]) + ]) + FlowVaultCollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], additionalFields: fields,callback: callback, contextOptions: ContextOptions(interface: .COLLECT_CONTAINER)) wait(for: [expectation], timeout: 10.0) - + let responseData = callback.receivedResponse XCTAssertEqual(responseData, ErrorCodes.DUPLICATE_ADDITIONAL_FIELD_FOUND(value: "duplicate").description) } @@ -344,157 +330,352 @@ final class Skyflow_iOS_collectErrorTests: XCTestCase { let window = UIWindow() let container = skyflow.container(type: ContainerType.COLLECT, options: nil) let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) cardNumber?.textField.secureText = "4111 1111 1111 1111" window.addSubview(cardNumber!) - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) + let collectInput2 = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "cvv", type: .CVV) let cvv = container?.create(input: collectInput2, options: options) cvv?.textField.secureText = "211" window.addSubview(cvv!) let expectation = XCTestExpectation(description: "Container insert call - All valid") let callback = DemoAPICallback(expectation: expectation) - let fields: [String: Any] = [ - "records": [[ - "table": "persons", - "fields": [ - "cvv": "123", - ]], - [ - "table": "persons", - "fields": [ - "duplicate": "123", - ]] - ]] - CollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], additionalFields: fields,callback: callback, contextOptions: ContextOptions(interface: .COLLECT_CONTAINER)) + let fields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "persons", data: ["cvv": "123"]), + AdditionalFieldsRecord(tableName: "persons", data: ["duplicate": "123"]) + ]) + FlowVaultCollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], additionalFields: fields,callback: callback, contextOptions: ContextOptions(interface: .COLLECT_CONTAINER)) wait(for: [expectation], timeout: 10.0) - + let responseData = callback.receivedResponse XCTAssertEqual(responseData, ErrorCodes.DUPLICATE_ELEMENT_FOUND(values: ["cvv", "persons"]).description) } - + func testCreateRequestBodyWithOnlyInserts() { let window = UIWindow() let container = skyflow.container(type: ContainerType.COLLECT, options: nil) let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) let cardNumber = container?.create(input: collectInput1, options: options) cardNumber?.textField.secureText = "4111 1111 1111 1111" window.addSubview(cardNumber!) - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) + let collectInput2 = CollectElementInput(tableName: "persons", column: "cvv", placeholder: "cvv", type: .CVV) let cvv = container?.create(input: collectInput2, options: options) cvv?.textField.secureText = "211" window.addSubview(cvv!) let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Insert only")) - let requestBody = CollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], callback: callback, contextOptions: ContextOptions()) + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], callback: callback, contextOptions: ContextOptions()) XCTAssertNotNil(requestBody) XCTAssertNotNil(requestBody?["records"]) XCTAssertEqual(requestBody?["records"] as! [NSDictionary], [ ["table": "persons", "fields": ["card_number": "", "cvv": ""]] ]) - XCTAssertEqual(requestBody?["update"] as? [String: String], [:]) + // Every plain CollectElementInput defaults to skyflowId: "" (not nil) - confirms that + // default doesn't accidentally get treated as "update targeting an empty ID". + XCTAssertTrue((requestBody?["update"] as! [String: Any]).isEmpty) } - func testCreateRequestBodyWithOnlyUpdates() { - let additionalFields: [String: Any] = [ - "records": [ - ["table": "table1", "fields": ["column1": "value1"], "skyflowID": "id1"], - ["table": "table1", "fields": ["column2": "value2"], "skyflowID": "id1"] - ] - ] - let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Update only")) - let requestBody = CollectRequestBody.createRequestBody(elements: [], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + func testCreateRequestBodyExplicitEmptyStringSkyflowIdOnElementIsRejected() { + // An explicit empty-string skyflowId is rejected client-side (see + // skyflow_iOS_emptySkyflowIdValidationTests) rather than silently falling back to insert. + let window = UIWindow() + let container = skyflow.container(type: ContainerType.COLLECT, options: nil) + let options = CollectElementOptions(required: false) + let collectInput = CollectElementInput(tableName: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER, skyflowId: "") + let cardNumber = container?.create(input: collectInput, options: options) + cardNumber?.textField.secureText = "4111 1111 1111 1111" + window.addSubview(cardNumber!) + + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Explicit empty-string skyflowId is rejected")) + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [cardNumber!], callback: callback, contextOptions: ContextOptions()) + + XCTAssertNil(requestBody) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_SKYFLOW_ID(value: "element with column 'card_number'").description) + } + + func testCreateRequestBodyWithSkyflowIDInAdditionalFields() { + let additionalFields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "table1", data: ["column1": "value1"], skyflowId: "id1") + ]) + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Update via additionalFields")) + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + XCTAssertNotNil(requestBody) + XCTAssertEqual((requestBody?["records"] as! [[String: Any]]).count, 0) + let update = requestBody?["update"] as! [String: Any] + let entry = update["id1"] as! [String: Any] + XCTAssertEqual(entry["table"] as! String, "table1") + XCTAssertEqual(entry["fields"] as! [String: String], ["column1": "value1"]) + } + + func testCreateRequestBodyEmptyStringSkyflowIdOnAdditionalFieldsIsRejected() { + // An explicit empty-string skyflowId is rejected client-side (see + // skyflow_iOS_emptySkyflowIdValidationTests) rather than silently falling back to insert. + let additionalFields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "table1", data: ["column1": "value1"], skyflowId: "") + ]) + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Empty skyflowId is rejected")) + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + + XCTAssertNil(requestBody) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_SKYFLOW_ID(value: "additional fields record at index 0").description) + } + + func testCreateRequestBodyMergesMultipleAdditionalFieldsSharingSkyflowId() { + // Two additionalFields entries that both target the same skyflowId should merge their + // fields into a single update payload entry, not clobber or duplicate it. + let additionalFields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "persons", data: ["name": "John"], skyflowId: "id1"), + AdditionalFieldsRecord(tableName: "persons", data: ["email": "john@example.com"], skyflowId: "id1") + ]) + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Merge additionalFields sharing a skyflowId")) + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + XCTAssertNotNil(requestBody) - XCTAssertNotNil(requestBody?["update"]) - XCTAssertEqual(requestBody?["update"] as? NSDictionary, ["id1": ["table": "table1", "fields": ["column1": "value1", "column2": "value2"], "skyflowID": "id1"]]) - XCTAssertEqual(requestBody?["records"] as? [[String: String]], []) + let update = requestBody?["update"] as! [String: Any] + XCTAssertEqual(update.count, 1) + let entry = update["id1"] as! [String: Any] + XCTAssertEqual(entry["table"] as! String, "persons") + let fields = entry["fields"] as! [String: String] + XCTAssertEqual(fields["name"], "John") + XCTAssertEqual(fields["email"], "john@example.com") } - func testCreateRequestBodyWithOnlyUpdates2() { + func testCreateRequestBodyWithSkyflowIDOnElement() { let window = UIWindow() let container = skyflow.container(type: ContainerType.COLLECT, options: nil) let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) + let collectInput1 = CollectElementInput(tableName: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER, skyflowId: "id1") let cardNumber = container?.create(input: collectInput1, options: options) cardNumber?.textField.secureText = "4111 1111 1111 1111" window.addSubview(cardNumber!) - - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV) - let cvv = container?.create(input: collectInput2, options: options) - cvv?.textField.secureText = "211" - window.addSubview(cvv!) - - let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Insert only")) - let requestBody = CollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], callback: callback, contextOptions: ContextOptions()) + + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Update via element skyflowID")) + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [cardNumber!], callback: callback, contextOptions: ContextOptions()) XCTAssertNotNil(requestBody) - XCTAssertNotNil(requestBody?["records"]) - XCTAssertEqual(requestBody?["records"] as! [NSDictionary], [ - ["table": "persons", "fields": ["card_number": "", "cvv": ""]] + XCTAssertEqual((requestBody?["records"] as! [[String: Any]]).count, 0) + let update = requestBody?["update"] as! [String: Any] + let entry = update["id1"] as! [String: Any] + XCTAssertEqual(entry["table"] as! String, "persons") + XCTAssertEqual(entry["fields"] as! [String: String], ["card_number": ""]) + } + + func testCreateRequestBodyMixedInsertAndUpdateElements() { + // One plain element (no skyflowId) and one update-by-skyflowId element in the same + // collect() call - the plain one should land in "records", the tagged one in "update", + // without either bucket interfering with the other. + let window = UIWindow() + let container = skyflow.container(type: ContainerType.COLLECT, options: nil) + let options = CollectElementOptions(required: false) + + let insertInput = CollectElementInput(tableName: "cards", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) + let insertElement = container?.create(input: insertInput, options: options) + insertElement?.textField.secureText = "4111 1111 1111 1111" + window.addSubview(insertElement!) + + let updateInput = CollectElementInput(tableName: "persons", column: "name", placeholder: "name", type: .CARDHOLDER_NAME, skyflowId: "id1") + let updateElement = container?.create(input: updateInput, options: options) + updateElement?.textField.secureText = "John Doe" + window.addSubview(updateElement!) + + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Mixed insert and update elements")) + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [insertElement!, updateElement!], callback: callback, contextOptions: ContextOptions()) + + XCTAssertNotNil(requestBody) + let records = requestBody?["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["table"] as! String, "cards") + + let update = requestBody?["update"] as! [String: Any] + XCTAssertEqual(update.count, 1) + let updateEntry = update["id1"] as! [String: Any] + XCTAssertEqual(updateEntry["table"] as! String, "persons") + } + + func testCreateRequestBodyPlainInsertViaAdditionalFieldsCombinedWithElement() { + // additionalFields without a skyflowId is a plain insert, same as a mounted element - + // both should end up in "records" together, none in "update". + let window = UIWindow() + let container = skyflow.container(type: ContainerType.COLLECT, options: nil) + let options = CollectElementOptions(required: false) + + let insertInput = CollectElementInput(tableName: "cards", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) + let insertElement = container?.create(input: insertInput, options: options) + insertElement?.textField.secureText = "4111 1111 1111 1111" + window.addSubview(insertElement!) + + let additionalFields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "accounts", data: ["status": "active"]) + ]) + + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Element insert + additionalFields insert")) + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [insertElement!], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + + XCTAssertNotNil(requestBody) + let records = requestBody?["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 2) + XCTAssertTrue(records.contains { $0["table"] as? String == "cards" }) + XCTAssertTrue(records.contains { $0["table"] as? String == "accounts" }) + + let update = requestBody?["update"] as! [String: Any] + XCTAssertTrue(update.isEmpty) + } + + func testCreateRequestBodyElementUpdateWithAdditionalFieldsInsert() { + // An update-by-skyflowId element combined with a plain-insert additionalFields record - + // "records" should only contain the additionalFields insert, "update" only the element. + let window = UIWindow() + let container = skyflow.container(type: ContainerType.COLLECT, options: nil) + let options = CollectElementOptions(required: false) + + let updateInput = CollectElementInput(tableName: "persons", column: "name", placeholder: "name", type: .CARDHOLDER_NAME, skyflowId: "id1") + let updateElement = container?.create(input: updateInput, options: options) + updateElement?.textField.secureText = "John Doe" + window.addSubview(updateElement!) + + let additionalFields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "accounts", data: ["status": "active"]) ]) - XCTAssertEqual(requestBody?["update"] as? [String: String], [:]) + + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Element update + additionalFields insert")) + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [updateElement!], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + + XCTAssertNotNil(requestBody) + let records = requestBody?["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["table"] as! String, "accounts") + + let update = requestBody?["update"] as! [String: Any] + XCTAssertEqual(update.count, 1) + XCTAssertEqual((update["id1"] as! [String: Any])["table"] as! String, "persons") } - func testCreateRequestBodyWithOnlyUpdates3() { + func testFullCombinationCollectUpdateUpsertAndAdditionalFields() { + // The kitchen-sink scenario: an insert element, an update-by-skyflowId element, an + // insert additionalFields record, and an update additionalFields record, all in one + // request - then upsert applied on top. Upsert only ever touches FlowVaultInsertRequestBody's + // "records" bucket (confirmed: FlowVaultUpdateRequestBody never reads options.upsert at + // all), so it should only decorate the matching insert record and leave "update" untouched. let window = UIWindow() let container = skyflow.container(type: ContainerType.COLLECT, options: nil) let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER, skyflowID: "id1") - let cardNumber = container?.create(input: collectInput1, options: options) - cardNumber?.textField.secureText = "4111 1111 1111 1111" - window.addSubview(cardNumber!) - - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV, skyflowID: "id2") - let cvv = container?.create(input: collectInput2, options: options) - cvv?.textField.secureText = "211" - window.addSubview(cvv!) - - let additionalFields: [String: Any] = [ - "records": [ - ["table": "table1", "fields": ["column1": "value1"], "skyflowID": "id1"], - ["table": "table1", "fields": ["column2": "value2"], "skyflowID": "id1"] - ] - ] - let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Update only")) - let requestBody = CollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + + let insertInput = CollectElementInput(tableName: "cards", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) + let insertElement = container?.create(input: insertInput, options: options) + insertElement?.textField.secureText = "4111 1111 1111 1111" + window.addSubview(insertElement!) + + let updateInput = CollectElementInput(tableName: "persons", column: "name", placeholder: "name", type: .CARDHOLDER_NAME, skyflowId: "id1") + let updateElement = container?.create(input: updateInput, options: options) + updateElement?.textField.secureText = "John Doe" + window.addSubview(updateElement!) + + let additionalFields = AdditionalFields(records: [ + AdditionalFieldsRecord(tableName: "accounts", data: ["status": "active"]), + AdditionalFieldsRecord(tableName: "billing", data: ["zip": "94105"], skyflowId: "id2") + ]) + + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Full combination")) + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [insertElement!, updateElement!], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) XCTAssertNotNil(requestBody) - XCTAssertNotNil(requestBody?["update"]) - XCTAssertEqual(requestBody?["update"] as? NSDictionary, ["id1": ["table": "table1", "fields": ["column1": "value1", "column2": "value2", "card_number": ""], "skyflowID": "id1"], "id2": ["table": "persons", "fields": ["cvv": ""], "skyflowID": "id2"]]) - XCTAssertEqual(requestBody?["records"] as? [[String: String]], []) + + let records = requestBody?["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 2) + XCTAssertTrue(records.contains { $0["table"] as? String == "cards" }) + XCTAssertTrue(records.contains { $0["table"] as? String == "accounts" }) + + let update = requestBody?["update"] as! [String: Any] + XCTAssertEqual(update.count, 2) + XCTAssertEqual((update["id1"] as! [String: Any])["table"] as! String, "persons") + XCTAssertEqual((update["id2"] as! [String: Any])["table"] as! String, "billing") + + // Apply upsert on top, matching only the "cards" table. + let upsertOptions = [UpsertOption(tableName: "cards", uniqueColumns: ["card_number"], updateType: .UPDATE)] + let wireBody = FlowVaultInsertRequestBody.createRequestBody(vaultID: "vault123", records: requestBody!, options: FlowVaultICOptions(upsert: upsertOptions)) + let wireRecords = wireBody["records"] as! [[String: Any]] + + let cardsWireRecord = wireRecords.first { $0["tableName"] as? String == "cards" } + XCTAssertNotNil(cardsWireRecord?["upsert"]) + + let accountsWireRecord = wireRecords.first { $0["tableName"] as? String == "accounts" } + XCTAssertNil(accountsWireRecord?["upsert"]) + + // update bucket is a completely separate path (FlowVaultUpdateRequestBody) - upsert has + // no way to reach or affect it, confirmed structurally since FlowVaultInsertRequestBody + // only ever reads requestBody["records"]. + XCTAssertEqual(update.count, 2) } - func testCreateRequestBodyWithMixedInsertsAndUpdates() { + // Not currently reachable from any documented flow (README only shows ElementValueMatchRule + // used to allow a duplicate plain/insert element, not two update-by-skyflowId elements), but + // the code path exists in FlowVaultCollectRequestBody's update branch too - covering it now + // in case a future update UI (e.g. confirm-password-style re-entry on an editable field) relies on it. + func testCreateRequestBodyElementValueMatchRuleBypassesUpdateDuplicate() { let window = UIWindow() let container = skyflow.container(type: ContainerType.COLLECT, options: nil) let options = CollectElementOptions(required: false) - let collectInput1 = CollectElementInput(table: "persons", column: "card_number", placeholder: "card number", type: .CARD_NUMBER) - let cardNumber = container?.create(input: collectInput1, options: options) - cardNumber?.textField.secureText = "4111 1111 1111 1111" - window.addSubview(cardNumber!) - - let collectInput2 = CollectElementInput(table: "persons", column: "cvv", placeholder: "cvv", type: .CVV, skyflowID: "id2") - let cvv = container?.create(input: collectInput2, options: options) - cvv?.textField.secureText = "211" - window.addSubview(cvv!) - - let additionalFields: [String: Any] = [ - "records": [ - ["table": "table1", "fields": ["column3": "value3"], "skyflowID": "id1"] + + let updateInput1 = CollectElementInput(tableName: "persons", column: "name", placeholder: "name", type: .CARDHOLDER_NAME, skyflowId: "id1") + let updateElement1 = container?.create(input: updateInput1, options: options) + updateElement1?.textField.secureText = "John" + updateElement1?.textFieldDidEndEditing(updateElement1!.textField) + window.addSubview(updateElement1!) + + var vs = ValidationSet() + vs.add(rule: ElementValueMatchRule(element: updateElement1!, error: "ELEMENT NOT MATCHING")) + let updateInput2 = CollectElementInput(tableName: "persons", column: "name", placeholder: "name", type: .CARDHOLDER_NAME, validations: vs, skyflowId: "id1") + let updateElement2 = container?.create(input: updateInput2, options: options) + updateElement2?.textField.secureText = "Jane" + updateElement2?.textFieldDidEndEditing(updateElement2!.textField) + window.addSubview(updateElement2!) + + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Should not fail")) + let requestBody = FlowVaultCollectRequestBody.createRequestBody(elements: [updateElement1!, updateElement2!], callback: callback, contextOptions: ContextOptions()) + + XCTAssertNotNil(requestBody) + let update = requestBody?["update"] as! [String: Any] + XCTAssertEqual(update.count, 1) + let entry = update["id1"] as! [String: Any] + // Second element's value is skipped (continue), not merged over the first's. + XCTAssertEqual((entry["fields"] as! [String: String])["name"], "John") + } + + func testInsertEmptyVaultURL() { + let expectation = XCTestExpectation(description: "Insert with empty vaultURL should fail") + let callback = DemoAPICallback(expectation: expectation) + let clientWithEmptyURL = Client(Configuration(vaultID: "id", vaultURL: "", tokenProvider: DemoTokenProvider())) + + clientWithEmptyURL.insert(records: ["records": [["table": "table", "fields": ["field": "value"]]]], callback: callback) + + wait(for: [expectation], timeout: 10.0) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_VAULT_URL().getErrorObject(contextOptions: ContextOptions(interface: .INSERT)).localizedDescription) + } + + func testCollectCallbackOnFailureParsesStructuredAPIError() { + let wholeRequestFailure: [String: Any] = [ + "error": [ + "grpcCode": 13, + "httpCode": 500, + "message": "Skyflow services experienced an internal error. Contact Skyflow support with request ID 2db2c594-b9a5-48db-a220-f936e12a43e9 for more information.", + "httpStatus": "Internal Server Error", + "details": [] ] ] - let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Mixed inserts and updates")) - let requestBody = CollectRequestBody.createRequestBody(elements: [cardNumber!, cvv!], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) - XCTAssertNotNil(requestBody) - XCTAssertNotNil(requestBody?["records"]) - let records = requestBody?["records"] as? [[String: Any]] - XCTAssertEqual(records?.count, 1) - XCTAssertEqual(records?[0] as! NSDictionary as NSDictionary, ["table": "persons", "fields": ["card_number": ""]]) - XCTAssertNotNil(requestBody?["update"]) - let update = requestBody?["update"] as? [String: Any] - let updateRecords = update?["id1"] as! [String: Any] - XCTAssertEqual(updateRecords as NSDictionary, ["table": "table1", "fields": ["column3": "value3"], "skyflowID": "id1"]) + var receivedError: SkyflowError? + let collectCallback = CollectCallback( + onSuccess: { _ in XCTFail("onSuccess should not be called") }, + onFailure: { error in receivedError = error } + ) + + collectCallback.onFailure(wholeRequestFailure) + + XCTAssertEqual(receivedError?.httpCode, 500) + XCTAssertEqual(receivedError?.message, "Skyflow services experienced an internal error. Contact Skyflow support with request ID 2db2c594-b9a5-48db-a220-f936e12a43e9 for more information.") + XCTAssertEqual(receivedError?.grpcCode, 13) + XCTAssertEqual(receivedError?.httpStatus, "Internal Server Error") + XCTAssertEqual(receivedError?.details?.count, 0) } } diff --git a/Tests/skyflow-iOS-errorTests/Skyflow_iOS_generalErrorTests.swift b/Tests/skyflow-iOS-errorTests/Skyflow_iOS_generalErrorTests.swift index c5dfc98d..1c3e52e9 100644 --- a/Tests/skyflow-iOS-errorTests/Skyflow_iOS_generalErrorTests.swift +++ b/Tests/skyflow-iOS-errorTests/Skyflow_iOS_generalErrorTests.swift @@ -69,14 +69,6 @@ class Skyflow_iOS_generalErrorTests: XCTestCase { } - func testGetByIDRecord() { - let record = GetByIdRecord(ids: ["id1", "id2"], table: "table", redaction: "DEFAULT") - - XCTAssertEqual(record.ids, ["id1", "id2"]) - XCTAssertEqual("table", record.table) - XCTAssertEqual(record.redaction, "DEFAULT") - } - func testValidationSet() { let validationSet = ValidationSet( rules: [SkyflowValidateCardNumber( @@ -146,8 +138,63 @@ class Skyflow_iOS_generalErrorTests: XCTestCase { func testIsTokenValid() { let apiClient = APIClient(vaultID: "", vaultURL: "", tokenProvider: DemoTokenProvider()) let expectation = XCTestExpectation(description: "should get token") - + XCTAssertEqual(false, apiClient.isTokenValid()) } + + func testSkyflowErrorWrapReturnsExistingInstanceUnchanged() { + let original = SkyflowError(domain: "TestDomain", code: 123, userInfo: [NSLocalizedDescriptionKey: "original message"]) + let wrapped = SkyflowError.wrap(original) + XCTAssertTrue(wrapped === original) + } + + func testSkyflowErrorWrapPreservesPlainNSError() { + let nsError = NSError(domain: "NSURLErrorDomain", code: -1009, userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."]) + let wrapped = SkyflowError.wrap(nsError) + + XCTAssertEqual(wrapped.domain, "NSURLErrorDomain") + XCTAssertEqual(wrapped.httpCode, -1009) + XCTAssertEqual(wrapped.message, "The Internet connection appears to be offline.") + XCTAssertNil(wrapped.grpcCode) + XCTAssertNil(wrapped.httpStatus) + XCTAssertNil(wrapped.details) + } + + func testSkyflowErrorWrapFallsBackForOpaqueValue() { + // Anything that isn't a SkyflowError, the structured {"error": {...}} API shape, an + // NSError, or the internal {"errors": [...]} wrapping still needs to produce *something* + // usable rather than crash. + let wrapped = SkyflowError.wrap("just a plain string, not an error at all") + XCTAssertEqual(wrapped.httpCode, 0) + XCTAssertTrue(wrapped.message.contains("just a plain string")) + } + + func testSkyflowAPIErrorInitMissingOptionalFields() { + let apiError: [String: Any] = [ + "error": ["httpCode": 500, "message": "Internal error"] + ] + let skyflowError = SkyflowError(apiError: apiError) + + XCTAssertEqual(skyflowError?.httpCode, 500) + XCTAssertEqual(skyflowError?.message, "Internal error") + XCTAssertNil(skyflowError?.grpcCode) + XCTAssertNil(skyflowError?.httpStatus) + XCTAssertNil(skyflowError?.details) + } + + func testSkyflowAPIErrorInitReturnsNilForMalformedInput() { + XCTAssertNil(SkyflowError(apiError: "not a dictionary")) + XCTAssertNil(SkyflowError(apiError: ["message": "no nested error key"])) + } + + func testSkyflowErrorWrapRecoversNSErrorFromNestedErrorsArray() { + // Mirrors what FlowVaultRevealAPICallback.callRevealOnFailure produces for a + // whole-request failure reaching Client.detokenize() directly (no RevealValueCallback + // in between to unwrap it first). + let underlying = NSError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: "Invalid bearer token"]) + let wrapped = SkyflowError.wrap(["errors": [["error": underlying]]]) + XCTAssertEqual(wrapped.httpCode, 400) + XCTAssertEqual(wrapped.message, "Invalid bearer token") + } } diff --git a/Tests/skyflow-iOS-errorTests/Skyflow_iOS_revealErrorTests.swift b/Tests/skyflow-iOS-errorTests/Skyflow_iOS_revealErrorTests.swift index 038716e6..74da44b9 100644 --- a/Tests/skyflow-iOS-errorTests/Skyflow_iOS_revealErrorTests.swift +++ b/Tests/skyflow-iOS-errorTests/Skyflow_iOS_revealErrorTests.swift @@ -48,26 +48,8 @@ class Skyflow_iOS_revealErrorTests: XCTestCase { } } - func getByIDFromClientWithExpectation(description: String = "should get records", records: [String: Any]) -> String { - let expectRecords = XCTestExpectation(description: description) - let callback = DemoAPICallback(expectation: expectRecords) - skyflow.getById(records: records, callback: callback) - - wait(for: [expectRecords], timeout: 10.0) - if callback.receivedResponse.isEmpty { - if callback.data["errors"] != nil { - return (callback.data["errors"] as! [NSError])[0].localizedDescription - } else { - return "ok" - } - } else { - return callback.receivedResponse - } - } - - func testDetokenizeNoRecords() { - let records = ["typo": [["token": revealTestId, "redaction": RedactionType.DEFAULT]]] + let records = ["typo": [["token": revealTestId]]] let result = getDataFromClientWithExpectation(records: records) XCTAssertEqual(result, ErrorCodes.RECORDS_KEY_ERROR().description) } @@ -77,7 +59,7 @@ class Skyflow_iOS_revealErrorTests: XCTestCase { let result = getDataFromClientWithExpectation(records: records) XCTAssertEqual(result, ErrorCodes.INVALID_RECORDS_TYPE().description) } - + func testDetokenizeEmptyRecords() { let records = ["records": []] let result = getDataFromClientWithExpectation(records: records) @@ -85,21 +67,16 @@ class Skyflow_iOS_revealErrorTests: XCTestCase { } func testDetokenizeNoTokens() { - let records = ["records": [["redaction": RedactionType.DEFAULT]]] + let records = ["records": [["foo": "bar"]]] let result = getDataFromClientWithExpectation(records: records) XCTAssertEqual(result, ErrorCodes.ID_KEY_ERROR().description) } func testDetokenizeBadTokens() { - let records = ["records": [["token": [], "redaction": RedactionType.DEFAULT]]] + let records = ["records": [["token": []]]] let result = getDataFromClientWithExpectation(records: records) XCTAssertEqual(result, ErrorCodes.INVALID_TOKEN_TYPE(value: "0").description) } - func testDetokenizeInvalidRedaction() { - let records = ["records": [["token": "123233232", "redaction": "123"]]] - let result = getDataFromClientWithExpectation(records: records) - XCTAssertEqual(result, ErrorCodes.INVALID_REDACTION_TYPE().description) - } func testContainerRevealWithUnmountedElements() { @@ -112,26 +89,12 @@ class Skyflow_iOS_revealErrorTests: XCTestCase { let revealElement = revealContainer?.create(input: revealElementInput, options: RevealElementOptions()) let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Should return reveal output")) - revealContainer?.reveal(callback: callback) + revealContainer?.reveal(callback: callback.asRevealCallback) let result = callback.receivedResponse XCTAssertEqual(result, ErrorCodes.UNMOUNTED_REVEAL_ELEMENT(value: revealTestId).description) } - func testContainerRevealWithNoRedaction() { // when redaction is not specify plaintext will be applied - let window = UIWindow() - let revealContainer = skyflow.container(type: ContainerType.REVEAL, options: nil) - - let bstyle = Style(borderColor: UIColor.blue, cornerRadius: 20, padding: UIEdgeInsets(top: 15, left: 12, bottom: 15, right: 5), borderWidth: 2, textColor: UIColor.blue) - let styles = Styles(base: bstyle) - - let revealElementInput = RevealElementInput(token: "232h3j23h2jh",inputStyles: styles, label: "RevealElement") - let revealElement = revealContainer?.create(input: revealElementInput, options: RevealElementOptions()) - - window.addSubview(revealElement!) - XCTAssertEqual(revealElement!.revealInput.redaction, .PLAIN_TEXT) - } - func testContainerRevealWithEmptyToken() { let window = UIWindow() let revealContainer = skyflow.container(type: ContainerType.REVEAL, options: nil) @@ -139,64 +102,44 @@ class Skyflow_iOS_revealErrorTests: XCTestCase { let bstyle = Style(borderColor: UIColor.blue, cornerRadius: 20, padding: UIEdgeInsets(top: 15, left: 12, bottom: 15, right: 5), borderWidth: 2, textColor: UIColor.blue) let styles = Styles(base: bstyle) - let revealElementInput = RevealElementInput(inputStyles: styles, label: "RevealElement", redaction: .DEFAULT) + let revealElementInput = RevealElementInput(inputStyles: styles, label: "RevealElement") let revealElement = revealContainer?.create(input: revealElementInput, options: RevealElementOptions()) window.addSubview(revealElement!) let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Should return reveal output")) - revealContainer?.reveal(callback: callback) + revealContainer?.reveal(callback: callback.asRevealCallback) let result = callback.receivedResponse XCTAssertEqual(result, ErrorCodes.EMPTY_TOKEN_ID().description) } - func testGetByIdNoRecords() { - let records = ["ok": [["redaction": RedactionType.DEFAULT]]] - let result = getByIDFromClientWithExpectation(records: records) - XCTAssertEqual(result, ErrorCodes.EMPTY_RECORDS_OBJECT().description) - } + func testContainerRevealEmptyVaultURL() { + // Unlike Client.detokenize()/getById()/get(), RevealContainer.reveal() calls + // callback.onFailure directly with the raw NSError (no callRevealOnFailure wrapping). + let clientWithEmptyURL = Client(Configuration(vaultID: "id", vaultURL: "", tokenProvider: DemoTokenProvider())) + let revealContainer = clientWithEmptyURL.container(type: ContainerType.REVEAL, options: nil) - func testGetByIdInvalidRecords() { - let records = ["records": 12] - let result = getByIDFromClientWithExpectation(records: records) - XCTAssertEqual(result, ErrorCodes.INVALID_RECORDS_TYPE().description) - } + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Reveal with empty vaultURL should fail")) + revealContainer?.reveal(callback: callback.asRevealCallback) - func testGetByIdNoIds() { - let records = ["records": [["redaction": RedactionType.DEFAULT]]] - let result = getByIDFromClientWithExpectation(records: records) - XCTAssertEqual(result, ErrorCodes.MISSING_KEY_IDS(value: "0").description) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.EMPTY_VAULT_URL().getErrorObject(contextOptions: ContextOptions(interface: .REVEAL_CONTAINER)).localizedDescription) } - func testGetByIdInvalidIds() { - let records = ["records": [["ids": RedactionType.DEFAULT]]] - let result = getByIDFromClientWithExpectation(records: records) - XCTAssertEqual(result, ErrorCodes.INVALID_IDS_TYPE().description) - } + func testDetokenizeEmptyVaultURL() { + // Client.detokenize()'s vault-level errors route through callRevealOnFailure, which + // wraps the NSError in {"errors": [errorObject]} rather than passing it through raw. + let expectation = XCTestExpectation(description: "Detokenize with empty vaultURL should fail") + let callback = DemoAPICallback(expectation: expectation) + let clientWithEmptyURL = Client(Configuration(vaultID: "id", vaultURL: "", tokenProvider: DemoTokenProvider())) - func testGetByIdNoTable() { - let records = ["records": [["ids": ["abc"]]]] - let result = getByIDFromClientWithExpectation(records: records) - XCTAssertEqual(result, ErrorCodes.TABLE_KEY_ERROR(value: "0").description) - } - - func testGetByIdInvalidTable() { - let records = ["records": [["ids": ["abc"], "table": ["abc"]]]] - let result = getByIDFromClientWithExpectation(records: records) - XCTAssertEqual(result, ErrorCodes.INVALID_TABLE_NAME_TYPE(value: "0").description) - } + clientWithEmptyURL.detokenize(records: ["records": [["token": "sometoken"]]], callback: callback) - func testGetByIdNoRedaction() { - let records = ["records": [["ids": ["abc"], "table": "table"]]] - let result = getByIDFromClientWithExpectation(records: records) - XCTAssertEqual(result, ErrorCodes.REDACTION_KEY_ERROR(value: "0").description) + wait(for: [expectation], timeout: 10.0) + let errors = callback.data["errors"] as! [NSError] + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors[0].localizedDescription, ErrorCodes.EMPTY_VAULT_URL().description) } - func testGetByIdInvalidRedaction() { - let records = ["records": [["ids": ["abc"], "table": "table", "redaction": "DEFAULT"]]] - let result = getByIDFromClientWithExpectation(records: records) - XCTAssertEqual(result, ErrorCodes.INVALID_REDACTION_TYPE().description) - } } diff --git a/Tests/skyflow-iOS-errorTests/Skyflow_iOS_revealOptionsValidationTests.swift b/Tests/skyflow-iOS-errorTests/Skyflow_iOS_revealOptionsValidationTests.swift new file mode 100644 index 00000000..0ca7c5b7 --- /dev/null +++ b/Tests/skyflow-iOS-errorTests/Skyflow_iOS_revealOptionsValidationTests.swift @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Unit tests for the tokenGroupRedactions validation in RevealContainer.reveal +// (port of the JS SDK's validateRevealOptions): every entry must have a non-empty, +// non-whitespace tokenGroupName and redaction; failures report the entry index. + +import Foundation +import XCTest +@testable import Skyflow + +class Skyflow_iOS_revealOptionsValidationTests: XCTestCase { + var skyflow: Client! + var window: UIWindow! + + override func setUp() { + self.skyflow = Client(Configuration(vaultID: "id", vaultURL: "http://demo.com", tokenProvider: DemoTokenProvider(), options: Options(logLevel: .DEBUG))) + self.window = UIWindow() + } + + override func tearDown() { + skyflow = nil + window = nil + } + + // Builds a container with one mounted, token-bearing element so the reveal call + // gets past the unmounted/empty-token checks and reaches the options validation. + private func makeContainerWithMountedElement() -> Container? { + let container = skyflow.container(type: ContainerType.REVEAL, options: nil) + let input = RevealElementInput(token: "some-token", inputStyles: Styles(), label: "RevealElement") + let element = container?.create(input: input, options: RevealElementOptions()) + window.addSubview(element!) + return container + } + + private func revealAndCaptureError(options: RevealOptions) -> String { + let container = makeContainerWithMountedElement() + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Should fail validation")) + container?.reveal(callback: callback.asRevealCallback, options: options) + return callback.receivedResponse + } + + func testEmptyTokenGroupNameRejected() { + let result = revealAndCaptureError(options: RevealOptions(tokenGroupRedactions: [ + TokenGroupRedaction(tokenGroupName: "", redaction: "plain_text") + ])) + XCTAssertEqual(result, ErrorCodes.INVALID_TOKEN_GROUP_REDACTION_ENTRY(value: "0").description) + } + + func testEmptyRedactionRejected() { + let result = revealAndCaptureError(options: RevealOptions(tokenGroupRedactions: [ + TokenGroupRedaction(tokenGroupName: "deterministic", redaction: "") + ])) + XCTAssertEqual(result, ErrorCodes.INVALID_TOKEN_GROUP_REDACTION_ENTRY(value: "0").description) + } + + func testWhitespaceOnlyTokenGroupNameRejected() { + let result = revealAndCaptureError(options: RevealOptions(tokenGroupRedactions: [ + TokenGroupRedaction(tokenGroupName: " ", redaction: "plain_text") + ])) + XCTAssertEqual(result, ErrorCodes.INVALID_TOKEN_GROUP_REDACTION_ENTRY(value: "0").description) + } + + func testWhitespaceOnlyRedactionRejected() { + let result = revealAndCaptureError(options: RevealOptions(tokenGroupRedactions: [ + TokenGroupRedaction(tokenGroupName: "deterministic", redaction: "\n\t ") + ])) + XCTAssertEqual(result, ErrorCodes.INVALID_TOKEN_GROUP_REDACTION_ENTRY(value: "0").description) + } + + func testInvalidEntryReportsItsIndex() { + // First entry valid, second invalid: index 1 in the error proves the valid + // entry passed and the offending one is identified precisely. + let result = revealAndCaptureError(options: RevealOptions(tokenGroupRedactions: [ + TokenGroupRedaction(tokenGroupName: "deterministic", redaction: "plain_text"), + TokenGroupRedaction(tokenGroupName: "nondeterministic", redaction: " ") + ])) + XCTAssertEqual(result, ErrorCodes.INVALID_TOKEN_GROUP_REDACTION_ENTRY(value: "1").description) + } +} diff --git a/Tests/skyflow-iOS-getByIdTests/skyflow_iOS_getByIdTests.swift b/Tests/skyflow-iOS-getByIdTests/skyflow_iOS_getByIdTests.swift index 315d8832..00c9fcd9 100644 --- a/Tests/skyflow-iOS-getByIdTests/skyflow_iOS_getByIdTests.swift +++ b/Tests/skyflow-iOS-getByIdTests/skyflow_iOS_getByIdTests.swift @@ -74,5 +74,20 @@ class skyflow_iOS_getByIdTests: XCTestCase { XCTAssertEqual(errorMessage, "TokenProvider error") } - + + func testGetByIdEmptyVaultURL() { + // Client.getById()'s vault-level errors route through callRevealOnFailure, which + // wraps the NSError in {"errors": [errorObject]} rather than passing it through raw. + let expectation = XCTestExpectation(description: "getById with empty vaultURL should fail") + let callback = DemoAPICallback(expectation: expectation) + let clientWithEmptyURL = Client(Configuration(vaultID: "id", vaultURL: "", tokenProvider: DemoTokenProvider())) + + clientWithEmptyURL.getById(records: ["records": [["ids": ["id1"], "table": "persons"]]], callback: callback) + + wait(for: [expectation], timeout: 10.0) + let errors = callback.data["errors"] as! [NSError] + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors[0].localizedDescription, ErrorCodes.EMPTY_VAULT_URL().description) + } + } diff --git a/Tests/skyflow-iOS-getTests/skyflow_iOS_getTests.swift b/Tests/skyflow-iOS-getTests/skyflow_iOS_getTests.swift index eabf5a0b..a0413337 100644 --- a/Tests/skyflow-iOS-getTests/skyflow_iOS_getTests.swift +++ b/Tests/skyflow-iOS-getTests/skyflow_iOS_getTests.swift @@ -108,6 +108,20 @@ class skyflow_iOS_getTests: XCTestCase { print(callback.receivedResponse) XCTAssertTrue(callback.data.description.contains(ErrorCodes.REDACTION_WITH_TOKEN_NOT_SUPPORTED().description)) } - - + + func testGetEmptyVaultURL() { + // Client.get()'s vault-level errors route through callRevealOnFailure, which wraps + // the NSError in {"errors": [errorObject]} rather than passing it through raw. + let expectation = XCTestExpectation(description: "get with empty vaultURL should fail") + let callback = DemoAPICallback(expectation: expectation) + let clientWithEmptyURL = Client(Configuration(vaultID: "id", vaultURL: "", tokenProvider: DemoTokenProvider())) + + clientWithEmptyURL.get(records: ["records": [["ids": ["id1"], "table": "persons"]]], callback: callback) + + wait(for: [expectation], timeout: 10.0) + let errors = callback.data["errors"] as! [NSError] + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors[0].localizedDescription, ErrorCodes.EMPTY_VAULT_URL().description) + } + } diff --git a/Tests/skyflow-iOS-revealTests/DemoImpl.swift b/Tests/skyflow-iOS-revealTests/DemoImpl.swift index 73208cc0..1454099e 100644 --- a/Tests/skyflow-iOS-revealTests/DemoImpl.swift +++ b/Tests/skyflow-iOS-revealTests/DemoImpl.swift @@ -35,12 +35,24 @@ public class DemoAPICallback: Callback { var receivedResponse: String = "" var expectation: XCTestExpectation var data: [String: Any] = [:] + var collectResponse: Skyflow.CollectResponse? + var revealResponse: Skyflow.RevealResponse? public init(expectation: XCTestExpectation) { self.expectation = expectation } public func onSuccess(_ responseBody: Any) { + if let response = responseBody as? Skyflow.CollectResponse { + self.collectResponse = response + expectation.fulfill() + return + } + if let response = responseBody as? Skyflow.RevealResponse { + self.revealResponse = response + expectation.fulfill() + return + } defer{ expectation.fulfill() } @@ -64,4 +76,15 @@ public class DemoAPICallback: Callback { } expectation.fulfill() } + + // CollectContainer.collect(callback:)/RevealContainer.reveal(callback:) require the + // concrete Skyflow.CollectCallback/RevealCallback types - these wrap self so existing + // DemoAPICallback call sites only need a one-word change (.asCollectCallback/.asRevealCallback). + var asCollectCallback: Skyflow.CollectCallback { + Skyflow.CollectCallback(onSuccess: { self.onSuccess($0) }, onFailure: { self.onFailure($0) }) + } + + var asRevealCallback: Skyflow.RevealCallback { + Skyflow.RevealCallback(onSuccess: { self.onSuccess($0) }, onFailure: { self.onFailure($0) }) + } } diff --git a/Tests/skyflow-iOS-revealTests/skyflow_iOS_revealTests.swift b/Tests/skyflow-iOS-revealTests/skyflow_iOS_revealTests.swift index 101264b8..d981c078 100644 --- a/Tests/skyflow-iOS-revealTests/skyflow_iOS_revealTests.swift +++ b/Tests/skyflow-iOS-revealTests/skyflow_iOS_revealTests.swift @@ -38,15 +38,6 @@ class skyflow_iOS_revealTests: XCTestCase { let istyle = Style(textColor: .red) let styles = Styles(base: bstyle, invalid: istyle) - let revealElementInput = RevealElementInput(token: revealTestId, inputStyles: styles, label: "RevealElement", redaction: .DEFAULT) - - return revealElementInput - } - func getRevealElementInputNoRedaction() -> RevealElementInput { - let bstyle = Style(borderColor: UIColor.blue, cornerRadius: 20, padding: UIEdgeInsets(top: 15, left: 12, bottom: 15, right: 5), borderWidth: 2, textColor: UIColor.blue) - let istyle = Style(textColor: .red) - let styles = Styles(base: bstyle, invalid: istyle) - let revealElementInput = RevealElementInput(token: revealTestId, inputStyles: styles, label: "RevealElement") return revealElementInput @@ -65,10 +56,23 @@ class skyflow_iOS_revealTests: XCTestCase { let revealElementInput = getRevealElementInput() XCTAssertEqual(revealElementInput.token, revealTestId) - XCTAssertEqual(revealElementInput.redaction, .DEFAULT) XCTAssertEqual(revealElementInput.label, "RevealElement") } + func testRevealOptionsCarriesTokenGroupRedactions() { + let options = RevealOptions(tokenGroupRedactions: [TokenGroupRedaction(tokenGroupName: "deterministic_string", redaction: "MASKED")]) + + XCTAssertEqual(options.tokenGroupRedactions?.count, 1) + XCTAssertEqual(options.tokenGroupRedactions?.first?.tokenGroupName, "deterministic_string") + XCTAssertEqual(options.tokenGroupRedactions?.first?.redaction, "MASKED") + } + + func testRevealOptionsDefaultsToNilTokenGroupRedactions() { + let options = RevealOptions() + + XCTAssertNil(options.tokenGroupRedactions) + } + func testCreateSkyflowRevealContainer() { let revealContainer = skyflow.container(type: ContainerType.REVEAL, options: nil) let revealElementInput = getRevealElementInput() @@ -104,22 +108,11 @@ class skyflow_iOS_revealTests: XCTestCase { let revealContainer = skyflow.container(type: ContainerType.REVEAL, options: nil) let revealElementInput = getRevealElementInput() let revealElement = revealContainer?.create(input: revealElementInput, options: RevealElementOptions()) - - let requestBody = RevealRequestBody.createRequestBody(elements: [revealElement!]) as! [String: [[String: Any]]] - - let result: [String: [[String: Any]]] = ["records": [["token": revealTestId, "redaction": RedactionType.DEFAULT]]] - - XCTAssertTrue(compareDictionaries(dict1: result, dict2: requestBody)) - } - func testCreateRevealRequestBodyWithNoRedaction() { - let revealContainer = skyflow.container(type: ContainerType.REVEAL, options: nil) - let revealElementInput = getRevealElementInputNoRedaction() - let revealElement = revealContainer?.create(input: revealElementInput, options: RevealElementOptions()) - + let requestBody = RevealRequestBody.createRequestBody(elements: [revealElement!]) as! [String: [[String: Any]]] - - let result: [String: [[String: Any]]] = ["records": [["token": revealTestId, "redaction": RedactionType.PLAIN_TEXT]]] - + + let result: [String: [[String: Any]]] = ["records": [["token": revealTestId]]] + XCTAssertTrue(compareDictionaries(dict1: result, dict2: requestBody)) } @@ -182,7 +175,7 @@ class skyflow_iOS_revealTests: XCTestCase { window.addSubview(revealElement!) let callback = DemoAPICallback(expectation: expectFailure) - revealContainer?.reveal(callback: callback) + revealContainer?.reveal(callback: callback.asRevealCallback) wait(for: [expectFailure], timeout: 10.0) diff --git a/Tests/skyflow-iOS-scenarioTests/ClientScenario.swift b/Tests/skyflow-iOS-scenarioTests/ClientScenario.swift index a0189fdb..33f86654 100644 --- a/Tests/skyflow-iOS-scenarioTests/ClientScenario.swift +++ b/Tests/skyflow-iOS-scenarioTests/ClientScenario.swift @@ -47,7 +47,7 @@ class ClientScenario { func getById(ids: [String: Any], callback: Callback) { Client(self.config).getById(records: ids, callback: callback) } - + private func detokenizeRequestBody(_ tokens: [String]) -> [String: [[String: String]]]{ var records = [] as [[String: String]] diff --git a/Tests/skyflow-iOS-scenarioTests/DemoImpl.swift b/Tests/skyflow-iOS-scenarioTests/DemoImpl.swift index 29e985e0..b5b8c05c 100644 --- a/Tests/skyflow-iOS-scenarioTests/DemoImpl.swift +++ b/Tests/skyflow-iOS-scenarioTests/DemoImpl.swift @@ -33,7 +33,6 @@ public class DemoTokenProvider: TokenProvider { public class DemoAPICallback: Callback { var receivedResponse: String = "" - var xml: String = "" var expectation: XCTestExpectation var data: [String: Any] = [:] var error: NSError? = nil @@ -54,9 +53,6 @@ public class DemoAPICallback: Callback { } else if error is [String: Any] { self.data = (error as! [String: Any]) } - if error is SkyflowError { - self.xml = (error as! SkyflowError).getXML() - } expectation.fulfill() } diff --git a/Tests/skyflow-iOS-utilTests/DemoImpl.swift b/Tests/skyflow-iOS-utilTests/DemoImpl.swift index e93c1169..f7315771 100644 --- a/Tests/skyflow-iOS-utilTests/DemoImpl.swift +++ b/Tests/skyflow-iOS-utilTests/DemoImpl.swift @@ -16,6 +16,8 @@ public class DemoAPICallback: Callback { var receivedResponse: String = "" var expectation: XCTestExpectation var data: [String: Any] = [:] + var collectResponse: Skyflow.CollectResponse? + var revealResponse: Skyflow.RevealResponse? public init(expectation: XCTestExpectation) { self.expectation = expectation @@ -23,7 +25,11 @@ public class DemoAPICallback: Callback { public func onSuccess(_ responseBody: Any) { print("success") - if let response = responseBody as? String { + if let response = responseBody as? Skyflow.CollectResponse { + self.collectResponse = response + } else if let response = responseBody as? Skyflow.RevealResponse { + self.revealResponse = response + } else if let response = responseBody as? String { self.receivedResponse = response } else { @@ -44,4 +50,15 @@ public class DemoAPICallback: Callback { expectation.fulfill() } + + // CollectContainer.collect(callback:)/RevealContainer.reveal(callback:) require the + // concrete Skyflow.CollectCallback/RevealCallback types - these wrap self so existing + // DemoAPICallback call sites only need a one-word change (.asCollectCallback/.asRevealCallback). + var asCollectCallback: Skyflow.CollectCallback { + Skyflow.CollectCallback(onSuccess: { self.onSuccess($0) }, onFailure: { self.onFailure($0) }) + } + + var asRevealCallback: Skyflow.RevealCallback { + Skyflow.RevealCallback(onSuccess: { self.onSuccess($0) }, onFailure: { self.onFailure($0) }) + } } diff --git a/Tests/skyflow-iOS-utilTests/MockURLProtocol.swift b/Tests/skyflow-iOS-utilTests/MockURLProtocol.swift new file mode 100644 index 00000000..64a125f9 --- /dev/null +++ b/Tests/skyflow-iOS-utilTests/MockURLProtocol.swift @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Minimal URLProtocol-based network mock. Register via URLProtocol.registerClass(MockURLProtocol.self) +// before the request fires (URLSession(configuration: .default) consults globally registered +// protocol classes), and unregister afterward. Used for tests that need to exercise real +// URLSession.dataTask call sites (e.g. FlowVaultInsertAPICallback's insert+update merge), where +// bypassing the network layer entirely (as most other tests do via processResponse(data:response:error:)) +// wouldn't exercise the merge logic itself. + +import Foundation + +final class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocol(self, didFailWithError: NSError(domain: "MockURLProtocol", code: -1, userInfo: [NSLocalizedDescriptionKey: "No requestHandler configured"])) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/skyflow-iOS-utilTests/skyflow_iOS_collectUtilTests.swift b/Tests/skyflow-iOS-utilTests/skyflow_iOS_collectUtilTests.swift index 40bbe132..98f024db 100644 --- a/Tests/skyflow-iOS-utilTests/skyflow_iOS_collectUtilTests.swift +++ b/Tests/skyflow-iOS-utilTests/skyflow_iOS_collectUtilTests.swift @@ -7,130 +7,169 @@ import XCTest final class skyflow_iOS_collectUtilTests: XCTestCase { - var collectCallback: CollectAPICallback! = nil + var collectCallback: FlowVaultCollectAPICallback! = nil var defaultRecord: [String: Any] = ["records": [["table": "table", "fields": ["field": "value"]]]] - + override func setUp() { - self.collectCallback = CollectAPICallback(callback: DemoAPICallback(expectation: XCTestExpectation()), + self.collectCallback = FlowVaultCollectAPICallback(callback: DemoAPICallback(expectation: XCTestExpectation()), apiClient: APIClient(vaultID: "", vaultURL: "", tokenProvider: DemoTokenProvider()), records: defaultRecord, - options: ICOptions(tokens: false, additionalFields: nil), + options: FlowVaultICOptions(additionalFields: nil), contextOptions: ContextOptions()) } - + func testBuildFieldsDict() { let dict = ["key": "value", "nested": ["key": "value"]] as [String: Any] let result = self.collectCallback.buildFieldsDict(dict: dict) XCTAssertEqual(dict["key"] as! String, result["key"] as! String) XCTAssertEqual(dict["nested"] as! [String: String], result["nested"] as! [String: String]) } - + func testOnSuccessInvalidUrl() { let expectation = XCTestExpectation(description: "Invalid URL should trigger failure") let callback = DemoAPICallback(expectation: expectation) self.collectCallback.apiClient.vaultURL = "Invalid url" self.collectCallback.callback = callback - + self.collectCallback.onSuccess("string") wait(for: [expectation], timeout: 20.0) - - let result = callback.data["errors"] as! [[String: Any]] - let errorObject = result[0]["error"] as! [String: Any] + + let errorObject = callback.data["error"] as! [String: Any] let msg = errorObject["message"] as! String XCTAssert(msg.contains("unsupported URL")) } - + func testGetRequestSession() { let url = URL(string: "https://example.org")! do { let (request, session) = try self.collectCallback.getRequestSession(url: url) - + XCTAssertEqual(request.allHTTPHeaderFields!["Authorization"], "Bearer ") // From DemoTokenProvider() + XCTAssertEqual(request.allHTTPHeaderFields!["Content-Type"], "application/json") + XCTAssertEqual(request.allHTTPHeaderFields!["Accept"], "application/json") let body = try JSONSerialization.jsonObject(with: request.httpBody!, options: .allowFragments) as! [String: Any] let records = body["records"] as! [[String: Any]] XCTAssertEqual(records.count, 1) - XCTAssertEqual(records[0]["fields"] as! [String: String], ["field": "value"]) - XCTAssertEqual(records[0]["method"] as! String, "POST") + XCTAssertEqual(records[0]["data"] as! [String: String], ["field": "value"]) XCTAssertEqual(records[0]["tableName"] as! String, "table") - + } catch { XCTFail(error.localizedDescription) } } - + func testGetCollectResponse() { - let response = ["responses": [["records": [["skyflow_id": "SID"]]]]] - + let response = ["records": [["skyflowID": "SID", "tableName": "table"]]] + do { let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) let result = try self.collectCallback.getCollectResponseBody(data: data) - - XCTAssertEqual(result as! [String: [[String: String]]], ["records": [ - [ - "table": "table", - "skyflow_id": "SID" - ] - ]]) + let records = result["records"] as! [[String: Any]] + + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["tableName"] as! String, "table") + XCTAssertEqual(records[0]["skyflowID"] as! String, "SID") + let fields = records[0]["fields"] as! [String: Any] + XCTAssertNil(fields["tokens"]) } catch { XCTFail(error.localizedDescription) } } - + func testGetCollectResponseWithTokens() { - let response = ["responses": [["records": [["skyflow_id": "SID"]]], ["fields": ["field": "value"]]]] - self.collectCallback.options = ICOptions() - + let response = ["records": [["skyflowID": "SID", "tableName": "table", "tokens": ["field": [["token": "tok", "tokenGroupName": "group"]]]]]] as [String: Any] + self.collectCallback.options = FlowVaultICOptions() + + do { + let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) + let result = try self.collectCallback.getCollectResponseBody(data: data) + let records = result["records"] as! [[String: Any]] + + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["tableName"] as! String, "table") + XCTAssertEqual(records[0]["skyflowID"] as! String, "SID") + let fields = records[0]["fields"] as! [String: Any] + let fieldTokens = fields["field"] as! [[String: Any]] + XCTAssertEqual(fieldTokens[0]["token"] as? String, "tok") + } catch { + XCTFail(error.localizedDescription) + } + } + + func testGetCollectResponseWithHashedData() { + let response: [String: Any] = ["records": [[ + "skyflowID": "SID", + "tableName": "table", + "httpCode": 200, + "data": ["field": "value"], + "hashedData": ["field": "hashed-value"] + ]]] + self.collectCallback.options = FlowVaultICOptions() + do { let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) let result = try self.collectCallback.getCollectResponseBody(data: data) let records = result["records"] as! [[String: Any]] - - let expected = ["records": [["table": "table", "fields": ["field": "value", "skyflow_id": "SID"]]]] - - XCTAssertEqual(records.count, expected["records"]!.count) - XCTAssertEqual(records[0]["table"] as! String, expected["records"]![0]["table"] as! String) - XCTAssertEqual(records[0]["fields"] as! [String: String], expected["records"]![0]["fields"] as! [String: String]) - - + + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["httpCode"] as? Int, 200) + let fields = records[0]["fields"] as! [String: Any] + XCTAssertNil(fields["data"]) + XCTAssertEqual(records[0]["hashedData"] as! [String: String], ["field": "hashed-value"]) } catch { XCTFail(error.localizedDescription) } } - + func testProcessResponse() { - let response = ["responses": [["records": [["skyflow_id": "SID"]]], ["fields": ["field": "value"]]]] - + let response = ["records": [["skyflowID": "SID", "tableName": "table"]]] + do { let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) let response = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) - - let processedData = try self.collectCallback.processResponse(data: data, response: response, error: nil) as! [String: [[String: String]]] - XCTAssertEqual(processedData, ["records": [["skyflow_id": "SID", "table": "table"]]]) - + + let processedData = try self.collectCallback.processResponse(data: data, response: response, error: nil) + let records = processedData["records"] as! [[String: Any]] + XCTAssertEqual(records[0]["tableName"] as! String, "table") + XCTAssertEqual(records[0]["skyflowID"] as! String, "SID") + } catch { XCTFail(error.localizedDescription) } } - + func testProcessResponseError() { - let response = ["responses": [["records": [["skyflow_id": "SID"]]], ["fields": ["field": "value"]]]] - + // A genuine connection/network-level error (URLSession error, no HTTP response reached) + // should not throw - it returns a top-level {"error": {...}} dict directly. + let networkError = NSError(domain: "NSURLErrorDomain", code: -1009, userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."]) + do { - let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) - let response = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) - - let processedData = try self.collectCallback.processResponse(data: data, response: response, error: NSError(domain: "", code: 400, userInfo: nil)) - as! [String: [String: String]] + let processedData = try self.collectCallback.processResponse(data: nil, response: nil, error: networkError) + let errorDict = processedData["error"] as! [String: Any] + XCTAssertEqual(errorDict["message"] as? String, "The Internet connection appears to be offline.") + } catch { + XCTFail("Should not throw for a connection-level error: \(error)") + } + } + + func testGetCollectResponseBodyWithMalformedTopLevelJSON() { + // A response body that parses as valid JSON but isn't a top-level object (e.g. a bare + // array or scalar) must not crash - it should fall back to an empty result. + do { + let arrayData = try JSONSerialization.data(withJSONObject: ["not", "an", "object"], options: .fragmentsAllowed) + let result = try self.collectCallback.getCollectResponseBody(data: arrayData) + XCTAssertEqual((result["records"] as? [[String: Any]])?.count, 0) } catch { + XCTFail("Malformed top-level JSON should not throw or crash: \(error)") } } - + func testProcessResponseFailure() { let response = ["error": ["message": "Internal Server Error"]] do { let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) let response = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 500, httpVersion: "1.1", headerFields: ["x-request-id": "RID"]) - + // XCTFail("Not throwing on Api Error") do { var res = try self.collectCallback.processResponse(data: data, response: response, error: nil) @@ -143,19 +182,19 @@ final class skyflow_iOS_collectUtilTests: XCTestCase { XCTAssertEqual(error.localizedDescription, "Internal Server Error - request-id: RID") } } - + func testCollectInvalidBearerToken() { let expectation = XCTestExpectation() let callback = DemoAPICallback(expectation: expectation) let client = Client(Configuration(vaultID: "id", vaultURL: "https://www.skyflow.com", tokenProvider: DemoTokenProvider())) let container = client.container(type: ContainerType.COLLECT) - let input = CollectElementInput(table: "table", column: "column", type: .EXPIRATION_YEAR) + let input = CollectElementInput(tableName: "table", column: "column", type: .EXPIRATION_YEAR) let element = container?.create(input: input) - + UIWindow().addSubview(element!) - - container?.collect(callback: callback) - + + container?.collect(callback: callback.asCollectCallback) + wait(for: [expectation], timeout: 20.0) XCTAssertTrue(callback.receivedResponse.contains("Token generated from 'getBearerToken' callback function is invalid")) } @@ -167,68 +206,34 @@ final class skyflow_iOS_collectUtilTests: XCTestCase { XCTAssertEqual(device.systemName + "@" + device.systemVersion, deviceInfo["sdk_client_os_details"] as! String) } func testDeviceDetails() { - + let deviceDetails = FetchMetrices().getDeviceDetails() - + XCTAssertNotNil(deviceDetails["device"]) XCTAssertNotNil(deviceDetails["os_details"]) XCTAssertNotNil(deviceDetails["sdk_name_version"]) - + if let device = deviceDetails["device"] as? String { XCTAssertFalse(device.isEmpty) } else { XCTAssertTrue(deviceDetails["device"] as! String == "") } - + if let osDetails = deviceDetails["os_details"] as? String { XCTAssertFalse(osDetails.isEmpty) } else { XCTAssertTrue(deviceDetails["os_details"] as! String == "") } - + if let sdkNameVersion = deviceDetails["sdk_name_version"] as? String { XCTAssertFalse(sdkNameVersion.isEmpty) } else { XCTAssertTrue(deviceDetails["sdk_name_version"] as! String == "") } } - func testUpdateNewFlow() { - let expectation = XCTestExpectation(description: "Update new flow should succeed and merge response") - let callback = DemoAPICallback(expectation: expectation) - let updateRecord: [String: Any] = [ - "table": "table", - "skyflowID": "id1", - "fields": ["field": "newValue"] - ] - let collectCallback = CollectAPICallback( - callback: callback, - apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), - records: ["update": ["id1": updateRecord]], - options: ICOptions(tokens: true, additionalFields: nil), - contextOptions: ContextOptions() - ) - // Simulate a successful update response - let responseDict: [String: Any] = [ - "skyflow_id": "id1", - "tokens": ["field": "newValue"] - ] - let responseData = try! JSONSerialization.data(withJSONObject: responseDict, options: .fragmentsAllowed) - let urlResponse = HTTPURLResponse(url: URL(string: "https://example.org/table/id1")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) - do { - let processed = try collectCallback.processUpdateResponse(data: responseData, response: urlResponse, error: nil, table: "table") - let records = processed["records"] as! [[String: Any]] - print("records after processing update response", records) - XCTAssertEqual(records.count, 1) - XCTAssertEqual(records[0]["table"] as? String, "table") - let fields = records[0]["fields"] as? [String: String] - XCTAssertEqual((fields?["skyflow_id"] ?? "") as String, "id1") - XCTAssertEqual(fields?["field"] as? String, "newValue") - } catch { - XCTFail("Update flow failed: \(error)") - } - } - func testPartialInsertAndUpdateScenario() { - let expectation = XCTestExpectation(description: "Partial insert and update scenario should succeed") + + func testOnlyInsertSuccess() { + let expectation = XCTestExpectation(description: "Only insert records should succeed") let callback = DemoAPICallback(expectation: expectation) let insertRecord: [String: Any] = [ @@ -236,343 +241,404 @@ final class skyflow_iOS_collectUtilTests: XCTestCase { "fields": ["field": "value"] ] - let updateRecord: [String: Any] = [ - "table": "table", - "skyflowID": "id1", - "fields": ["field": "newValue"] - ] - - let collectCallback = CollectAPICallback( + let collectCallback = FlowVaultCollectAPICallback( callback: callback, apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), - records: ["records": [insertRecord], "update": ["id1": updateRecord]], - options: ICOptions(tokens: true, additionalFields: nil), + records: ["records": [insertRecord]], + options: FlowVaultICOptions(additionalFields: nil), contextOptions: ContextOptions() ) // Simulate a successful insert response - let insertResponseDict = ["responses": [["records": [["skyflow_id": "SID"]]], ["fields": ["field": "value"]]]] - let insertResponseData = try! JSONSerialization.data(withJSONObject: insertResponseDict, options: .fragmentsAllowed) - let insertUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/vault")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + let insertResponseDict: [String: Any] = ["records": [["skyflowID": "SID", "tableName": "table", "tokens": ["field": "value"]]]] - // Simulate a successful update response - let updateResponseDict: [String: Any] = [ - "skyflow_id": "id1", - "tokens": ["field": "newValue"] - ] - let updateResponseData = try! JSONSerialization.data(withJSONObject: updateResponseDict, options: .fragmentsAllowed) - let updateUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/table/id1")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + let insertResponseData = try! JSONSerialization.data(withJSONObject: insertResponseDict, options: .fragmentsAllowed) + let insertUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/v2/records/insert")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) do { let processedInsert = try collectCallback.processResponse(data: insertResponseData, response: insertUrlResponse, error: nil) - let processedUpdate = try collectCallback.processUpdateResponse(data: updateResponseData, response: updateUrlResponse, error: nil, table: "table") - let insertRecords = processedInsert["records"] as! [[String: Any]] - let updateRecords = processedUpdate["records"] as! [[String: Any]] XCTAssertEqual(insertRecords.count, 1) - let ifields = insertRecords[0]["fields"] as? [String: String] - XCTAssertEqual(ifields?["skyflow_id"] as? String, "SID") - - XCTAssertEqual(updateRecords.count, 1) - XCTAssertEqual(updateRecords[0]["table"] as? String, "table") - let fields = updateRecords[0]["fields"] as? [String: String] - XCTAssertEqual(fields?["skyflow_id"], "id1") - XCTAssertEqual(fields?["field"], "newValue") + XCTAssertEqual(insertRecords[0]["tableName"] as? String, "table") + XCTAssertEqual(insertRecords[0]["skyflowID"] as? String, "SID") + let fields = insertRecords[0]["fields"] as! [String: Any] + XCTAssertEqual(fields["field"] as? String, "value") } catch { - XCTFail("Partial insert and update scenario failed: \(error)") + XCTFail("Insert scenario failed: \(error)") } } - func testOnlyInsertSuccess() { - let expectation = XCTestExpectation(description: "Only insert records should succeed") + + func testInsertPartialFailure() { + let expectation = XCTestExpectation(description: "Partial insert failure should populate errors array") let callback = DemoAPICallback(expectation: expectation) - let insertRecord: [String: Any] = [ - "table": "table", - "fields": ["field": "value"] + let insertRecords: [[String: Any]] = [ + ["table": "table", "fields": ["field": "value"]], + ["table": "table", "fields": ["field": "value2"]] ] - let collectCallback = CollectAPICallback( + let collectCallback = FlowVaultCollectAPICallback( callback: callback, apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), - records: ["records": [insertRecord]], - options: ICOptions(tokens: true, additionalFields: nil), + records: ["records": insertRecords], + options: FlowVaultICOptions(additionalFields: nil), contextOptions: ContextOptions() ) - // Simulate a successful insert response - let insertResponseDict = ["responses": [["records": [["skyflow_id": "SID"]]], ["fields": ["field": "value"]]]] - - let insertResponseData = try! JSONSerialization.data(withJSONObject: insertResponseDict, options: .fragmentsAllowed) - let insertUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/vault")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + let responseDict: [String: Any] = ["records": [ + ["skyflowID": "SID", "tableName": "table", "tokens": ["field": "value"]], + ["tableName": "table", "error": "insert failed", "httpCode": 400] + ]] + let responseData = try! JSONSerialization.data(withJSONObject: responseDict, options: .fragmentsAllowed) + let urlResponse = HTTPURLResponse(url: URL(string: "https://example.org/v2/records/insert")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) do { - let processedInsert = try collectCallback.processResponse(data: insertResponseData, response: insertUrlResponse, error: nil) - let insertRecords = processedInsert["records"] as! [[String: Any]] + let processed = try collectCallback.processResponse(data: responseData, response: urlResponse, error: nil) + let records = processed["records"] as! [[String: Any]] - XCTAssertEqual(insertRecords.count, 1) - let fields = insertRecords[0]["fields"] as? [String: String] - XCTAssertEqual(fields?["skyflow_id"] as? String, "SID") + XCTAssertEqual(records.count, 2) + XCTAssertNil(records[0]["error"]) + XCTAssertEqual(records[1]["error"] as? String, "insert failed") + XCTAssertEqual(records[1]["httpCode"] as? Int, 400) } catch { - XCTFail("Insert scenario failed: \(error)") + XCTFail("Partial insert failure scenario failed: \(error)") } } - func testOnlyUpdateSuccess() { - let expectation = XCTestExpectation(description: "Only update records should succeed") + func testInsertFullFailureWithNon2xxOuterStatus() { + let expectation = XCTestExpectation(description: "Full batch failure with 400 outer status should still parse per-record errors") let callback = DemoAPICallback(expectation: expectation) - let updateRecord: [String: Any] = [ - "table": "table", - "skyflowID": "id1", - "fields": ["field": "newValue"] + let insertRecords: [[String: Any]] = [ + ["table": "table", "fields": ["field": "value"]], + ["table": "table", "fields": ["field": "value2"]] ] - let collectCallback = CollectAPICallback( + let collectCallback = FlowVaultCollectAPICallback( callback: callback, apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), - records: ["update": ["id1": updateRecord]], - options: ICOptions(tokens: true, additionalFields: nil), + records: ["records": insertRecords], + options: FlowVaultICOptions(additionalFields: nil), contextOptions: ContextOptions() ) - // Simulate a successful update response - let updateResponseDict: [String: Any] = [ - "skyflow_id": "id1", - "tokens": ["field": "newValue"] - ] - let updateResponseData = try! JSONSerialization.data(withJSONObject: updateResponseDict, options: .fragmentsAllowed) - let updateUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/table/id1")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + let responseDict: [String: Any] = ["records": [ + ["skyflowID": NSNull(), "tokens": NSNull(), "data": NSNull(), "hashedData": NSNull(), + "error": "Invalid request. Table name table not present for record. Specify a valid table name.", + "httpCode": 400, "tableName": ""], + ["skyflowID": NSNull(), "tokens": NSNull(), "data": NSNull(), "hashedData": NSNull(), + "error": "Invalid request. Table name table not present for record. Specify a valid table name.", + "httpCode": 400, "tableName": ""] + ]] + let responseData = try! JSONSerialization.data(withJSONObject: responseDict, options: .fragmentsAllowed) + let urlResponse = HTTPURLResponse(url: URL(string: "https://example.org/v2/records/insert")!, statusCode: 400, httpVersion: "1.1", headerFields: nil) do { - let processedUpdate = try collectCallback.processUpdateResponse(data: updateResponseData, response: updateUrlResponse, error: nil, table: "table") - let updateRecords = processedUpdate["records"] as! [[String: Any]] - - XCTAssertEqual(updateRecords.count, 1) - XCTAssertEqual(updateRecords[0]["table"] as? String, "table") - let fields = updateRecords[0]["fields"] as? [String: String] - XCTAssertEqual(fields?["skyflow_id"], "id1") - XCTAssertEqual(fields?["field"], "newValue") + let processed = try collectCallback.processResponse(data: responseData, response: urlResponse, error: nil) + XCTAssertNil(processed["error"], "Should not collapse into a generic top-level error when the body has a records array") + let records = processed["records"] as! [[String: Any]] + + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records[0]["error"] as? String, "Invalid request. Table name table not present for record. Specify a valid table name.") + XCTAssertEqual(records[0]["httpCode"] as? Int, 400) } catch { - XCTFail("Update scenario failed: \(error)") + XCTFail("Full failure scenario should not throw: \(error)") } } - func testInsertAndUpdateSuccess() { - let expectation = XCTestExpectation(description: "Insert and update records should succeed") + func testInsertPartialFailureWith207OuterStatus() { + let expectation = XCTestExpectation(description: "Partial failure with 207 outer status should parse per-record errors") let callback = DemoAPICallback(expectation: expectation) - let insertRecord: [String: Any] = [ - "table": "table", - "fields": ["field": "value"] - ] - - let updateRecord: [String: Any] = [ - "table": "table", - "skyflowID": "id1", - "fields": ["field": "newValue"] + let insertRecords: [[String: Any]] = [ + ["table": "table2", "fields": ["address": "dedede", "gender": "sagar"]], + ["table": "table", "fields": ["field": "value"]] ] - let collectCallback = CollectAPICallback( + let collectCallback = FlowVaultCollectAPICallback( callback: callback, apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), - records: ["records": [insertRecord], "update": ["id1": updateRecord]], - options: ICOptions(tokens: true, additionalFields: nil), + records: ["records": insertRecords], + options: FlowVaultICOptions(additionalFields: nil), contextOptions: ContextOptions() ) - // Simulate a successful insert response -// let insertResponseDict: [String: Any] = [ -// "records": [["skyflow_id": "inserted_id"]] -// ] - let insertResponseDict = ["responses": [["records": [["skyflow_id": "inserted_id"]]], ["fields": ["field": "value"]]]] - - let insertResponseData = try! JSONSerialization.data(withJSONObject: insertResponseDict, options: .fragmentsAllowed) - let insertUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/vault")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) - - // Simulate a successful update response - let updateResponseDict: [String: Any] = [ - "skyflow_id": "id1", - "tokens": ["field": "newValue"] - ] - let updateResponseData = try! JSONSerialization.data(withJSONObject: updateResponseDict, options: .fragmentsAllowed) - let updateUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/table/id1")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + let responseDict: [String: Any] = ["records": [ + ["skyflowID": "b187b5b6-28b4-4881-93fa-4ef10e30b20e", "tokens": ["address": [["token": "dedwim", "tokenGroupName": "deterministic_string"]]], + "data": ["address": "dedede", "gender": "sagar"], "hashedData": [:], "error": NSNull(), "httpCode": 200, "tableName": "table2"], + ["skyflowID": NSNull(), "tokens": NSNull(), "data": NSNull(), "hashedData": NSNull(), + "error": "Invalid request. Table name table not present for record. Specify a valid table name.", + "httpCode": 400, "tableName": ""] + ]] + let responseData = try! JSONSerialization.data(withJSONObject: responseDict, options: .fragmentsAllowed) + let urlResponse = HTTPURLResponse(url: URL(string: "https://example.org/v2/records/insert")!, statusCode: 207, httpVersion: "1.1", headerFields: nil) do { - let processedInsert = try collectCallback.processResponse(data: insertResponseData, response: insertUrlResponse, error: nil) - let processedUpdate = try collectCallback.processUpdateResponse(data: updateResponseData, response: updateUrlResponse, error: nil, table: "table") - - let insertRecords = processedInsert["records"] as! [[String: Any]] - let updateRecords = processedUpdate["records"] as! [[String: Any]] + let processed = try collectCallback.processResponse(data: responseData, response: urlResponse, error: nil) + let records = processed["records"] as! [[String: Any]] - XCTAssertEqual(insertRecords.count, 1) - var ifields = insertRecords[0]["fields"] as? [String: String] - XCTAssertEqual(ifields?["skyflow_id"] as? String, "inserted_id") - - XCTAssertEqual(updateRecords.count, 1) - XCTAssertEqual(updateRecords[0]["table"] as? String, "table") - let fields = updateRecords[0]["fields"] as? [String: String] - XCTAssertEqual(fields?["skyflow_id"], "id1") - XCTAssertEqual(fields?["field"], "newValue") + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records[0]["skyflowID"] as? String, "b187b5b6-28b4-4881-93fa-4ef10e30b20e") + XCTAssertEqual(records[1]["httpCode"] as? Int, 400) } catch { - XCTFail("Insert and update scenario failed: \(error)") + XCTFail("Partial (207) scenario should not throw: \(error)") } } - func testInsertSuccessAndUpdateFailure() { - let expectation = XCTestExpectation(description: "Insert success and update failure") - let callback = DemoAPICallback(expectation: expectation) + func testUpdateSuccessResponseLiteral() { + let json = """ + { + "records": [ + { + "skyflowID": "f30c8ccf-7e86-46b4-be74-0b2db44e4b87", + "tokens": { + "email": [{"token": "a@ehmw.aqk", "tokenGroupName": "nondeterministic_string"}], + "name": [{"token": "sagwm", "tokenGroupName": "deterministic_string"}], + "passport": [{"token": "21284054160", "tokenGroupName": "deterministic_string"}] + }, + "data": {"email": "b@demo.com", "name": "sagar", "passport": "21212121212"}, + "hashedData": {}, + "error": null, + "httpCode": 200, + "tableName": "table1" + } + ] + } + """ + let responseData = json.data(using: .utf8)! + let urlResponse = HTTPURLResponse(url: URL(string: "https://example.org/v2/records/update")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + self.collectCallback.options = FlowVaultICOptions() - let insertRecord: [String: Any] = [ - "table": "table", - "fields": ["field": "value"] - ] + do { + let processed = try self.collectCallback.processResponse(data: responseData, response: urlResponse, error: nil) + let records = processed["records"] as! [[String: Any]] - let updateRecord: [String: Any] = [ - "table": "table", - "skyflowID": "id1", - "fields": ["field": "newValue"] - ] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["tableName"] as? String, "table1") + XCTAssertEqual(records[0]["skyflowID"] as? String, "f30c8ccf-7e86-46b4-be74-0b2db44e4b87") + let fields = records[0]["fields"] as! [String: Any] + XCTAssertNil(fields["data"]) + let nameTokens = fields["name"] as! [[String: Any]] + XCTAssertEqual(nameTokens[0]["token"] as? String, "sagwm") + } catch { + XCTFail("Update success response should not throw: \(error)") + } + } - let collectCallback = CollectAPICallback( - callback: callback, - apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), - records: ["records": [insertRecord], "update": ["id1": updateRecord]], - options: ICOptions(tokens: true, additionalFields: nil), - contextOptions: ContextOptions() - ) + func testUpdateFullFailureResponseLiteral() { + let json = """ + { + "records": [ + { + "skyflowID": null, "tokens": null, "data": null, "hashedData": null, + "error": "Invalid request. Table name table not present for record. Specify a valid table name.", + "httpCode": 400, "tableName": "" + }, + { + "skyflowID": null, "tokens": null, "data": null, "hashedData": null, + "error": "Invalid request. Table name table not present for record. Specify a valid table name.", + "httpCode": 400, "tableName": "" + } + ] + } + """ + let responseData = json.data(using: .utf8)! + let urlResponse = HTTPURLResponse(url: URL(string: "https://example.org/v2/records/update")!, statusCode: 400, httpVersion: "1.1", headerFields: nil) - // Simulate a successful insert response - let insertResponseDict = ["responses": [["records": [["skyflow_id": "inserted_id"]]], ["fields": ["field": "value"]]]] - let insertResponseData = try! JSONSerialization.data(withJSONObject: insertResponseDict, options: .fragmentsAllowed) - let insertUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/vault")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + do { + let processed = try self.collectCallback.processResponse(data: responseData, response: urlResponse, error: nil) + XCTAssertNil(processed["error"], "Should not collapse into a generic top-level error when the body has a records array") + let records = processed["records"] as! [[String: Any]] - // Simulate a failed update response - let updateError = NSError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: "Update failed"]) + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records[0]["error"] as? String, "Invalid request. Table name table not present for record. Specify a valid table name.") + } catch { + XCTFail("Update full failure response should not throw: \(error)") + } + } - do { - let processedInsert = try collectCallback.processResponse(data: insertResponseData, response: insertUrlResponse, error: nil) - let insertRecords = processedInsert["records"] as! [[String: Any]] + func testUpdatePartialFailureResponseLiteral() { + let json = """ + { + "records": [ + { + "skyflowID": "b187b5b6-28b4-4881-93fa-4ef10e30b20e", + "tokens": { + "address": [{"token": "dedwim", "tokenGroupName": "deterministic_string"}], + "gender": [{"token": "sagwm", "tokenGroupName": "deterministic_string"}] + }, + "data": {"address": "dedede", "gender": "sagar"}, + "hashedData": {}, + "error": null, + "httpCode": 200, + "tableName": "table2" + }, + { + "skyflowID": null, "tokens": null, "data": null, "hashedData": null, + "error": "Invalid request. Table name table not present for record. Specify a valid table name.", + "httpCode": 400, "tableName": "" + } + ] + } + """ + let responseData = json.data(using: .utf8)! + let urlResponse = HTTPURLResponse(url: URL(string: "https://example.org/v2/records/update")!, statusCode: 207, httpVersion: "1.1", headerFields: nil) + self.collectCallback.options = FlowVaultICOptions() - XCTAssertEqual(insertRecords.count, 1) - var fields = insertRecords[0]["fields"] as? [String: Any] - XCTAssertEqual(fields?["skyflow_id"] as? String, "inserted_id") + do { + let processed = try self.collectCallback.processResponse(data: responseData, response: urlResponse, error: nil) + let records = processed["records"] as! [[String: Any]] - let insertResponse = try collectCallback.processUpdateResponse(data: nil, response: nil, error: updateError, table: "table") as! [String: [String: String]] - XCTAssertEqual(insertResponse, ["error": ["message": "Update failed"]]) + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records[0]["skyflowID"] as? String, "b187b5b6-28b4-4881-93fa-4ef10e30b20e") + XCTAssertEqual(records[1]["httpCode"] as? Int, 400) } catch { - XCTFail("Insert success and update failure scenario failed: \(error)") + XCTFail("Update partial response should not throw: \(error)") } } - func testInsertFailureAndUpdateSuccess() { - let expectation = XCTestExpectation(description: "Insert failure and update success") - let callback = DemoAPICallback(expectation: expectation) + // The tests above all call processResponse(data:response:error:) directly, which only + // exercises parsing a single HTTP response body. When a request has BOTH inserts and + // updates, FlowVaultInsertAPICallback.onSuccess fires two REAL network calls (v2/records/insert + // and v2/records/update) and merges both results via a DispatchGroup - that merge logic can + // only be exercised by going through onSuccess with real URLSession dispatch, hence the + // URLProtocol mock here. + func testInsertAndUpdateResponsesMergeIntoOneCollectResponse() { + let mockConfiguration = URLSessionConfiguration.ephemeral + mockConfiguration.protocolClasses = [MockURLProtocol.self] + let originalConfiguration = FlowVaultInsertAPICallback.urlSessionConfiguration + FlowVaultInsertAPICallback.urlSessionConfiguration = mockConfiguration + defer { FlowVaultInsertAPICallback.urlSessionConfiguration = originalConfiguration } + + MockURLProtocol.requestHandler = { request in + let url = request.url!.absoluteString + let body: [String: Any] + if url.contains("v2/records/insert") { + body = ["records": [["skyflowID": "insertedId", "tableName": "cards", "tokens": ["card_number": "value"], "httpCode": 200]]] + } else if url.contains("v2/records/update") { + body = ["records": [["skyflowID": "id1", "tableName": "persons", "tokens": [:], "httpCode": 200]]] + } else { + XCTFail("Unexpected URL: \(url)") + body = ["records": []] + } + let data = try! JSONSerialization.data(withJSONObject: body) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "1.1", headerFields: nil)! + return (response, data) + } - let insertRecord: [String: Any] = [ - "table": "table", - "fields": ["field": "value"] - ] + let expectation = XCTestExpectation(description: "Merged insert+update response") + let callback = DemoAPICallback(expectation: expectation) - let updateRecord: [String: Any] = [ - "table": "table", - "skyflowID": "id1", - "fields": ["field": "newValue"] + let records: [String: Any] = [ + "records": [["table": "cards", "fields": ["card_number": "4111"]]], + "update": ["id1": ["table": "persons", "fields": ["name": "John"]]] ] - let collectCallback = CollectAPICallback( - callback: callback, + let insertApiCallback = FlowVaultInsertAPICallback( + callback: callback.asCollectCallback, apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), - records: ["records": [insertRecord], "update": ["id1": updateRecord]], - options: ICOptions(tokens: true, additionalFields: nil), + records: records, + options: FlowVaultICOptions(), contextOptions: ContextOptions() ) + // onSuccess's parameter here is the bearer token from TokenAPICallback - unused by this + // method's own logic (it reads self.records instead), so any value triggers the real flow. + insertApiCallback.onSuccess("dummy-token") - // Simulate a failed insert response - let insertErrorResponseDict: [String: Any] = [ - "error": ["message": "Insert failed"] - ] - let insertErrorResponseData = try! JSONSerialization.data(withJSONObject: insertErrorResponseDict, options: .fragmentsAllowed) - let insertErrorUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/vault")!, statusCode: 404, httpVersion: "1.1", headerFields: nil) - - // Simulate a successful update response - let updateResponseDict: [String: Any] = [ - "skyflow_id": "id1", - "tokens": ["field": "newValue"] - ] - let updateResponseData = try! JSONSerialization.data(withJSONObject: updateResponseDict, options: .fragmentsAllowed) - let updateUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/table/id1")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + wait(for: [expectation], timeout: 10.0) - do { - let insertProccessed = try collectCallback.processResponse(data: insertErrorResponseData, response: insertErrorUrlResponse, error: nil) as! [String: [String: Any]] - XCTAssertEqual(insertProccessed["error"]?["message"] as? String, "Insert failed") - XCTAssertEqual(insertProccessed["error"]?["code"] as? Int, 404) - - let processedUpdate = try collectCallback.processUpdateResponse(data: updateResponseData, response: updateUrlResponse, error: nil, table: "table") - let updateRecords = processedUpdate["records"] as! [[String: Any]] - - XCTAssertEqual(updateRecords.count, 1) - XCTAssertEqual(updateRecords[0]["table"] as? String, "table") - let fields = updateRecords[0]["fields"] as? [String: String] - XCTAssertEqual(fields?["skyflow_id"], "id1") - XCTAssertEqual(fields?["field"], "newValue") - } catch { - XCTFail("Insert failure and update success scenario failed: \(error)") + guard let collectResponse = callback.collectResponse else { + XCTFail("Expected a merged CollectResponse, got: receivedResponse=\(callback.receivedResponse) data=\(callback.data)") + return } + XCTAssertEqual(collectResponse.records.count, 2) + XCTAssertTrue(collectResponse.records.contains { $0.tableName == "cards" && $0.skyflowId == "insertedId" }) + XCTAssertTrue(collectResponse.records.contains { $0.tableName == "persons" && $0.skyflowId == "id1" }) } - func testInsertFailureAndUpdateFailure() { - let expectation = XCTestExpectation(description: "Insert failure and update failure") - let callback = DemoAPICallback(expectation: expectation) - let insertRecord: [String: Any] = [ - "table": "table", - "fields": ["field": "value"] - ] + func testInsertSucceedsButUpdateFailsSurfacesAsSkyflowError() { + // One side of the merge fails outright (not a per-record error, a whole-side failure) - + // the merge should surface a proper Skyflow.SkyflowError with the real message, not a + // stringified dump of the internal {"records", "errors"} merge container. + let mockConfiguration = URLSessionConfiguration.ephemeral + mockConfiguration.protocolClasses = [MockURLProtocol.self] + let originalConfiguration = FlowVaultInsertAPICallback.urlSessionConfiguration + FlowVaultInsertAPICallback.urlSessionConfiguration = mockConfiguration + defer { FlowVaultInsertAPICallback.urlSessionConfiguration = originalConfiguration } + + MockURLProtocol.requestHandler = { request in + let url = request.url!.absoluteString + if url.contains("v2/records/insert") { + let body: [String: Any] = ["records": [["skyflowID": "insertedId", "tableName": "cards", "tokens": ["card_number": "value"], "httpCode": 200]]] + let data = try! JSONSerialization.data(withJSONObject: body) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "1.1", headerFields: nil)! + return (response, data) + } else if url.contains("v2/records/update") { + let body: [String: Any] = ["error": ["message": "Update service unavailable", "httpCode": 503]] + let data = try! JSONSerialization.data(withJSONObject: body) + let response = HTTPURLResponse(url: request.url!, statusCode: 503, httpVersion: "1.1", headerFields: nil)! + return (response, data) + } + XCTFail("Unexpected URL: \(url)") + return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: "1.1", headerFields: nil)!, Data()) + } - let updateRecord: [String: Any] = [ - "table": "table", - "skyflowID": "id1", - "fields": ["field": "newValue"] + let expectation = XCTestExpectation(description: "Update-side failure surfaces as SkyflowError") + let callback = DemoAPICallback(expectation: expectation) + + let records: [String: Any] = [ + "records": [["table": "cards", "fields": ["card_number": "4111"]]], + "update": ["id1": ["table": "persons", "fields": ["name": "John"]]] ] - let collectCallback = CollectAPICallback( - callback: callback, + let insertApiCallback = FlowVaultInsertAPICallback( + callback: callback.asCollectCallback, apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), - records: ["records": [insertRecord], "update": ["id1": updateRecord]], - options: ICOptions(tokens: true, additionalFields: nil), + records: records, + options: FlowVaultICOptions(), contextOptions: ContextOptions() ) + insertApiCallback.onSuccess("dummy-token") - // Simulate a failed insert response - let insertErrorResponseDict: [String: Any] = [ - "error": ["message": "Insert failed"] - ] - let insertErrorResponseData = try! JSONSerialization.data(withJSONObject: insertErrorResponseDict, options: .fragmentsAllowed) - let insertErrorUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/vault")!, statusCode: 404, httpVersion: "1.1", headerFields: nil) + wait(for: [expectation], timeout: 10.0) - // Simulate a successful update response - let updateResponseDict: [String: Any] = [ - "skyflow_id": "id1", - "tokens": ["field": "newValue"] - ] - let updateErrorResponseDict: [String: Any] = [ - "error": ["message": "Update failed"] - ] - let updateResponseData = try! JSONSerialization.data(withJSONObject: updateErrorResponseDict, options: .fragmentsAllowed) - let updateUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/table/id1")!, statusCode: 404, httpVersion: "1.1", headerFields: nil) + XCTAssertEqual(callback.receivedResponse, "Update service unavailable") + } - do { - let insertProccessed = try collectCallback.processResponse(data: insertErrorResponseData, response: insertErrorUrlResponse, error: nil) as! [String: [String: Any]] - XCTAssertEqual(insertProccessed["error"]?["message"] as? String, "Insert failed") - XCTAssertEqual(insertProccessed["error"]?["code"] as? Int, 404) + func testCollectRecordHttpCodeDefaultsToZeroWhenMissing() { + // httpCode is declared non-optional (Int, not Int?) - verify a dict lacking the key + // doesn't crash and just defaults to 0 instead. + let record = CollectRecord(["tableName": "persons", "skyflowID": "id1"]) + XCTAssertEqual(record.httpCode, 0) + XCTAssertNil(record.error) + XCTAssertEqual(record.tableName, "persons") + } - let processedUpdate = try collectCallback.processUpdateResponse(data: updateResponseData, response: updateUrlResponse, error: nil, table: "table") as! [String: [String: Any]] + func testCollectRecordErrorDiscriminatesSuccessFromFailure() { + let success = CollectRecord(["tableName": "persons", "httpCode": 200]) + let failure = CollectRecord(["error": "Invalid request", "httpCode": 400]) - XCTAssertEqual(processedUpdate["error"]?["message"] as? String, "Update failed") - XCTAssertEqual(processedUpdate["error"]?["code"] as? Int, 404) + XCTAssertNil(success.error) + XCTAssertEqual(failure.error, "Invalid request") + XCTAssertEqual(failure.httpCode, 400) + } - } catch { - XCTFail("Insert failure and update success scenario failed: \(error)") - } + func testCollectResponseInitReturnsNilForMalformedBody() { + XCTAssertNil(CollectResponse("not a dictionary")) + XCTAssertNil(CollectResponse(["typo": []])) + XCTAssertNil(CollectResponse(["records": "not an array"])) + } + + func testCollectResponseInitParsesValidBody() { + let response = CollectResponse([ + "records": [ + ["tableName": "persons", "skyflowID": "id1", "httpCode": 200], + ["error": "failed", "httpCode": 400] + ] + ]) + XCTAssertEqual(response?.records.count, 2) + XCTAssertNil(response?.records[0].error) + XCTAssertEqual(response?.records[1].error, "failed") } } diff --git a/Tests/skyflow-iOS-utilTests/skyflow_iOS_cvvMockTests.swift b/Tests/skyflow-iOS-utilTests/skyflow_iOS_cvvMockTests.swift new file mode 100644 index 00000000..751f13ee --- /dev/null +++ b/Tests/skyflow-iOS-utilTests/skyflow_iOS_cvvMockTests.swift @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +import XCTest +@testable import Skyflow + +final class skyflow_iOS_cvvMockTests: XCTestCase { + + // MARK: - CVVMockGenerator + + func testGeneratedMockMatchesRequestedLength() { + for length in [3, 4] { + let mock = CVVMockGenerator.generateMockCVV(length: length, actualValue: "000") + XCTAssertEqual(mock.count, length) + } + } + + func testGeneratedMockIsNumeric() { + let mock = CVVMockGenerator.generateMockCVV(length: 4, actualValue: "1234") + XCTAssertNotNil(Int(mock)) + } + + func testGeneratedMockNeverEqualsActualValue() { + // Run repeatedly since generation is random - none of the draws should ever match. + for _ in 0..<200 { + let mock = CVVMockGenerator.generateMockCVV(length: 3, actualValue: "123") + XCTAssertNotEqual(mock, "123") + } + } + + func testGeneratedMockAllowsLeadingZeros() { + // A 3-digit space has only 1000 possibilities; forcing actualValue to something that + // can't collide keeps this deterministic while still exercising the "starts with 0" case + // over many draws. + var sawLeadingZero = false + for _ in 0..<500 where !sawLeadingZero { + let mock = CVVMockGenerator.generateMockCVV(length: 3, actualValue: "999") + if mock.hasPrefix("0") { + sawLeadingZero = true + } + } + XCTAssertTrue(sawLeadingZero) + } + + // MARK: - CVVTokenReplacer.captureCVVMap + + private func makeCVVElement(table: String, column: String, value: String, skyflowId: String? = nil) -> TextField { + let input = CollectElementInput(tableName: table, column: column, type: .CVV, skyflowId: skyflowId) + let field = TextField(input: input, options: CollectElementOptions(), contextOptions: ContextOptions(), elements: []) + field.actualValue = value + return field + } + + private func makeInputFieldElement(table: String, column: String, value: String) -> TextField { + let input = CollectElementInput(tableName: table, column: column, type: .INPUT_FIELD) + let field = TextField(input: input, options: CollectElementOptions(), contextOptions: ContextOptions(), elements: []) + field.actualValue = value + return field + } + + func testCaptureOnlyPicksUpCVVElements() { + let cvv = makeCVVElement(table: "persons", column: "cvv", value: "123") + let name = makeInputFieldElement(table: "persons", column: "name", value: "John") + + let map = CVVTokenReplacer.captureCVVMap(elements: [cvv, name]) + + XCTAssertEqual(map.byTable["persons"]?["cvv"], "123") + XCTAssertNil(map.byTable["persons"]?["name"]) + } + + func testCaptureKeysInsertsByTableAndUpdatesByRecordId() { + let insertCVV = makeCVVElement(table: "persons", column: "cvv", value: "123") + let updateCVV = makeCVVElement(table: "persons", column: "cvv", value: "456", skyflowId: "SID1") + + let map = CVVTokenReplacer.captureCVVMap(elements: [insertCVV, updateCVV]) + + XCTAssertEqual(map.byTable["persons"]?["cvv"], "123") + XCTAssertEqual(map.byRecordId["SID1"]?["cvv"], "456") + } + + func testCaptureFirstElementWinsOnDuplicateColumn() { + // Mirrors FlowVaultCollectRequestBody's own dedup rule (e.g. ElementValueMatchRule): + // when two elements target the same column, only the first one's value reaches the vault. + let first = makeCVVElement(table: "persons", column: "cvv", value: "111") + let second = makeCVVElement(table: "persons", column: "cvv", value: "222") + + let map = CVVTokenReplacer.captureCVVMap(elements: [first, second]) + + XCTAssertEqual(map.byTable["persons"]?["cvv"], "111") + } + + func testCaptureStillCapturesEmptyValue() { + // An optional CVV element left blank still submits "" to the vault and can get a real + // token back - it must still be captured so that token gets masked too. + let cvv = makeCVVElement(table: "persons", column: "cvv", value: "") + let map = CVVTokenReplacer.captureCVVMap(elements: [cvv]) + XCTAssertEqual(map.byTable["persons"]?["cvv"], "") + } + + func testReplaceFlatColumnWithEmptyEnteredValueBecomesEmptyString() { + let cvvMap = CVVCaptureMap(byTable: ["persons": ["cvv": ""]], byRecordId: [:]) + let records: [[String: Any]] = [[ + "tableName": "persons", + "fields": [ + "cvv": [["token": "real-token-for-empty-value", "tokenGroupName": "deterministic"]], + "name": [["token": "name-token", "tokenGroupName": "deterministic"]] + ] + ]] + + let result = CVVTokenReplacer.replaceCVVTokens(in: records, cvvMap: cvvMap) + let fields = result[0]["fields"] as! [String: Any] + let cvvEntries = fields["cvv"] as! [[String: Any]] + let nameEntries = fields["name"] as! [[String: Any]] + + // A real (long) vault token no longer leaks through unmasked just because the field + // was left blank - it's replaced with "", never a generated mock (generating one for a + // length-0 entered value would never terminate, see CVVMockGenerator's guard). + XCTAssertEqual(cvvEntries[0]["token"] as! String, "") + // Sibling column untouched. + XCTAssertEqual(nameEntries[0]["token"] as! String, "name-token") + } + + func testReplaceNestedColumnWithEmptyEnteredValueBecomesEmptyString() { + let cvvMap = CVVCaptureMap(byTable: ["persons": ["address.pincode": ""]], byRecordId: [:]) + let records: [[String: Any]] = [[ + "tableName": "persons", + "fields": [ + "address": [ + ["token": "whole-column-token", "tokenGroupName": "deterministic"], + ["path": "pincode", "token": "real-pincode-token", "tokenGroupName": "deterministic"], + ["path": "city", "token": "city-token", "tokenGroupName": "deterministic"] + ] + ] + ]] + + let result = CVVTokenReplacer.replaceCVVTokens(in: records, cvvMap: cvvMap) + let addressEntries = (result[0]["fields"] as! [String: Any])["address"] as! [[String: Any]] + + let wholeColumn = addressEntries.first { $0["path"] == nil }! + let pincode = addressEntries.first { $0["path"] as? String == "pincode" }! + let city = addressEntries.first { $0["path"] as? String == "city" }! + + // Only the "pincode" leaf becomes "" - parent/sibling entries are untouched. + XCTAssertEqual(pincode["token"] as! String, "") + XCTAssertEqual(wholeColumn["token"] as! String, "whole-column-token") + XCTAssertEqual(city["token"] as! String, "city-token") + } + + func testGeneratedMockWithZeroLengthReturnsEmptyStringWithoutLooping() { + // Guards against the hazard directly: length 0 with actualValue "" would make the + // "regenerate until different" loop never terminate if this guard weren't there, + // since every candidate is "" and "" always equals the empty actualValue. + let mock = CVVMockGenerator.generateMockCVV(length: 0, actualValue: "") + XCTAssertEqual(mock, "") + } + + private func makePINElement(table: String, column: String, value: String) -> TextField { + let input = CollectElementInput(tableName: table, column: column, type: .PIN) + let field = TextField(input: input, options: CollectElementOptions(), contextOptions: ContextOptions(), elements: []) + field.actualValue = value + return field + } + + // A PIN element is never masked, even on a column literally named "pincode" (a postal-code + // subfield, unrelated to ElementType.PIN) or sharing a table with a real CVV element. + func testCaptureAndReplaceNeverTouchPINElements() { + let cvv = makeCVVElement(table: "nested", column: "card.cvv", value: "123") + let pin = makePINElement(table: "nested", column: "address.pincode", value: "5000") + + let map = CVVTokenReplacer.captureCVVMap(elements: [cvv, pin]) + XCTAssertNil(map.byTable["nested"]?["address.pincode"]) + XCTAssertEqual(map.byTable["nested"]?["card.cvv"], "123") + + let records: [[String: Any]] = [[ + "tableName": "nested", + "fields": [ + "card": [["path": "cvv", "token": "real-cvv-token", "tokenGroupName": "deterministic"]], + "address": [["path": "pincode", "token": "real-pincode-token", "tokenGroupName": "deterministic"]] + ] + ]] + + let result = CVVTokenReplacer.replaceCVVTokens(in: records, cvvMap: map) + let fields = result[0]["fields"] as! [String: Any] + let cardEntries = fields["card"] as! [[String: Any]] + let addressEntries = fields["address"] as! [[String: Any]] + + XCTAssertNotEqual(cardEntries[0]["token"] as! String, "real-cvv-token") + // PIN's "pincode" token is untouched. + XCTAssertEqual(addressEntries[0]["token"] as! String, "real-pincode-token") + } + + // MARK: - CVVTokenReplacer.replaceCVVTokens + + func testReplaceFlatColumnSingleTokenGroup() { + let cvvMap = CVVCaptureMap(byTable: ["persons": ["cvv": "123"]], byRecordId: [:]) + let records: [[String: Any]] = [[ + "tableName": "persons", + "skyflowID": "SID1", + "fields": [ + "cvv": [["token": "real-token", "tokenGroupName": "deterministic"]], + "name": [["token": "name-token", "tokenGroupName": "deterministic"]] + ] + ]] + + let result = CVVTokenReplacer.replaceCVVTokens(in: records, cvvMap: cvvMap) + let fields = result[0]["fields"] as! [String: Any] + let cvvEntries = fields["cvv"] as! [[String: Any]] + let nameEntries = fields["name"] as! [[String: Any]] + + XCTAssertEqual(cvvEntries.count, 1) + XCTAssertNotEqual(cvvEntries[0]["token"] as! String, "real-token") + XCTAssertEqual((cvvEntries[0]["token"] as! String).count, 3) + // Non-CVV column untouched. + XCTAssertEqual(nameEntries[0]["token"] as! String, "name-token") + } + + func testReplaceFlatColumnMultipleTokenGroups() { + let cvvMap = CVVCaptureMap(byTable: ["persons": ["cvv": "1234"]], byRecordId: [:]) + let records: [[String: Any]] = [[ + "tableName": "persons", + "fields": [ + "cvv": [ + ["token": "det-token", "tokenGroupName": "deterministic"], + ["token": "nondet-token", "tokenGroupName": "nondeterministic"] + ] + ] + ]] + + let result = CVVTokenReplacer.replaceCVVTokens(in: records, cvvMap: cvvMap) + let cvvEntries = (result[0]["fields"] as! [String: Any])["cvv"] as! [[String: Any]] + + XCTAssertEqual(cvvEntries.count, 2) + let mock1 = cvvEntries[0]["token"] as! String + let mock2 = cvvEntries[1]["token"] as! String + // Both token-group entries for the same column get the same mock. + XCTAssertEqual(mock1, mock2) + XCTAssertNotEqual(mock1, "1234") + } + + func testReplaceNestedColumnOnlyTouchesMatchingPath() { + let cvvMap = CVVCaptureMap(byTable: ["nested": ["address.pincode": "9876"]], byRecordId: [:]) + let records: [[String: Any]] = [[ + "tableName": "nested", + "fields": [ + "address": [ + ["token": "whole-column-token", "tokenGroupName": "deterministic"], + ["path": "pincode", "token": "pincode-token", "tokenGroupName": "deterministic"], + ["path": "city", "token": "city-token", "tokenGroupName": "deterministic"] + ] + ] + ]] + + let result = CVVTokenReplacer.replaceCVVTokens(in: records, cvvMap: cvvMap) + let addressEntries = (result[0]["fields"] as! [String: Any])["address"] as! [[String: Any]] + + let wholeColumn = addressEntries.first { $0["path"] == nil }! + let pincode = addressEntries.first { $0["path"] as? String == "pincode" }! + let city = addressEntries.first { $0["path"] as? String == "city" }! + + // Only the "pincode" subfield is mocked. + XCTAssertEqual(wholeColumn["token"] as! String, "whole-column-token") + XCTAssertNotEqual(pincode["token"] as! String, "pincode-token") + XCTAssertEqual(city["token"] as! String, "city-token") + } + + func testReplaceMatchesUpdateByRecordIdBeforeInsertByTable() { + let cvvMap = CVVCaptureMap(byTable: ["persons": ["cvv": "111"]], byRecordId: ["SID1": ["cvv": "222"]]) + let records: [[String: Any]] = [[ + "tableName": "persons", + "skyflowID": "SID1", + "fields": ["cvv": [["token": "real-token", "tokenGroupName": "deterministic"]]] + ]] + + let result = CVVTokenReplacer.replaceCVVTokens(in: records, cvvMap: cvvMap) + let cvvEntries = (result[0]["fields"] as! [String: Any])["cvv"] as! [[String: Any]] + + // Matched via byRecordId (length of captured "222" is 3), not byTable's "111". + XCTAssertEqual((cvvEntries[0]["token"] as! String).count, 3) + } + + func testReplaceLeavesHashedDataAndErrorRecordsUntouched() { + let cvvMap = CVVCaptureMap(byTable: ["persons": ["cvv": "123"]], byRecordId: [:]) + let records: [[String: Any]] = [ + [ + "tableName": "persons", + "fields": ["cvv": [["token": "real-token", "tokenGroupName": "deterministic"]]], + "hashedData": ["cvv": [["data": "hash", "hashName": "sha256"]]] + ], + [ + "error": "some error", + "tableName": "persons" + ] + ] + + let result = CVVTokenReplacer.replaceCVVTokens(in: records, cvvMap: cvvMap) + + let hashedData = result[0]["hashedData"] as! [String: Any] + let hashEntries = hashedData["cvv"] as! [[String: Any]] + XCTAssertEqual(hashEntries[0]["data"] as! String, "hash") + + XCTAssertEqual(result[1]["error"] as! String, "some error") + XCTAssertNil(result[1]["fields"]) + } + + func testReplaceIsNoOpWhenNoCVVCaptured() { + let records: [[String: Any]] = [[ + "tableName": "persons", + "fields": ["cvv": [["token": "real-token", "tokenGroupName": "deterministic"]]] + ]] + + let result = CVVTokenReplacer.replaceCVVTokens(in: records, cvvMap: CVVCaptureMap()) + let cvvEntries = (result[0]["fields"] as! [String: Any])["cvv"] as! [[String: Any]] + XCTAssertEqual(cvvEntries[0]["token"] as! String, "real-token") + } + + // MARK: - CVVMaskingCallback + + func testMaskingCallbackMasksOnSuccessRecords() { + let cvvMap = CVVCaptureMap(byTable: ["persons": ["cvv": "123"]], byRecordId: [:]) + let expectation = XCTestExpectation(description: "onSuccess forwarded") + let demo = DemoAPICallback(expectation: expectation) + let masking = CVVMaskingCallback(cvvMap: cvvMap, wrapping: demo) + + masking.onSuccess([ + "records": [[ + "tableName": "persons", + "fields": ["cvv": [["token": "real-token", "tokenGroupName": "deterministic"]]] + ]] + ]) + + wait(for: [expectation], timeout: 5.0) + let records = demo.data["records"] as! [[String: Any]] + let cvvEntries = (records[0]["fields"] as! [String: Any])["cvv"] as! [[String: Any]] + XCTAssertNotEqual(cvvEntries[0]["token"] as! String, "real-token") + } + + func testMaskingCallbackMasksPartialFailureRecords() { + let cvvMap = CVVCaptureMap(byTable: ["persons": ["cvv": "123"]], byRecordId: [:]) + let expectation = XCTestExpectation(description: "onFailure forwarded") + let demo = DemoAPICallback(expectation: expectation) + let masking = CVVMaskingCallback(cvvMap: cvvMap, wrapping: demo) + + masking.onFailure([ + "records": [[ + "tableName": "persons", + "fields": ["cvv": [["token": "real-token", "tokenGroupName": "deterministic"]]] + ]], + "errors": [["error": "some other record failed"]] + ]) + + wait(for: [expectation], timeout: 5.0) + let records = demo.data["records"] as! [[String: Any]] + let cvvEntries = (records[0]["fields"] as! [String: Any])["cvv"] as! [[String: Any]] + XCTAssertNotEqual(cvvEntries[0]["token"] as! String, "real-token") + } + + // Note: a true end-to-end test through CollectContainer.collect()/ComposableContainer.collect() + // (real network dispatch) isn't reachable from a unit test here: FlowVaultCollectAPICallback + // always builds a fresh URLSession(configuration: .default), and URLProtocol.registerClass + // only reliably intercepts URLSession.shared, not ad-hoc .default sessions - confirmed by this + // failing against the real network (github.com/.../example.org) rather than the mock handler. + // Making that interceptable would require adding a test-only seam to production SDK code + // (e.g. FlowVaultInsertAPICallback's injectable urlSessionConfiguration), which is out of + // scope here. The wiring itself (cvvMap capture + CVVMaskingCallback insertion in + // CollectContainer.swift/ComposableContainer.swift) is two lines per container and is + // exercised for real via the NormalTesting sample app's CVV Mock Scenarios screen against a + // live vault; the masking logic itself is fully covered above without needing the network. +} diff --git a/Tests/skyflow-iOS-utilTests/skyflow_iOS_dormantCollectApiCallbackTests.swift b/Tests/skyflow-iOS-utilTests/skyflow_iOS_dormantCollectApiCallbackTests.swift new file mode 100644 index 00000000..9da2f33e --- /dev/null +++ b/Tests/skyflow-iOS-utilTests/skyflow_iOS_dormantCollectApiCallbackTests.swift @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Unit tests for the dormant v1 PDB CollectAPICallback/InsertAPICallback (kept for potential +// future PDB reuse). + +import XCTest +@testable import Skyflow + +final class skyflow_iOS_dormantCollectApiCallbackTests: XCTestCase { + var collectCallback: CollectAPICallback! + var defaultRecord: [String: Any] = ["records": [["table": "table", "fields": ["field": "value"]]]] + + override func setUp() { + self.collectCallback = CollectAPICallback( + callback: DemoAPICallback(expectation: XCTestExpectation()), + apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), + records: defaultRecord, + options: ICOptions(tokens: false), + contextOptions: ContextOptions() + ) + } + + func testBuildFieldsDict() { + let dict = ["key": "value", "nested": ["key": "value"]] as [String: Any] + let result = self.collectCallback.buildFieldsDict(dict: dict) + XCTAssertEqual(dict["key"] as! String, result["key"] as! String) + XCTAssertEqual(dict["nested"] as! [String: String], result["nested"] as! [String: String]) + } + + func testOnSuccessWithNoInsertAndNoUpdateRecords() { + // No network call should be made at all when there's nothing to insert or update. + let expectation = XCTestExpectation(description: "Empty batch should succeed with an empty response") + let callback = DemoAPICallback(expectation: expectation) + let emptyCallback = CollectAPICallback( + callback: callback, + apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), + records: ["records": []], + options: ICOptions(tokens: false), + contextOptions: ContextOptions() + ) + + emptyCallback.onSuccess("token") + wait(for: [expectation], timeout: 10.0) + + XCTAssertTrue(callback.data.isEmpty) + } + + func testGetRequestSession() { + let url = URL(string: "https://example.org")! + do { + let (request, session) = try self.collectCallback.getRequestSession(url: url) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "Bearer ") + let body = try JSONSerialization.jsonObject(with: request.httpBody!, options: .allowFragments) as! [String: Any] + let records = body["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["tableName"] as? String, "table") + XCTAssertNotNil(session) + } catch { + XCTFail(error.localizedDescription) + } + } + + func testProcessResponseNetworkError() { + let networkError = NSError(domain: "NSURLErrorDomain", code: -1009, userInfo: [NSLocalizedDescriptionKey: "offline"]) + do { + let result = try self.collectCallback.processResponse(data: nil, response: nil, error: networkError) + let errorDict = result["error"] as! [String: Any] + XCTAssertEqual(errorDict["message"] as? String, "offline") + } catch { + XCTFail("Should not throw for a connection-level error: \(error)") + } + } + + func testProcessResponseBadStatusCode() { + let responseDict = ["error": ["message": "Internal Server Error"]] + do { + let data = try JSONSerialization.data(withJSONObject: responseDict, options: .fragmentsAllowed) + let httpResponse = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 500, httpVersion: "1.1", headerFields: ["x-request-id": "RID"]) + let result = try self.collectCallback.processResponse(data: data, response: httpResponse, error: nil) + let errorDict = result["error"] as! [String: Any] + XCTAssertEqual(errorDict["message"] as? String, "Internal Server Error - request-id: RID") + } catch { + XCTFail(error.localizedDescription) + } + } + + func testGetCollectResponseBody() { + // Old v1 gateway-batch response shape: {"responses": [{"records": [{"skyflow_id": ...}]}]} + // getCollectResponseBody walks self.records["records"] (the INPUT records), reading the + // matching entry out of the responses array at the same index - so the input record count + // must match, not the responses array's own length. + let twoRecordCallback = CollectAPICallback( + callback: DemoAPICallback(expectation: XCTestExpectation()), + apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), + records: ["records": [["table": "table1", "fields": ["field": "value1"]], ["table": "table2", "fields": ["field": "value2"]]]], + options: ICOptions(tokens: false), + contextOptions: ContextOptions() + ) + let responseDict: [String: Any] = [ + "responses": [ + ["records": [["skyflow_id": "SID1"]]], + ["records": [["skyflow_id": "SID2"]]] + ] + ] + do { + let data = try JSONSerialization.data(withJSONObject: responseDict, options: .fragmentsAllowed) + let result = try twoRecordCallback.getCollectResponseBody(data: data) + let records = result["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records[0]["table"] as? String, "table1") + XCTAssertEqual(records[0]["skyflow_id"] as? String, "SID1") + XCTAssertEqual(records[1]["table"] as? String, "table2") + XCTAssertEqual(records[1]["skyflow_id"] as? String, "SID2") + } catch { + XCTFail(error.localizedDescription) + } + } + + func testProcessUpdateResponseSuccess() { + let responseDict: [String: Any] = ["skyflow_id": "SID1"] + do { + let data = try JSONSerialization.data(withJSONObject: responseDict, options: .fragmentsAllowed) + let httpResponse = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + let result = try self.collectCallback.processUpdateResponse(data: data, response: httpResponse, error: nil, table: "table") + let records = result["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["skyflow_id"] as? String, "SID1") + XCTAssertEqual(records[0]["table"] as? String, "table") + } catch { + XCTFail(error.localizedDescription) + } + } + + func testProcessUpdateResponseBadStatusCode() { + let responseDict = ["error": ["message": "Record not found"]] + do { + let data = try JSONSerialization.data(withJSONObject: responseDict, options: .fragmentsAllowed) + let httpResponse = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 404, httpVersion: "1.1", headerFields: nil) + let result = try self.collectCallback.processUpdateResponse(data: data, response: httpResponse, error: nil, table: "table") + let errorDict = result["error"] as! [String: Any] + XCTAssertEqual(errorDict["message"] as? String, "Record not found") + } catch { + XCTFail(error.localizedDescription) + } + } + + func testOnFailure() { + let expectation = XCTestExpectation() + let callback = DemoAPICallback(expectation: expectation) + self.collectCallback.callback = callback + + self.collectCallback.onFailure(["errors": [["error": "boom"]]]) + wait(for: [expectation], timeout: 10.0) + + XCTAssertNotNil(callback.data["errors"]) + } +} + +final class skyflow_iOS_dormantInsertApiCallbackTests: XCTestCase { + var insertCallback: InsertAPICallback! + var defaultRecord: [String: Any] = ["records": [["table": "table", "fields": ["field": "value"]]]] + + override func setUp() { + self.insertCallback = InsertAPICallback( + callback: DemoAPICallback(expectation: XCTestExpectation()), + apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), + records: defaultRecord, + options: ICOptions(tokens: false), + contextOptions: ContextOptions() + ) + } + + func testBuildFieldsDict() { + let dict = ["key": "value", "nested": ["key": "value"]] as [String: Any] + let result = self.insertCallback.buildFieldsDict(dict: dict) + XCTAssertEqual(dict["key"] as! String, result["key"] as! String) + XCTAssertEqual(dict["nested"] as! [String: String], result["nested"] as! [String: String]) + } + + func testGetRequestSession() { + let url = URL(string: "https://example.org")! + do { + let (request, session) = try self.insertCallback.getRequestSession(url: url) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "Bearer ") + let body = try JSONSerialization.jsonObject(with: request.httpBody!, options: .allowFragments) as! [String: Any] + let records = body["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["tableName"] as? String, "table") + XCTAssertNotNil(session) + } catch { + XCTFail(error.localizedDescription) + } + } + + func testProcessResponseNetworkErrorThrows() { + let networkError = NSError(domain: "NSURLErrorDomain", code: -1009, userInfo: [NSLocalizedDescriptionKey: "offline"]) + do { + _ = try self.insertCallback.processResponse(data: nil, response: nil, error: networkError) + XCTFail("Should throw on a connection-level error") + } catch { + XCTAssertEqual((error as NSError).code, -1009) + } + } + + func testProcessResponseBadStatusCodeThrows() { + let responseDict = ["error": ["message": "Internal Server Error"]] + do { + let data = try JSONSerialization.data(withJSONObject: responseDict, options: .fragmentsAllowed) + let httpResponse = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 500, httpVersion: "1.1", headerFields: ["x-request-id": "RID"]) + _ = try self.insertCallback.processResponse(data: data, response: httpResponse, error: nil) + XCTFail("Should throw on a bad status code") + } catch { + XCTAssertEqual(error.localizedDescription, "Internal Server Error - request-id: RID") + } + } + + func testGetCollectResponseBody() { + let responseDict: [String: Any] = [ + "responses": [ + ["records": [["skyflow_id": "SID1"]]] + ] + ] + do { + let data = try JSONSerialization.data(withJSONObject: responseDict, options: .fragmentsAllowed) + let result = try self.insertCallback.getCollectResponseBody(data: data) + let records = result["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["table"] as? String, "table") + XCTAssertEqual(records[0]["skyflow_id"] as? String, "SID1") + } catch { + XCTFail(error.localizedDescription) + } + } + + func testOnFailure() { + let expectation = XCTestExpectation() + let callback = DemoAPICallback(expectation: expectation) + self.insertCallback.callback = callback + + self.insertCallback.onFailure(["errors": [["error": "boom"]]]) + wait(for: [expectation], timeout: 10.0) + + XCTAssertNotNil(callback.data["errors"]) + } +} diff --git a/Tests/skyflow-iOS-utilTests/skyflow_iOS_dormantCollectRequestBodyTests.swift b/Tests/skyflow-iOS-utilTests/skyflow_iOS_dormantCollectRequestBodyTests.swift new file mode 100644 index 00000000..9a7affe9 --- /dev/null +++ b/Tests/skyflow-iOS-utilTests/skyflow_iOS_dormantCollectRequestBodyTests.swift @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Unit tests for the dormant v1 PDB CollectRequestBody (kept for potential future PDB reuse). +// Distinct from FlowVaultCollectRequestBody, which is the live class covering this same logic. + +import XCTest +@testable import Skyflow + +final class skyflow_iOS_dormantCollectRequestBodyTests: XCTestCase { + var skyflow: Client! + + override func setUp() { + self.skyflow = Client(Configuration(vaultID: "id", vaultURL: "http://demo.com", tokenProvider: DemoTokenProvider(), options: Options(logLevel: .DEBUG))) + } + + func testCreateRequestBodySimpleInsert() { + let window = UIWindow() + let container = skyflow.container(type: ContainerType.COLLECT, options: nil) + let options = CollectElementOptions(required: false) + + let cardNumberInput = CollectElementInput(tableName: "persons", column: "card_number", type: .CARD_NUMBER) + let cardNumber = container?.create(input: cardNumberInput, options: options) + cardNumber?.textField.secureText = "4111 1111 1111 1111" + window.addSubview(cardNumber!) + + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Pure insert")) + let requestBody = CollectRequestBody.createRequestBody(elements: [cardNumber!], callback: callback, contextOptions: ContextOptions()) + + XCTAssertNotNil(requestBody) + let records = requestBody?["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["table"] as? String, "persons") + let update = requestBody?["update"] as! [String: Any] + XCTAssertTrue(update.isEmpty) + } + + func testCreateRequestBodyWithSkyflowIDInAdditionalFields() { + let additionalFields: [String: Any] = [ + "records": [ + ["table": "table1", "fields": ["column1": "value1"], "skyflowId": "id1"] + ] + ] + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Update via additionalFields")) + let requestBody = CollectRequestBody.createRequestBody(elements: [], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + + XCTAssertNotNil(requestBody) + XCTAssertEqual((requestBody?["records"] as! [[String: Any]]).count, 0) + let update = requestBody?["update"] as! [String: Any] + let entry = update["id1"] as! [String: Any] + XCTAssertEqual(entry["table"] as! String, "table1") + XCTAssertEqual(entry["fields"] as! [String: String], ["column1": "value1"]) + } + + func testCreateRequestBodyWithSkyflowIDOnElement() { + let window = UIWindow() + let container = skyflow.container(type: ContainerType.COLLECT, options: nil) + let options = CollectElementOptions(required: false) + + let cardNumberInput = CollectElementInput(tableName: "persons", column: "card_number", type: .CARD_NUMBER, skyflowId: "id1") + let cardNumber = container?.create(input: cardNumberInput, options: options) + cardNumber?.textField.secureText = "4111 1111 1111 1111" + window.addSubview(cardNumber!) + + let callback = DemoAPICallback(expectation: XCTestExpectation(description: "Update via element skyflowID")) + let requestBody = CollectRequestBody.createRequestBody(elements: [cardNumber!], callback: callback, contextOptions: ContextOptions()) + + XCTAssertNotNil(requestBody) + XCTAssertEqual((requestBody?["records"] as! [[String: Any]]).count, 0) + let update = requestBody?["update"] as! [String: Any] + let entry = update["id1"] as! [String: Any] + XCTAssertEqual(entry["table"] as! String, "persons") + XCTAssertEqual(entry["fields"] as! [String: String], ["card_number": ""]) + } + + func testCreateRequestBodyDuplicateAdditionalFieldError() { + let additionalFields: [String: Any] = [ + "records": [ + ["table": "table1", "fields": ["column1": "value1"]], + ["table": "table1", "fields": ["column1": "value2"]] + ] + ] + let expectation = XCTestExpectation(description: "Duplicate additional field should fail") + let callback = DemoAPICallback(expectation: expectation) + let requestBody = CollectRequestBody.createRequestBody(elements: [], additionalFields: additionalFields, callback: callback, contextOptions: ContextOptions()) + + XCTAssertNil(requestBody) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.DUPLICATE_ADDITIONAL_FIELD_FOUND(value: "column1").getErrorObject(contextOptions: ContextOptions()).localizedDescription) + } + + func testGetUniqueColumn() { + let upsert: [[String: Any]] = [ + ["table": "table1", "column": "email"], + ["table": "table2", "column": "ssn"] + ] + + XCTAssertEqual(CollectRequestBody.getUniqueColumn(tableName: "table1", upsert: upsert), "email") + XCTAssertEqual(CollectRequestBody.getUniqueColumn(tableName: "table2", upsert: upsert), "ssn") + XCTAssertEqual(CollectRequestBody.getUniqueColumn(tableName: "table3", upsert: upsert), "") + } +} diff --git a/Tests/skyflow-iOS-utilTests/skyflow_iOS_insertUtilTests 2.swift b/Tests/skyflow-iOS-utilTests/skyflow_iOS_insertUtilTests 2.swift index e3becb93..a3a3a60b 100644 --- a/Tests/skyflow-iOS-utilTests/skyflow_iOS_insertUtilTests 2.swift +++ b/Tests/skyflow-iOS-utilTests/skyflow_iOS_insertUtilTests 2.swift @@ -7,149 +7,187 @@ import XCTest final class skyflow_iOS_insertUtilTests: XCTestCase { - var collectCallback: InsertAPICallback! = nil + var collectCallback: FlowVaultInsertAPICallback! = nil var defaultRecord: [String: Any] = ["records": [["table": "table", "fields": ["field": "value"]]]] - + override func setUp() { - self.collectCallback = InsertAPICallback(callback: DemoAPICallback(expectation: XCTestExpectation()), + self.collectCallback = FlowVaultInsertAPICallback(callback: DemoAPICallback(expectation: XCTestExpectation()), apiClient: APIClient(vaultID: "", vaultURL: "", tokenProvider: DemoTokenProvider()), records: defaultRecord, - options: ICOptions(tokens: false, additionalFields: nil), + options: FlowVaultICOptions(additionalFields: nil), contextOptions: ContextOptions()) } - + func testBuildFieldsDict() { let dict = ["key": "value", "nested": ["key": "value"]] as [String: Any] let result = self.collectCallback.buildFieldsDict(dict: dict) XCTAssertEqual(dict["key"] as! String, result["key"] as! String) XCTAssertEqual(dict["nested"] as! [String: String], result["nested"] as! [String: String]) } - + func testOnSuccessInvalidUrl() { let expectation = XCTestExpectation(description: "Invalid URL should trigger failure") let callback = DemoAPICallback(expectation: expectation) self.collectCallback.apiClient.vaultURL = "Invalid url" self.collectCallback.callback = callback - + self.collectCallback.onSuccess("string") wait(for: [expectation], timeout: 20.0) - - let result = callback.receivedResponse - XCTAssert(result.contains("unsupported URL")) + + let errorObject = callback.data["error"] as! [String: Any] + let msg = errorObject["message"] as! String + XCTAssert(msg.contains("unsupported URL")) } - + func testGetRequestSession() { let url = URL(string: "https://example.org")! do { let (request, session) = try self.collectCallback.getRequestSession(url: url) - + XCTAssertEqual(request.allHTTPHeaderFields!["Authorization"], "Bearer ") // From DemoTokenProvider() + XCTAssertEqual(request.allHTTPHeaderFields!["Content-Type"], "application/json") + XCTAssertEqual(request.allHTTPHeaderFields!["Accept"], "application/json") let body = try JSONSerialization.jsonObject(with: request.httpBody!, options: .allowFragments) as! [String: Any] let records = body["records"] as! [[String: Any]] XCTAssertEqual(records.count, 1) - XCTAssertEqual(records[0]["fields"] as! [String: String], ["field": "value"]) - XCTAssertEqual(records[0]["method"] as! String, "POST") + XCTAssertEqual(records[0]["data"] as! [String: String], ["field": "value"]) XCTAssertEqual(records[0]["tableName"] as! String, "table") - + } catch { XCTFail(error.localizedDescription) } } - + func testGetCollectResponse() { - let response = ["responses": [["records": [["skyflow_id": "SID"]]]]] - + let response = ["records": [["skyflowID": "SID", "tableName": "table"]]] + do { let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) let result = try self.collectCallback.getCollectResponseBody(data: data) - - XCTAssertEqual(result as! [String: [[String: String]]], ["records": [ - [ - "table": "table", - "skyflow_id": "SID" - ] - ]]) + let records = result["records"] as! [[String: Any]] + + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["tableName"] as! String, "table") + XCTAssertEqual(records[0]["skyflowID"] as! String, "SID") } catch { XCTFail(error.localizedDescription) } } - + func testGetCollectResponseWithTokens() { - let response = ["responses": [["records": [["skyflow_id": "SID"]]], ["fields": ["field": "value"]]]] - self.collectCallback.options = ICOptions() - + let response = ["records": [["skyflowID": "SID", "tableName": "table", "tokens": ["field": [["token": "tok", "tokenGroupName": "group"]]]]]] as [String: Any] + self.collectCallback.options = FlowVaultICOptions() + do { let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) let result = try self.collectCallback.getCollectResponseBody(data: data) let records = result["records"] as! [[String: Any]] - - let expected = ["records": [["table": "table", "fields": ["field": "value", "skyflow_id": "SID"]]]] - - XCTAssertEqual(records.count, expected["records"]!.count) - XCTAssertEqual(records[0]["table"] as! String, expected["records"]![0]["table"] as! String) - XCTAssertEqual(records[0]["fields"] as! [String: String], expected["records"]![0]["fields"] as! [String: String]) - - + + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["tableName"] as! String, "table") + XCTAssertEqual(records[0]["skyflowID"] as! String, "SID") + let fields = records[0]["fields"] as! [String: Any] + let fieldTokens = fields["field"] as! [[String: Any]] + XCTAssertEqual(fieldTokens[0]["token"] as? String, "tok") } catch { XCTFail(error.localizedDescription) } } - + + func testGetCollectResponseWithHashedData() { + let response: [String: Any] = ["records": [[ + "skyflowID": "SID", + "tableName": "table", + "httpCode": 200, + "data": ["field": "value"], + "hashedData": ["field": "hashed-value"] + ]]] + self.collectCallback.options = FlowVaultICOptions() + + do { + let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) + let result = try self.collectCallback.getCollectResponseBody(data: data) + let records = result["records"] as! [[String: Any]] + + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["httpCode"] as? Int, 200) + let fields = records[0]["fields"] as! [String: Any] + XCTAssertNil(fields["data"]) + XCTAssertEqual(records[0]["hashedData"] as! [String: String], ["field": "hashed-value"]) + } catch { + XCTFail(error.localizedDescription) + } + } + func testProcessResponse() { - let response = ["responses": [["records": [["skyflow_id": "SID"]]], ["fields": ["field": "value"]]]] - + let response = ["records": [["skyflowID": "SID", "tableName": "table"]]] + do { let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) let response = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) - - let processedData = try self.collectCallback.processResponse(data: data, response: response, error: nil) as! [String: [[String: String]]] - XCTAssertEqual(processedData, ["records": [["skyflow_id": "SID", "table": "table"]]]) - + + let processedData = try self.collectCallback.processResponse(data: data, response: response, error: nil) + let records = processedData["records"] as! [[String: Any]] + XCTAssertEqual(records[0]["tableName"] as! String, "table") + XCTAssertEqual(records[0]["skyflowID"] as! String, "SID") + } catch { XCTFail(error.localizedDescription) } } - + func testProcessResponseError() { - let response = ["responses": [["records": [["skyflow_id": "SID"]]], ["fields": ["field": "value"]]]] - + // A genuine connection/network-level error (URLSession error, no HTTP response reached) + // should not throw - it returns a top-level {"error": {...}} dict directly. + let networkError = NSError(domain: "NSURLErrorDomain", code: -1009, userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."]) + do { - let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) - let response = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) - - let processedData = try self.collectCallback.processResponse(data: data, response: response, error: NSError(domain: "", code: 400, userInfo: nil)) as! [String: [[String: String]]] - XCTFail("Should have thrown error") - + let processedData = try self.collectCallback.processResponse(data: nil, response: nil, error: networkError) + let errorDict = processedData["error"] as! [String: Any] + XCTAssertEqual(errorDict["message"] as? String, "The Internet connection appears to be offline.") + } catch { + XCTFail("Should not throw for a connection-level error: \(error)") + } + } + + func testGetCollectResponseBodyWithMalformedTopLevelJSON() { + // A response body that parses as valid JSON but isn't a top-level object (e.g. a bare + // array or scalar) must not crash - it should fall back to an empty result. + do { + let arrayData = try JSONSerialization.data(withJSONObject: ["not", "an", "object"], options: .fragmentsAllowed) + let result = try self.collectCallback.getCollectResponseBody(data: arrayData) + XCTAssertEqual((result["records"] as? [[String: Any]])?.count, 0) } catch { + XCTFail("Malformed top-level JSON should not throw or crash: \(error)") } } - + func testProcessResponseFailure() { let response = ["error": ["message": "Internal Server Error"]] do { let data = try JSONSerialization.data(withJSONObject: response, options: .fragmentsAllowed) let response = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 500, httpVersion: "1.1", headerFields: ["x-request-id": "RID"]) - - try self.collectCallback.processResponse(data: data, response: response, error: nil) - XCTFail("Not throwing on Api Error") - + + let res = try self.collectCallback.processResponse(data: data, response: response, error: nil) + let message = (res["error"] as! [String: Any])["message"] as! String + XCTAssertEqual(message, "Internal Server Error - request-id: RID") } catch { - XCTAssertEqual(error.localizedDescription, "Internal Server Error - request-id: RID") + XCTFail("Should not throw on Api Error: \(error)") } } - + func testCollectInvalidBearerToken() { let expectation = XCTestExpectation() let callback = DemoAPICallback(expectation: expectation) let client = Client(Configuration(vaultID: "id", vaultURL: "https://www.skyflow.com", tokenProvider: DemoTokenProvider())) let container = client.container(type: ContainerType.COLLECT) - let input = CollectElementInput(table: "table", column: "column", type: .EXPIRATION_YEAR) + let input = CollectElementInput(tableName: "table", column: "column", type: .EXPIRATION_YEAR) let element = container?.create(input: input) - + UIWindow().addSubview(element!) - - container?.collect(callback: callback) - + + container?.collect(callback: callback.asCollectCallback) + wait(for: [expectation], timeout: 20.0) XCTAssertTrue(callback.receivedResponse.contains("Token generated from 'getBearerToken' callback function is invalid")) } @@ -161,25 +199,25 @@ final class skyflow_iOS_insertUtilTests: XCTestCase { XCTAssertEqual(device.systemName + "@" + device.systemVersion, deviceInfo["sdk_client_os_details"] as! String) } func testDeviceDetails() { - + let deviceDetails = FetchMetrices().getDeviceDetails() - + XCTAssertNotNil(deviceDetails["device"]) XCTAssertNotNil(deviceDetails["os_details"]) XCTAssertNotNil(deviceDetails["sdk_name_version"]) - + if let device = deviceDetails["device"] as? String { XCTAssertFalse(device.isEmpty) } else { XCTAssertTrue(deviceDetails["device"] as! String == "") } - + if let osDetails = deviceDetails["os_details"] as? String { XCTAssertFalse(osDetails.isEmpty) } else { XCTAssertTrue(deviceDetails["os_details"] as! String == "") } - + if let sdkNameVersion = deviceDetails["sdk_name_version"] as? String { XCTAssertFalse(sdkNameVersion.isEmpty) } else { @@ -196,30 +234,70 @@ final class skyflow_iOS_insertUtilTests: XCTestCase { "fields": ["field": "value"] ] - let collectCallback = InsertAPICallback( + let collectCallback = FlowVaultInsertAPICallback( callback: callback, apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), records: ["records": [insertRecord]], - options: ICOptions(tokens: true, additionalFields: nil), + options: FlowVaultICOptions(additionalFields: nil), contextOptions: ContextOptions() ) // Simulate a successful insert response - let insertResponseDict = ["responses": [["records": [["skyflow_id": "SID"]]], ["fields": ["field": "value"]]]] + let insertResponseDict: [String: Any] = ["records": [["skyflowID": "SID", "tableName": "table", "tokens": ["field": "value"]]]] let insertResponseData = try! JSONSerialization.data(withJSONObject: insertResponseDict, options: .fragmentsAllowed) - let insertUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/vault")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + let insertUrlResponse = HTTPURLResponse(url: URL(string: "https://example.org/v2/records/insert")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) do { let processedInsert = try collectCallback.processResponse(data: insertResponseData, response: insertUrlResponse, error: nil) let insertRecords = processedInsert["records"] as! [[String: Any]] XCTAssertEqual(insertRecords.count, 1) - let fields = insertRecords[0]["fields"] as? [String: String] - XCTAssertEqual(fields?["skyflow_id"] as? String, "SID") + XCTAssertEqual(insertRecords[0]["tableName"] as? String, "table") + XCTAssertEqual(insertRecords[0]["skyflowID"] as? String, "SID") + let fields = insertRecords[0]["fields"] as! [String: Any] + XCTAssertEqual(fields["field"] as? String, "value") } catch { XCTFail("Insert scenario failed: \(error)") } } + func testInsertFullFailureWithNon2xxOuterStatus() { + let expectation = XCTestExpectation(description: "Full batch failure with 400 outer status should still parse per-record errors") + let callback = DemoAPICallback(expectation: expectation) + + let insertRecord: [String: Any] = [ + "table": "table", + "fields": ["field": "value"] + ] + + let collectCallback = FlowVaultInsertAPICallback( + callback: callback, + apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), + records: ["records": [insertRecord]], + options: FlowVaultICOptions(additionalFields: nil), + contextOptions: ContextOptions() + ) + + let responseDict: [String: Any] = ["records": [ + ["skyflowID": NSNull(), "tokens": NSNull(), "data": NSNull(), "hashedData": NSNull(), + "error": "Invalid request. Table name table not present for record. Specify a valid table name.", + "httpCode": 400, "tableName": ""] + ]] + let responseData = try! JSONSerialization.data(withJSONObject: responseDict, options: .fragmentsAllowed) + let urlResponse = HTTPURLResponse(url: URL(string: "https://example.org/v2/records/insert")!, statusCode: 400, httpVersion: "1.1", headerFields: nil) + + do { + let processed = try collectCallback.processResponse(data: responseData, response: urlResponse, error: nil) + XCTAssertNil(processed["error"], "Should not collapse into a generic top-level error when the body has a records array") + let records = processed["records"] as! [[String: Any]] + + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["error"] as? String, "Invalid request. Table name table not present for record. Specify a valid table name.") + XCTAssertEqual(records[0]["httpCode"] as? Int, 400) + } catch { + XCTFail("Full failure scenario should not throw: \(error)") + } + } + } diff --git a/Tests/skyflow-iOS-utilTests/skyflow_iOS_revealApiCallbackTests.swift b/Tests/skyflow-iOS-utilTests/skyflow_iOS_revealApiCallbackTests.swift new file mode 100644 index 00000000..c18a05e5 --- /dev/null +++ b/Tests/skyflow-iOS-utilTests/skyflow_iOS_revealApiCallbackTests.swift @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2022 Skyflow +*/ + +// Unit tests for the dormant v1 PDB RevealAPICallback (kept for potential future PDB reuse). + +import XCTest +@testable import Skyflow + +final class skyflow_iOS_revealApiCallbackTests: XCTestCase { + var revealApiCallback: RevealAPICallback! + var expectation: XCTestExpectation! + var callback: DemoAPICallback! + + override func setUp() { + self.expectation = XCTestExpectation() + self.callback = DemoAPICallback(expectation: self.expectation) + self.revealApiCallback = RevealAPICallback( + callback: self.callback, + apiClient: APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()), + connectionUrl: "https://example.org/v1/vaults/vault", + records: [RevealRequestRecord(token: "token1")], + contextOptions: ContextOptions() + ) + } + + func testGetRequestSession() { + let (request, _) = self.revealApiCallback.getRequestSession() + + XCTAssertEqual(request.url?.absoluteString, "https://example.org/v1/vaults/vault/detokenize") + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json; utf-8") + XCTAssertEqual(request.allHTTPHeaderFields?["Accept"], "application/json") + XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "Bearer ") + } + + func testGetRevealRequestBody() { + do { + let record = RevealRequestRecord(token: "abc123") + let data = try self.revealApiCallback.getRevealRequestBody(record: record) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let params = json["detokenizationParameters"] as! [[String: Any]] + + XCTAssertEqual(params.count, 1) + XCTAssertEqual(params[0]["token"] as? String, "abc123") + XCTAssertEqual(params[0]["redaction"] as? String, "PLAIN_TEXT") + } catch { + XCTFail(error.localizedDescription) + } + } + + func testProcessResponseSuccess() { + do { + let record = RevealRequestRecord(token: "abc123") + let responseDict = ["records": [["token": "abc123", "value": "revealed-value"]]] + let data = try JSONSerialization.data(withJSONObject: responseDict) + let httpResponse = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + + let (success, failure) = try self.revealApiCallback.processResponse(record: record, data: data, response: httpResponse, error: nil) + + XCTAssertNil(failure) + XCTAssertEqual(success?.token_id, "abc123") + XCTAssertEqual(success?.value, "revealed-value") + } catch { + XCTFail(error.localizedDescription) + } + } + + func testProcessResponseBadStatusCode() { + do { + let record = RevealRequestRecord(token: "badtoken") + let responseDict = ["error": ["message": "Invalid Token"]] + let data = try JSONSerialization.data(withJSONObject: responseDict) + let httpResponse = HTTPURLResponse(url: URL(string: "https://example.org")!, statusCode: 404, httpVersion: "1.1", headerFields: ["x-request-id": "RID"]) + + let (success, failure) = try self.revealApiCallback.processResponse(record: record, data: data, response: httpResponse, error: nil) + + XCTAssertNil(success) + XCTAssertEqual(failure?.id, "badtoken") + XCTAssertEqual(failure?.error.localizedDescription, "Invalid Token - request-id: RID") + } catch { + XCTFail(error.localizedDescription) + } + } + + func testProcessResponseNetworkError() { + let record = RevealRequestRecord(token: "abc123") + let networkError = NSError(domain: "NSURLErrorDomain", code: -1009, userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."]) + + do { + _ = try self.revealApiCallback.processResponse(record: record, data: nil, response: nil, error: networkError) + XCTFail("Should throw on a connection-level error") + } catch { + XCTAssertEqual((error as NSError).code, -1009) + } + } + + func testHandleCallbacksAllSuccess() { + let success = [RevealSuccessRecord(token_id: "t1", value: "v1")] + + self.revealApiCallback.handleCallbacks(success: success, failure: [], isSuccess: true, errorObject: nil) + wait(for: [self.expectation], timeout: 10.0) + + let records = self.callback.data["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["token"] as? String, "t1") + XCTAssertEqual(records[0]["value"] as? String, "v1") + XCTAssertNil(self.callback.data["errors"]) + } + + func testHandleCallbacksPartialFailure() { + let success = [RevealSuccessRecord(token_id: "t1", value: "v1")] + let failure = [RevealErrorRecord(id: "t2", error: NSError(domain: "", code: 404, userInfo: [NSLocalizedDescriptionKey: "Invalid Token"]))] + + self.revealApiCallback.handleCallbacks(success: success, failure: failure, isSuccess: true, errorObject: nil) + wait(for: [self.expectation], timeout: 10.0) + + let records = self.callback.data["records"] as! [[String: Any]] + let errors = self.callback.data["errors"] as! [[String: Any]] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors[0]["token"] as? String, "t2") + } + + func testHandleCallbacksAllFailure() { + let failure = [RevealErrorRecord(id: "t1", error: NSError(domain: "", code: 404, userInfo: [NSLocalizedDescriptionKey: "Invalid Token"]))] + + self.revealApiCallback.handleCallbacks(success: [], failure: failure, isSuccess: true, errorObject: nil) + wait(for: [self.expectation], timeout: 10.0) + + XCTAssertNil(self.callback.data["records"]) + let errors = self.callback.data["errors"] as! [[String: Any]] + XCTAssertEqual(errors.count, 1) + } + + func testHandleCallbacksTransportFailure() { + // isSuccess = false represents a request-level (network) failure somewhere in the batch, + // as opposed to a per-token error - it should route through callRevealOnFailure instead. + let networkError = NSError(domain: "", code: -1009, userInfo: [NSLocalizedDescriptionKey: "offline"]) + + self.revealApiCallback.handleCallbacks(success: [], failure: [], isSuccess: false, errorObject: networkError) + wait(for: [self.expectation], timeout: 10.0) + + let errors = self.callback.data["errors"] as! [[String: NSError]] + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors[0]["error"]?.localizedDescription, "offline") + } + + func testOnSuccessInvalidUrl() { + self.revealApiCallback.connectionUrl = "invalid url" + self.revealApiCallback.onSuccess("token") + wait(for: [self.expectation], timeout: 20.0) + + let errors = self.callback.data["errors"] as! [[String: NSError]] + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors[0]["error"]?.localizedDescription, "unsupported URL") + } + + func testOnFailureWrapsSwiftError() { + let error = NSError(domain: "", code: 500, userInfo: [NSLocalizedDescriptionKey: "boom"]) + + self.revealApiCallback.onFailure(error) + wait(for: [self.expectation], timeout: 10.0) + + let errors = self.callback.data["errors"] as! [[String: NSError]] + XCTAssertEqual(errors.count, 1) + XCTAssertEqual(errors[0]["error"]?.localizedDescription, "boom") + } + + func testOnFailurePassesThroughNonErrorValues() { + let dict: [String: Any] = ["errors": [["error": "already a dict"]]] + + self.revealApiCallback.onFailure(dict) + wait(for: [self.expectation], timeout: 10.0) + + XCTAssertNotNil(self.callback.data["errors"]) + } +} diff --git a/Tests/skyflow-iOS-utilTests/skyflow_iOS_revealUtilTests.swift b/Tests/skyflow-iOS-utilTests/skyflow_iOS_revealUtilTests.swift index f09f9108..9c01c42f 100644 --- a/Tests/skyflow-iOS-utilTests/skyflow_iOS_revealUtilTests.swift +++ b/Tests/skyflow-iOS-utilTests/skyflow_iOS_revealUtilTests.swift @@ -9,7 +9,7 @@ import XCTest // swiftlint:disable:next type_body_length final class skyflow_iOS_revealUtilTests: XCTestCase { - var revealApiCallback: RevealAPICallback! = nil + var revealApiCallback: FlowVaultRevealAPICallback! = nil var revealValueCallback: RevealValueCallback! = nil var expectation: XCTestExpectation! = nil var callback: DemoAPICallback! = nil @@ -20,7 +20,7 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { override func setUp() { self.expectation = XCTestExpectation() self.callback = DemoAPICallback(expectation: self.expectation) - self.revealApiCallback = RevealAPICallback(callback: self.callback, + self.revealApiCallback = FlowVaultRevealAPICallback(callback: self.callback, apiClient: APIClient(vaultID: "", vaultURL: "", tokenProvider: DemoTokenProvider()), connectionUrl: "", records: [], @@ -40,7 +40,7 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { func testOnSuccessInvalidUrl() { self.revealApiCallback.connectionUrl = "invalid url" - let record = RevealRequestRecord(token: "token", redaction: RedactionType.PLAIN_TEXT.rawValue) + let record = RevealRequestRecord(token: "token") self.revealApiCallback.records = [record] self.revealApiCallback.onSuccess("token") wait(for: [self.expectation], timeout: 10.0) @@ -48,111 +48,178 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { XCTAssertEqual(errors.count, 1) XCTAssertEqual(errors[0]["error"]?.localizedDescription, "unsupported URL") } - + func testGetRequestSession() { - self.revealApiCallback.connectionUrl = "https://www.example.org" - - let (request, session) = self.revealApiCallback.getRequestSession() - XCTAssertEqual(request.url?.absoluteString, "https://www.example.org/detokenize") - XCTAssertEqual(request.allHTTPHeaderFields!["Content-Type"], "application/json; utf-8") - XCTAssertEqual(request.allHTTPHeaderFields!["Accept"], "application/json") - XCTAssertEqual(request.allHTTPHeaderFields!["Authorization"], "Bearer ") - } - - func testRevealRequestBody() { - let record = RevealRequestRecord(token: "token", redaction: RedactionType.PLAIN_TEXT.rawValue) + let url = URL(string: "https://www.example.org")! + do { - let result = try self.revealApiCallback.getRevealRequestBody(record: record) - - if let jsonObject = try? JSONSerialization.jsonObject(with: result, options: []) as? [String: Any] { - let key = jsonObject.keys - let values = jsonObject.values - XCTAssertTrue(key.contains("detokenizationParameters")) - } else { - print("Failed to convert JSON data into a JSON object.") - } - XCTAssertNotNil(result) + let (request, session) = try self.revealApiCallback.getRequestSession(url: url) + XCTAssertEqual(request.url?.absoluteString, "https://www.example.org") + XCTAssertEqual(request.allHTTPHeaderFields!["Content-Type"], "application/json") + XCTAssertEqual(request.allHTTPHeaderFields!["Accept"], "application/json") + XCTAssertEqual(request.allHTTPHeaderFields!["Authorization"], "Bearer ") } catch { XCTFail(error.localizedDescription) } } - + + func testConstructV2DetokenizeRequestBody() { + self.revealApiCallback.records = [RevealRequestRecord(token: "token1"), RevealRequestRecord(token: "token2")] + let result = FlowVaultDetokenizeRequestBody.createRequestBody(vaultID: "vault123", records: self.revealApiCallback.records) + + XCTAssertEqual(result["vaultID"] as! String, "vault123") + XCTAssertEqual(result["tokens"] as! [String], ["token1", "token2"]) + XCTAssertNil(result["tokenGroupRedactions"]) + } + + func testConstructV2DetokenizeRequestBodyWithTokenGroupRedactions() { + let records = [RevealRequestRecord(token: "token1")] + let tokenGroupRedactions = [TokenGroupRedaction(tokenGroupName: "group1", redaction: "MASKED")] + let result = FlowVaultDetokenizeRequestBody.createRequestBody(vaultID: "vault123", records: records, tokenGroupRedactions: tokenGroupRedactions) + + let redactions = result["tokenGroupRedactions"] as! [[String: Any]] + XCTAssertEqual(redactions.count, 1) + XCTAssertEqual(redactions[0]["tokenGroupName"] as? String, "group1") + XCTAssertEqual(redactions[0]["redaction"] as? String, "MASKED") + } + func testProcessResponseError() { let revealedResponse = ["key": "value"] let serverError = NSError(domain: "", code: 500, userInfo: [NSLocalizedDescriptionKey: "Internal Server Error"]) do { let responseData = try JSONSerialization.data(withJSONObject: revealedResponse, options: .fragmentsAllowed) - try self.revealApiCallback.processResponse(record: RevealRequestRecord(token: "token", redaction: RedactionType.PLAIN_TEXT.rawValue), data: responseData, response: nil, error: serverError) + _ = try self.revealApiCallback.processResponse(data: responseData, response: nil, error: serverError) XCTFail("Not throwing on http error") } catch { XCTAssertEqual(error.localizedDescription, serverError.localizedDescription) } } - + func testProcessResponseBadCode() { let revealedResponse = ["error": ["message": "Internal Server Error"]] let httpResponse = HTTPURLResponse(url: URL(string: "https://www.example.org")!, statusCode: 500, httpVersion: "1.1", headerFields: ["x-request-id": "RID"]) do { let responseData = try JSONSerialization.data(withJSONObject: revealedResponse, options: .fragmentsAllowed) - let (success, failure) = try self.revealApiCallback.processResponse(record: RevealRequestRecord(token: "token", redaction: RedactionType.PLAIN_TEXT.rawValue), data: responseData, response: httpResponse, error: nil) - XCTAssertNil(success) - XCTAssertNotNil(failure) - XCTAssertEqual(failure?.error.localizedDescription, "Internal Server Error - request-id: RID") + _ = try self.revealApiCallback.processResponse(data: responseData, response: httpResponse, error: nil) + XCTFail("Not throwing on http error") } catch { - XCTFail(error.localizedDescription) + XCTAssertEqual(error.localizedDescription, "Internal Server Error - request-id: RID") } } - + func testProcessResponseSuccess() { - let revealedResponse = ["records": [["token": "token", "value": "value"]]] + let revealedResponse = ["response": [["token": "token", "value": "value"]]] let httpResponse = HTTPURLResponse(url: URL(string: "https://www.example.org")!, statusCode: 200, httpVersion: "1.1", headerFields: ["x-request-id": "RID"]) do { let responseData = try JSONSerialization.data(withJSONObject: revealedResponse, options: .fragmentsAllowed) - let (success, failure) = try self.revealApiCallback.processResponse(record: RevealRequestRecord(token: "token", redaction: RedactionType.PLAIN_TEXT.rawValue), data: responseData, response: httpResponse, error: nil) - XCTAssertNil(failure) - XCTAssertNotNil(success) - XCTAssertEqual(success?.token_id, "token") - XCTAssertEqual(success?.value, "value") + let response = try self.revealApiCallback.processResponse(data: responseData, response: httpResponse, error: nil) + let records = response["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["token"] as? String, "token") + XCTAssertEqual(records[0]["value"] as? String, "value") + XCTAssertNil(records[0]["error"]) } catch { XCTFail(error.localizedDescription) } } - - func testHandleCallbacksSuccess() { - let errorObject = NSError(domain: "", code: 200, userInfo: nil) - let success = [RevealSuccessRecord(token_id: "token", value: "value")] - - self.revealApiCallback.handleCallbacks(success: success, failure: [], isSuccess: true, errorObject: nil) - - wait(for: [self.expectation], timeout: 20.0) - let records = self.callback.data["records"] as! [[String: String]] - - XCTAssertEqual(records.count, 1) - XCTAssertEqual(records[0]["value"], "value") - XCTAssertEqual(records[0]["token"], "token") + + func testProcessResponseWithMetadataAndHttpCode() { + let revealedResponse: [String: Any] = ["response": [ + ["token": "token", "value": "value", "httpCode": 200, "metadata": ["tableName": "table", "skyflowID": "SID"]] + ]] + let httpResponse = HTTPURLResponse(url: URL(string: "https://www.example.org")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + do { + let responseData = try JSONSerialization.data(withJSONObject: revealedResponse, options: .fragmentsAllowed) + let response = try self.revealApiCallback.processResponse(data: responseData, response: httpResponse, error: nil) + let records = response["records"] as! [[String: Any]] + + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["httpCode"] as? Int, 200) + let metadata = records[0]["metadata"] as! [String: Any] + XCTAssertEqual(metadata["tableName"] as? String, "table") + XCTAssertEqual(metadata["skyflowID"] as? String, "SID") + } catch { + XCTFail(error.localizedDescription) + } } - - func testHandleCallbacksFailure() { - let failure = RevealErrorRecord(id: "token", error: NSError(domain: "", code: 500, userInfo: [NSLocalizedDescriptionKey: "Invalid Token"])) - self.revealApiCallback.handleCallbacks(success: [], failure: [failure], isSuccess: true, errorObject: nil) - - wait(for: [self.expectation], timeout: 20.0) - let errors = self.callback.data["errors"] as! [[String: Any]] - XCTAssertEqual(errors.count, 1) - XCTAssertEqual(errors[0]["error"] as! NSError, failure.error) - XCTAssertEqual(errors[0]["token"] as! String, "token") + + func testProcessResponsePartialFailure() { + let revealedResponse: [String: Any] = ["response": [ + ["token": "token1", "value": "value1"], + ["token": "token2", "error": "Invalid Token", "httpCode": 400] + ]] + let httpResponse = HTTPURLResponse(url: URL(string: "https://www.example.org")!, statusCode: 200, httpVersion: "1.1", headerFields: nil) + do { + let responseData = try JSONSerialization.data(withJSONObject: revealedResponse, options: .fragmentsAllowed) + let response = try self.revealApiCallback.processResponse(data: responseData, response: httpResponse, error: nil) + let records = response["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records[0]["token"] as? String, "token1") + XCTAssertNil(records[0]["error"]) + XCTAssertEqual(records[1]["token"] as? String, "token2") + XCTAssertEqual(records[1]["error"] as? String, "Invalid Token") + } catch { + XCTFail(error.localizedDescription) + } } - - func testHandleCallbacksError() { - let error = NSError(domain: "", code: 500, userInfo: [NSLocalizedDescriptionKey: "Invalid Token"]) - self.revealApiCallback.handleCallbacks(success: [], failure: [], isSuccess: false, errorObject: error) - - wait(for: [self.expectation], timeout: 20.0) - let errors = self.callback.data["errors"] as! [[String: Any]] - XCTAssertEqual(errors.count, 1) - XCTAssertEqual(errors[0]["error"] as! NSError, error) + + func testProcessResponseFullFailureWithNon2xxOuterStatus() { + let revealedResponse: [String: Any] = ["response": [ + ["token": "dedwimm", "value": NSNull(), "tokenGroupName": NSNull(), + "error": "Detokenize failed. Token dedwimm is invalid. Specify a valid token.", + "httpCode": 404, "metadata": NSNull()], + ["token": "femwdmm", "value": NSNull(), "tokenGroupName": NSNull(), + "error": "Detokenize failed. Token femwdmm is invalid. Specify a valid token.", + "httpCode": 404, "metadata": NSNull()] + ]] + let httpResponse = HTTPURLResponse(url: URL(string: "https://www.example.org")!, statusCode: 404, httpVersion: "1.1", headerFields: nil) + do { + let responseData = try JSONSerialization.data(withJSONObject: revealedResponse, options: .fragmentsAllowed) + let response = try self.revealApiCallback.processResponse(data: responseData, response: httpResponse, error: nil) + let records = response["records"] as! [[String: Any]] + + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records[0]["error"] as? String, "Detokenize failed. Token dedwimm is invalid. Specify a valid token.") + XCTAssertEqual(records[0]["httpCode"] as? Int, 404) + } catch { + XCTFail("Full failure with 404 outer status should not throw: \(error)") + } } - + + func testProcessResponseInvalidTokenGroupError() { + let revealedResponse: [String: Any] = ["error": [ + "grpc_code": 3, + "http_code": 400, + "message": "Detokenize failed. Token group no is invalid. Specify a valid token group.", + "http_status": "Bad Request", + "details": [] + ]] + let httpResponse = HTTPURLResponse(url: URL(string: "https://www.example.org")!, statusCode: 400, httpVersion: "1.1", headerFields: nil) + do { + let responseData = try JSONSerialization.data(withJSONObject: revealedResponse, options: .fragmentsAllowed) + _ = try self.revealApiCallback.processResponse(data: responseData, response: httpResponse, error: nil) + XCTFail("Should throw on genuine top-level error (invalid token group)") + } catch { + XCTAssertEqual(error.localizedDescription, "Detokenize failed. Token group no is invalid. Specify a valid token group.") + } + } + + func testConstructV2DetokenizeRequestBodyPassesThroughDuplicateTokenGroupRedactions() { + let records = [RevealRequestRecord(token: "token1")] + let tokenGroupRedactions = [ + TokenGroupRedaction(tokenGroupName: "group1", redaction: "MASKED"), + TokenGroupRedaction(tokenGroupName: "group1", redaction: "PLAIN_TEXT") + ] + let result = FlowVaultDetokenizeRequestBody.createRequestBody(vaultID: "vault123", records: records, tokenGroupRedactions: tokenGroupRedactions) + + let redactions = result["tokenGroupRedactions"] as! [[String: Any]] + XCTAssertEqual(redactions.count, 2) + XCTAssertEqual(redactions[0]["tokenGroupName"] as? String, "group1") + XCTAssertEqual(redactions[0]["redaction"] as? String, "MASKED") + XCTAssertEqual(redactions[1]["tokenGroupName"] as? String, "group1") + XCTAssertEqual(redactions[1]["redaction"] as? String, "PLAIN_TEXT") + } + func testGetTokensToErrors() { let errors = [["token": "1234"], ["token": "4321"]] @@ -162,6 +229,148 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { XCTAssertEqual(result["4321"], "Invalid Token") } + func testRevealValueOnSuccessPureSuccess() { + // Every token revealed, zero errors - a genuine full-success scenario + // (as opposed to the mixed success+error fixtures used elsewhere in this file). + let token1 = "123" + let token2 = "456" + let response: [String: Any] = [ + "records": [ + ["token": token1, "value": "John"], + ["token": token2, "value": "Doe"] + ], + "errors": [] + ] + + let element1 = self.container.create(input: RevealElementInput(token: token1, label: "first"), options: RevealElementOptions()) + let element2 = self.container.create(input: RevealElementInput(token: token2, label: "second"), options: RevealElementOptions()) + + self.revealValueCallback.revealElements = [element1, element2] + self.revealValueCallback.onSuccess(response) + wait(for: [self.expectation], timeout: 20.0) + waitForUIUpdates() + + let records = self.callback.data["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 2) + XCTAssertTrue(records.allSatisfy { $0["error"] == nil }) + XCTAssertEqual(element1.actualValue, "John") + XCTAssertEqual(element2.actualValue, "Doe") + XCTAssertEqual(element1.errorMessage.text, nil) + XCTAssertEqual(element2.errorMessage.text, nil) + } + + func testRevealValueOnFailurePureFailure() { + // Every token invalid, zero successes - a genuine full-failure scenario. + let token1 = "123" + let token2 = "456" + let response: [String: Any] = [ + "records": [], + "errors": [ + ["token": token1, "error": "Invalid Token"], + ["token": token2, "error": "Invalid Token"] + ] + ] + + let element1 = self.container.create(input: RevealElementInput(token: token1, label: "first")) + let element2 = self.container.create(input: RevealElementInput(token: token2, label: "second")) + + self.revealValueCallback.revealElements = [element1, element2] + self.revealValueCallback.onFailure(response) + wait(for: [self.expectation], timeout: 20.0) + waitForUIUpdates() + + let errors = self.callback.data["records"] as! [[String: Any]] + XCTAssertEqual(errors.count, 2) + XCTAssertTrue(errors.allSatisfy { $0["error"] != nil }) + XCTAssertEqual(element1.actualValue, nil) + XCTAssertEqual(element2.actualValue, nil) + XCTAssertEqual(element1.errorMessage.text, "Invalid Token") + XCTAssertEqual(element2.errorMessage.text, "Invalid Token") + } + + func testRevealValueOnFailureNetworkErrorHasNoPerTokenErrors() { + // Simulates what FlowVaultRevealAPICallback.callRevealOnFailure produces for a genuine + // network/connection-level failure: no "records", and the error entry has no "token" + // (since the failure isn't scoped to any specific token). Elements should not crash and + // should not show a per-element error message (there's no token to match against), but + // the client callback should still receive the error. + let token1 = "123" + let networkError = NSError(domain: "NSURLErrorDomain", code: -1009, userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."]) + let response: [String: Any] = [ + "errors": [["error": networkError]] + ] + + let element1 = self.container.create(input: RevealElementInput(token: token1, label: "first")) + self.revealValueCallback.revealElements = [element1] + + self.revealValueCallback.onFailure(response) + wait(for: [self.expectation], timeout: 20.0) + waitForUIUpdates() + + // Not scoped to any specific token, so it's delivered via onFailure (not folded into the + // records array) rather than a per-record error. + XCTAssertNil(self.callback.data["records"]) + XCTAssertEqual(self.callback.receivedResponse, "The Internet connection appears to be offline.") + // No token to match this error against, so the element shows no inline error. + XCTAssertEqual(element1.errorMessage.text, nil) + XCTAssertEqual(element1.actualValue, nil) + } + + func testRevealValueOnFailureWithNonDictionaryError() { + // onFailure(_ error: Any) can in principle be called with something that isn't a + // dictionary at all (the Callback protocol accepts Any). Must not crash, and should + // report an empty response rather than propagating garbage. + let element1 = self.container.create(input: RevealElementInput(token: "123", label: "first")) + self.revealValueCallback.revealElements = [element1] + + self.revealValueCallback.onFailure("not a dictionary") + wait(for: [self.expectation], timeout: 20.0) + + XCTAssertNil(self.callback.data["success"]) + XCTAssertNil(self.callback.data["errors"]) + } + + func testRevealValueOnSuccessWithNonDictionaryResponseBody() { + // Same defensive case for onSuccess: a non-dictionary responseBody must not crash. + let element1 = self.container.create(input: RevealElementInput(token: "123", label: "first")) + self.revealValueCallback.revealElements = [element1] + + self.revealValueCallback.onSuccess(42) + wait(for: [self.expectation], timeout: 20.0) + waitForUIUpdates() + + XCTAssertNil(self.callback.data["success"]) + XCTAssertNil(self.callback.data["errors"]) + XCTAssertEqual(element1.actualValue, nil) + } + + func testRevealValueOnSuccessSkipsMalformedRecords() { + // A record missing "token" (or not a dictionary at all) must be skipped, not crash + // the whole reveal for every other token in the same batch. + let goodToken = "123" + let response: [String: Any] = [ + "records": [ + ["token": goodToken, "value": "John"], + ["value": "no token here"], + "not even a dictionary", + ["token": 42, "value": "token is not a String"] + ], + "errors": [] + ] + + let goodElement = self.container.create(input: RevealElementInput(token: goodToken, label: "good"), options: RevealElementOptions()) + self.revealValueCallback.revealElements = [goodElement] + + self.revealValueCallback.onSuccess(response) + wait(for: [self.expectation], timeout: 20.0) + waitForUIUpdates() + + let records = self.callback.data["records"] as! [[String: Any]] + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0]["token"] as? String, goodToken) + XCTAssertEqual(goodElement.actualValue, "John") + } + func testRevealValueOnFailure() { let successToken = "123" let failureToken = "1234" @@ -179,8 +388,9 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { wait(for: [self.expectation], timeout: 20.0) waitForUIUpdates() - let errors = self.callback.data["errors"] as! [[String: String]] - let records = self.callback.data["success"] as! [[String: String]] + let allRecords = self.callback.data["records"] as! [[String: String]] + let errors = allRecords.filter { $0["error"] != nil } + let records = allRecords.filter { $0["error"] == nil } XCTAssertEqual(errors.count, 1) XCTAssertEqual(errors[0]["token"], failureToken) @@ -198,8 +408,7 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { let successToken = "123" let failureToken = "1234" let response = [ - "records": [["token": successToken, "value": "John"]], - "errors": [["token": failureToken, "error": "Invalid Token"]] + "records": [["token": successToken, "value": "John"], ["token": failureToken, "error": "Invalid Token"]] ] let successElement = self.container.create(input: RevealElementInput(token: successToken, label: "name"), options: RevealElementOptions()) @@ -211,8 +420,9 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { wait(for: [self.expectation], timeout: 20.0) waitForUIUpdates() - let errors = self.callback.data["errors"] as! [[String: String]] - let records = self.callback.data["success"] as! [[String: String]] + let allRecords = self.callback.data["records"] as! [[String: String]] + let errors = allRecords.filter { $0["error"] != nil } + let records = allRecords.filter { $0["error"] == nil } XCTAssertEqual(errors.count, 1) XCTAssertEqual(errors[0]["token"], failureToken) @@ -229,8 +439,7 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { let successToken = "123" let failureToken = "1234" let response = [ - "records": [["token": successToken, "value": "4567890"]], - "errors": [["token": failureToken, "error": "Invalid Token"]] + "records": [["token": successToken, "value": "4567890"], ["token": failureToken, "error": "Invalid Token"]] ] let successElement = self.container.create(input: RevealElementInput(token: successToken, label: "name"), options: RevealElementOptions(format: "XXX-XXX-X", translation: ["X": "[0-9]"])) @@ -242,8 +451,9 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { wait(for: [self.expectation], timeout: 20.0) waitForUIUpdates() - let errors = self.callback.data["errors"] as! [[String: String]] - let records = self.callback.data["success"] as! [[String: String]] + let allRecords = self.callback.data["records"] as! [[String: String]] + let errors = allRecords.filter { $0["error"] != nil } + let records = allRecords.filter { $0["error"] == nil } XCTAssertEqual(errors.count, 1) XCTAssertEqual(errors[0]["token"], failureToken) @@ -262,8 +472,7 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { let successToken = "123" let failureToken = "1234" let response = [ - "records": [["token": successToken, "value": "12345678"]], - "errors": [["token": failureToken, "error": "Invalid Token"]] + "records": [["token": successToken, "value": "12345678"], ["token": failureToken, "error": "Invalid Token"]] ] let successElement = self.container.create(input: RevealElementInput(token: successToken, label: "name"), options: RevealElementOptions(format: "XXX-XXX-XXX", translation: ["X": "[0-9]"])) @@ -275,8 +484,9 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { wait(for: [self.expectation], timeout: 20.0) waitForUIUpdates() - let errors = self.callback.data["errors"] as! [[String: String]] - let records = self.callback.data["success"] as! [[String: String]] + let allRecords = self.callback.data["records"] as! [[String: String]] + let errors = allRecords.filter { $0["error"] != nil } + let records = allRecords.filter { $0["error"] == nil } XCTAssertEqual(errors.count, 1) XCTAssertEqual(errors[0]["token"], failureToken) @@ -295,8 +505,7 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { let successToken = "123" let failureToken = "1234" let response = [ - "records": [["token": successToken, "value": "12345678"]], - "errors": [["token": failureToken, "error": "Invalid Token"]] + "records": [["token": successToken, "value": "12345678"], ["token": failureToken, "error": "Invalid Token"]] ] let successElement = self.container.create(input: RevealElementInput(token: successToken, label: "name"), options: RevealElementOptions(format: "XXX-XXX", translation: ["X": "[0-9]"])) @@ -308,8 +517,9 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { wait(for: [self.expectation], timeout: 20.0) waitForUIUpdates() - let errors = self.callback.data["errors"] as! [[String: String]] - let records = self.callback.data["success"] as! [[String: String]] + let allRecords = self.callback.data["records"] as! [[String: String]] + let errors = allRecords.filter { $0["error"] != nil } + let records = allRecords.filter { $0["error"] == nil } XCTAssertEqual(errors.count, 1) XCTAssertEqual(errors[0]["token"], failureToken) @@ -329,8 +539,7 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { let successToken = "123" let failureToken = "1234" let response = [ - "records": [["token": successToken, "value": "12345678"]], - "errors": [["token": failureToken, "error": "Invalid Token"]] + "records": [["token": successToken, "value": "12345678"], ["token": failureToken, "error": "Invalid Token"]] ] let successElement = self.container.create(input: RevealElementInput(token: successToken, label: "name"), options: RevealElementOptions(format: "+91 XXX-XXX", translation: ["X": "[0-9]"])) @@ -342,8 +551,9 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { wait(for: [self.expectation], timeout: 20.0) waitForUIUpdates() - let errors = self.callback.data["errors"] as! [[String: String]] - let records = self.callback.data["success"] as! [[String: String]] + let allRecords = self.callback.data["records"] as! [[String: String]] + let errors = allRecords.filter { $0["error"] != nil } + let records = allRecords.filter { $0["error"] == nil } XCTAssertEqual(errors.count, 1) XCTAssertEqual(errors[0]["token"], failureToken) @@ -362,8 +572,7 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { let successToken = "123" let failureToken = "1234" let response = [ - "records": [["token": successToken, "value": "name"]], - "errors": [["token": failureToken, "error": "Invalid Token"]] + "records": [["token": successToken, "value": "name"], ["token": failureToken, "error": "Invalid Token"]] ] let successElement = self.container.create(input: RevealElementInput(token: successToken, label: "name"), options: RevealElementOptions(format: "+91 XXX-XXX", translation: ["X": "[0-9]"])) @@ -375,8 +584,9 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { wait(for: [self.expectation], timeout: 20.0) waitForUIUpdates() - let errors = self.callback.data["errors"] as! [[String: String]] - let records = self.callback.data["success"] as! [[String: String]] + let allRecords = self.callback.data["records"] as! [[String: String]] + let errors = allRecords.filter { $0["error"] != nil } + let records = allRecords.filter { $0["error"] == nil } XCTAssertEqual(errors.count, 1) XCTAssertEqual(errors[0]["token"], failureToken) @@ -395,8 +605,7 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { let successToken = "123" let failureToken = "1234" let response = [ - "records": [["token": successToken, "value": "name"]], - "errors": [["token": failureToken, "error": "Invalid Token"]] + "records": [["token": successToken, "value": "name"], ["token": failureToken, "error": "Invalid Token"]] ] let successElement = self.container.create(input: RevealElementInput(token: successToken, label: "name"), options: RevealElementOptions(format: "+91 XXX-XXX", translation: ["Y": "[0-9]"])) @@ -408,8 +617,9 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { wait(for: [self.expectation], timeout: 20.0) waitForUIUpdates() - let errors = self.callback.data["errors"] as! [[String: String]] - let records = self.callback.data["success"] as! [[String: String]] + let allRecords = self.callback.data["records"] as! [[String: String]] + let errors = allRecords.filter { $0["error"] != nil } + let records = allRecords.filter { $0["error"] == nil } XCTAssertEqual(errors.count, 1) XCTAssertEqual(errors[0]["token"], failureToken) @@ -436,12 +646,111 @@ final class skyflow_iOS_revealUtilTests: XCTestCase { UIWindow().addSubview(element!) - container?.reveal(callback: callback) + container?.reveal(callback: callback.asRevealCallback) wait(for: [expectation], timeout: 20.0) - print(callback.data) - let errors = callback.data["errors"] as! [[String: NSError]] - XCTAssertTrue(errors[0]["error"]!.localizedDescription.contains("Token generated from 'getBearerToken' callback function is invalid")) + // Invalid bearer token isn't scoped to any specific token, so RevealValueCallback routes it + // through onFailure as a Skyflow.SkyflowError rather than folding it into the records array. + XCTAssertTrue(callback.receivedResponse.contains("Token generated from 'getBearerToken' callback function is invalid")) } - + + func testRevealValueOnFailureMixedTokenScopedAndUnscopedErrorsRoutesToOnFailure() { + // Edge case: if a genuine network/API-level error (no "token") arrives in the same batch + // as per-token errors, the whole thing is treated as a whole-request failure - the + // per-token entries are discarded rather than partially surfaced via onSuccess. This + // documents that behavior explicitly, since it's not obvious from reading either branch + // in isolation. + let scopedToken = "123" + let networkError = NSError(domain: "NSURLErrorDomain", code: -1009, userInfo: [NSLocalizedDescriptionKey: "network down"]) + let response: [String: Any] = [ + "errors": [ + ["token": scopedToken, "error": "Invalid Token"], + ["error": networkError] + ] + ] + + let element1 = self.container.create(input: RevealElementInput(token: scopedToken, label: "first")) + self.revealValueCallback.revealElements = [element1] + + self.revealValueCallback.onFailure(response) + wait(for: [self.expectation], timeout: 20.0) + waitForUIUpdates() + + XCTAssertNil(self.callback.data["records"]) + XCTAssertEqual(self.callback.receivedResponse, "network down") + XCTAssertEqual(element1.errorMessage.text, nil) + } + + func testRevealRecordHttpCodeDefaultsToZeroWhenMissing() { + let record = RevealRecord(["token": "abc"]) + XCTAssertEqual(record.httpCode, 0) + XCTAssertNil(record.error) + } + + func testRevealRecordErrorDiscriminatesSuccessFromFailure() { + let success = RevealRecord(["token": "abc", "value": "1234", "httpCode": 200]) + let failure = RevealRecord(["token": "xyz", "error": "Tokens not found", "httpCode": 404]) + + XCTAssertNil(success.error) + XCTAssertEqual(failure.error, "Tokens not found") + XCTAssertEqual(failure.httpCode, 404) + } + + // The vault sends metadata's skyflowId field as "skyflowID" on the wire - RevealRecord + // normalizes it to "skyflowId" so it's consistent with Skyflow.CollectRecord.skyflowId, + // rather than exposing the raw wire casing to consumers. + func testRevealRecordNormalizesSkyflowIDKeyInMetadata() { + let record = RevealRecord(["token": "abc", "httpCode": 200, "metadata": ["skyflowID": "SID", "tableName": "table"]]) + let metadata = record.metadata! + XCTAssertEqual(metadata["skyflowId"] as? String, "SID") + XCTAssertNil(metadata["skyflowID"]) + XCTAssertEqual(metadata["tableName"] as? String, "table") + } + + // Exact metadata shape reported from a real device run: {"skyflowID": "426279b9-...", + // "tableName": "table5"}. + func testRevealRecordNormalizesSkyflowIDKeyInMetadataWithReportedRealWorldValues() { + let record = RevealRecord([ + "token": "abc", + "httpCode": 200, + "metadata": ["skyflowID": "426279b9-efe0-44df-bc39-16839bc86e86", "tableName": "table5"] + ]) + print("metadata:", record.metadata as Any) + let metadata = record.metadata! + XCTAssertEqual(metadata["skyflowId"] as? String, "426279b9-efe0-44df-bc39-16839bc86e86") + XCTAssertNil(metadata["skyflowID"]) + XCTAssertEqual(metadata["tableName"] as? String, "table5") + } + + func testRevealResponseInitReturnsNilForMalformedBody() { + XCTAssertNil(RevealResponse("not a dictionary")) + XCTAssertNil(RevealResponse(["typo": []])) + XCTAssertNil(RevealResponse(["records": "not an array"])) + } + + // Verifies the real end-to-end wiring for a whole-request failure through + // FlowVaultRevealAPICallback -> callRevealOnFailure -> LogCallback -> RevealCallback -> + // SkyflowError.wrap, using a token provider that fails immediately (no network mocking + // needed - TokenAPICallback's failure reaches the exact same callRevealOnFailure wrapping + // as a real dispatch failure would). This is the plumbing that had a real bug (SkyflowError.wrap + // losing the message entirely) until it was found and fixed earlier via a unit test on + // SkyflowError.wrap directly - this test instead confirms the full real call chain, not just + // the isolated function. + func testDetokenizeTokenProviderFailureSurfacesAsSkyflowErrorThroughRealCallChain() { + class FailingTokenProvider: TokenProvider { + func getBearerToken(_ apiCallback: Callback) { + apiCallback.onFailure(NSError(domain: "", code: 500, userInfo: [NSLocalizedDescriptionKey: "TokenProvider error"])) + } + } + let client = Client(Configuration(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: FailingTokenProvider())) + let expectation = XCTestExpectation(description: "TokenProvider failure surfaces as SkyflowError") + let callback = DemoAPICallback(expectation: expectation) + + client.detokenize(records: ["records": [["token": "tok1"]]], callback: callback.asRevealCallback) + + wait(for: [expectation], timeout: 10.0) + + XCTAssertEqual(callback.receivedResponse, "TokenProvider error") + } + } diff --git a/Tests/skyflow-iOS-utilTests/skyflow_iOS_utilTests.swift b/Tests/skyflow-iOS-utilTests/skyflow_iOS_utilTests.swift index 6e872245..9ee85ed9 100644 --- a/Tests/skyflow-iOS-utilTests/skyflow_iOS_utilTests.swift +++ b/Tests/skyflow-iOS-utilTests/skyflow_iOS_utilTests.swift @@ -61,30 +61,171 @@ final class skyflow_iOS_utilTests: XCTestCase { textField.addAndFormatText(str) XCTAssertEqual(textField.secureText, "4111 1111 1111 1111") } - func testConstructRequestWithTokens() { - let apiClient = APIClient(vaultID: "", vaultURL: "", tokenProvider: DemoTokenProvider()) - let result = apiClient.constructBatchRequestBody(records: ["records": [["table": "table", "fields": ["field1": "value1"]]]], options: ICOptions(tokens: true)) - let record = result["records"] as! [[String: Any]] - let id = record[1]["ID"] as! String - let tokenization = record[1]["tokenization"] as! Bool - XCTAssertEqual(id, "$responses.0.records.0.skyflow_id") - XCTAssertTrue(tokenization) + func testConstructV2RequestBody() { + let result = FlowVaultInsertRequestBody.createRequestBody(vaultID: "vault123", records: ["records": [["table": "table", "fields": ["field1": "value1"]]]], options: FlowVaultICOptions()) + XCTAssertEqual(result["vaultID"] as! String, "vault123") + let records = result["records"] as! [[String: Any]] + XCTAssertEqual(records[0]["tableName"] as! String, "table") + XCTAssertEqual(records[0]["data"] as! [String: String], ["field1": "value1"]) } - - func testConstructUpdateRequestBody() { - let apiClient = APIClient(vaultID: "vault123", vaultURL: "https://example.com", tokenProvider: DemoTokenProvider()) - - let updateRecords: [String: Any] = [ - "update": [ - ["table": "users", "fields": ["name": "John Doe", "email": "john.doe@example.com"], "skyflowID": "id123"], - ["table": "users", "fields": ["name": "Jane Doe", "email": "jane.doe@example.com"], "skyflowID": "id124"] - ] + + func testConstructV2RequestBodyWithUpsert() { + let upsert = [UpsertOption(tableName: "table", uniqueColumns: ["field1"], updateType: .REPLACE)] + let result = FlowVaultInsertRequestBody.createRequestBody(vaultID: "vault123", records: ["records": [["table": "table", "fields": ["field1": "value1"]]]], options: FlowVaultICOptions(upsert: upsert)) + let records = result["records"] as! [[String: Any]] + let upsertPayload = records[0]["upsert"] as! [String: Any] + XCTAssertEqual(upsertPayload["uniqueColumns"] as! [String], ["field1"]) + XCTAssertEqual(upsertPayload["updateType"] as! String, "REPLACE") + } + + // Not exercised by any current call site (UpsertOption always supplies a concrete + // updateType today), but the nil default is part of the public API - confirms + // "updateType" is omitted from the wire payload rather than serialized as null. + func testConstructV2RequestBodyWithUpsertAndNilUpdateType() { + let upsert = [UpsertOption(tableName: "table", uniqueColumns: ["field1"])] + let result = FlowVaultInsertRequestBody.createRequestBody(vaultID: "vault123", records: ["records": [["table": "table", "fields": ["field1": "value1"]]]], options: FlowVaultICOptions(upsert: upsert)) + let records = result["records"] as! [[String: Any]] + let upsertPayload = records[0]["upsert"] as! [String: Any] + XCTAssertEqual(upsertPayload["uniqueColumns"] as! [String], ["field1"]) + XCTAssertNil(upsertPayload["updateType"]) + } + + // Dormant v1 PDB helpers on APIClient (untyped upsert), kept for potential future PDB reuse. + + func testGetUniqueColumn() { + let apiClient = APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()) + let upsert: [[String: Any]] = [ + ["table": "table1", "column": "email"], + ["table": "table2", "column": "ssn"] ] - let singleUpdateRecord = ["table": "users", "fields": ["name": "John Doe", "email": "john.doe@example.com"], "skyflowID": "id123"] as [String : Any] - let result = apiClient.constructUpdateRequestBody(records: singleUpdateRecord, options: ICOptions(tokens: false)) - let records = result["record"] as! [String: Any] - let tokenization = result["tokenization"] as! Bool - XCTAssertEqual(records as NSDictionary, ["fields": ["name": "John Doe", "email": "john.doe@example.com"]]) - XCTAssertFalse(tokenization) + + XCTAssertEqual(apiClient.getUniqueColumn(tableName: "table1", upsert: upsert), "email") + XCTAssertEqual(apiClient.getUniqueColumn(tableName: "table2", upsert: upsert), "ssn") + XCTAssertEqual(apiClient.getUniqueColumn(tableName: "table3", upsert: upsert), "") + } + + func testConstructBatchRequestBodyWithUpsert() { + let apiClient = APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()) + let records: [String: Any] = ["records": [["table": "table1", "fields": ["email": "a@b.com"]]]] + let upsert: [[String: Any]] = [["table": "table1", "column": "email"]] + let options = ICOptions(tokens: false, upsert: upsert) + + let result = apiClient.constructBatchRequestBody(records: records, options: options) + let postPayload = result["records"] as! [[String: Any]] + + XCTAssertEqual(postPayload.count, 1) + XCTAssertEqual(postPayload[0]["upsert"] as? String, "email") + XCTAssertEqual(postPayload[0]["tableName"] as? String, "table1") + XCTAssertEqual(postPayload[0]["method"] as? String, "POST") + XCTAssertEqual(postPayload[0]["quorum"] as? Bool, true) + } + + func testConstructBatchRequestBodyWithTokensAddsGetStep() { + let apiClient = APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()) + let records: [String: Any] = ["records": [["table": "table1", "fields": ["email": "a@b.com"]]]] + let options = ICOptions(tokens: true) + + let result = apiClient.constructBatchRequestBody(records: records, options: options) + let postPayload = result["records"] as! [[String: Any]] + + // One POST insert entry, plus one GET tokenization entry appended after it. + XCTAssertEqual(postPayload.count, 2) + XCTAssertEqual(postPayload[0]["method"] as? String, "POST") + XCTAssertEqual(postPayload[1]["method"] as? String, "GET") + XCTAssertEqual(postPayload[1]["tokenization"] as? Bool, true) + XCTAssertEqual(postPayload[1]["ID"] as? String, "$responses.0.records.0.skyflow_id") + } + + func testConstructBatchRequestBodyNoUpsertNoTokens() { + let apiClient = APIClient(vaultID: "vault", vaultURL: "https://example.org/", tokenProvider: DemoTokenProvider()) + let records: [String: Any] = ["records": [["table": "table1", "fields": ["email": "a@b.com"]]]] + let options = ICOptions(tokens: false) + + let result = apiClient.constructBatchRequestBody(records: records, options: options) + let postPayload = result["records"] as! [[String: Any]] + + XCTAssertEqual(postPayload.count, 1) + XCTAssertNil(postPayload[0]["upsert"]) + } + + // Dormant v1 ICOptions.validateUpsert() (untyped upsert dicts), kept for potential future + // PDB reuse. Distinct from FlowVaultICOptions.validateUpsert(), which covers the live path. + + func testICOptionsValidateUpsertValid() { + let callback = DemoAPICallback(expectation: XCTestExpectation()) + let upsert: [[String: Any]] = [["table": "table1", "column": "email"]] + let options = ICOptions(tokens: false, upsert: upsert, callback: callback, contextOptions: ContextOptions()) + + XCTAssertFalse(options.validateUpsert()) + } + + func testICOptionsValidateUpsertEmptyArray() { + let expectation = XCTestExpectation() + let callback = DemoAPICallback(expectation: expectation) + let options = ICOptions(tokens: false, upsert: [], callback: callback, contextOptions: ContextOptions()) + + XCTAssertTrue(options.validateUpsert()) + wait(for: [expectation], timeout: 10.0) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.UPSERT_OPTION_CANNOT_BE_EMPTY().getErrorObject(contextOptions: ContextOptions()).localizedDescription) + } + + func testICOptionsValidateUpsertMissingTableKey() { + let expectation = XCTestExpectation() + let callback = DemoAPICallback(expectation: expectation) + let upsert: [[String: Any]] = [["column": "email"]] + let options = ICOptions(tokens: false, upsert: upsert, callback: callback, contextOptions: ContextOptions()) + + XCTAssertTrue(options.validateUpsert()) + wait(for: [expectation], timeout: 10.0) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.MISSING_TABLE_NAME_IN_USERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions()).localizedDescription) + } + + func testICOptionsValidateUpsertMissingColumnKey() { + let expectation = XCTestExpectation() + let callback = DemoAPICallback(expectation: expectation) + let upsert: [[String: Any]] = [["table": "table1"]] + let options = ICOptions(tokens: false, upsert: upsert, callback: callback, contextOptions: ContextOptions()) + + XCTAssertTrue(options.validateUpsert()) + wait(for: [expectation], timeout: 10.0) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.MISSING_COLUMN_NAME_IN_USERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions()).localizedDescription) + } + + func testICOptionsValidateUpsertEmptyTableValue() { + let expectation = XCTestExpectation() + let callback = DemoAPICallback(expectation: expectation) + let upsert: [[String: Any]] = [["table": "", "column": "email"]] + let options = ICOptions(tokens: false, upsert: upsert, callback: callback, contextOptions: ContextOptions()) + + XCTAssertTrue(options.validateUpsert()) + wait(for: [expectation], timeout: 10.0) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.TABLE_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions()).localizedDescription) + } + + func testICOptionsValidateUpsertEmptyColumnValue() { + let expectation = XCTestExpectation() + let callback = DemoAPICallback(expectation: expectation) + let upsert: [[String: Any]] = [["table": "table1", "column": ""]] + let options = ICOptions(tokens: false, upsert: upsert, callback: callback, contextOptions: ContextOptions()) + + XCTAssertTrue(options.validateUpsert()) + wait(for: [expectation], timeout: 10.0) + XCTAssertEqual(callback.receivedResponse, ErrorCodes.COLUMN_NAME_IS_EMPTY_FOR_ATLEAST_ONE_UPSERT_OPTION(value: "0").getErrorObject(contextOptions: ContextOptions()).localizedDescription) + } + + func testFlowVaultUpdateRequestBody() { + let records: [[String: Any]] = [ + ["skyflowID": "id1", "tableName": "table1", "data": ["email": "a@b.com"]], + ["skyflowID": "id2", "tableName": "table2", "data": ["name": "chiku"]] + ] + + let result = FlowVaultUpdateRequestBody.createRequestBody(vaultID: "vault123", records: records) + + XCTAssertEqual(result["vaultID"] as? String, "vault123") + let resultRecords = result["records"] as! [[String: Any]] + XCTAssertEqual(resultRecords.count, 2) + XCTAssertEqual(resultRecords[0]["skyflowID"] as? String, "id1") + XCTAssertEqual(resultRecords[0]["tableName"] as? String, "table1") + XCTAssertEqual(resultRecords[1]["tableName"] as? String, "table2") } }