Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: Add interfaces and types for bulk import #138

Closed
wants to merge 11 commits into from
Closed
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.supertokens.pluginInterface.bulkimport;

import java.util.ArrayList;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import com.google.gson.JsonObject;

import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.AppIdentifier;
import io.supertokens.pluginInterface.nonAuthRecipe.NonAuthRecipeStorage;

public interface BulkImportStorage extends NonAuthRecipeStorage {
/**
* Add users to the bulk_import_users table
*/
void addBulkImportUsers(AppIdentifier appIdentifier, ArrayList<BulkImportUser> users)
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
throws StorageQueryException,
io.supertokens.pluginInterface.bulkimport.exceptions.DuplicateUserIdException;
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved

/**
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
* Get users from the bulk_import_users table
*/
JsonObject[] getBulkImportUsers(AppIdentifier appIdentifier, @Nonnull Integer limit, @Nullable String status,
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
@Nullable String bulkImportUserId) throws StorageQueryException;

/**
* Delete users by id from the bulk_import_users table
*/
// void deleteBulkImportUsers(AppIdentifier appIdentifier, @Nullable ArrayList<String> bulkImportUserIds)
// throws StorageQueryException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
/*
* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package io.supertokens.pluginInterface.bulkimport;

import java.util.List;
import java.util.UUID;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;

import io.supertokens.pluginInterface.bulkimport.exceptions.InvalidBulkImportDataException;

import java.util.ArrayList;
import java.util.Arrays;

public class BulkImportUser {
public String id;
public JsonObject userData;
public String externalUserId;
public JsonObject userMetadata;
public List<String> userRoles;
public List<TotpDevice> totpDevices;
public List<LoginMethod> loginMethods;
public ArrayList<String> errors = new ArrayList<>();

public BulkImportUser(JsonObject userData, String id) throws InvalidBulkImportDataException {
this.id = id != null ? id : UUID.randomUUID().toString();
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
this.userData = userData;
this.externalUserId = parseAndValidateField(userData, "externalUserId", ValueType.STRING, false, String.class,
".");
this.userMetadata = parseAndValidateField(userData, "userMetadata", ValueType.OBJECT, false, JsonObject.class,
".");
this.userRoles = getParsedUserRoles(userData);
this.totpDevices = getParsedTotpDevices(userData);
this.loginMethods = getParsedLoginMethods(userData);

if (errors.size() > 0) {
throw new InvalidBulkImportDataException(errors);
}
}

public String toString() {
return this.userData.toString();
}

private ArrayList<String> getParsedUserRoles(JsonObject userData) {
JsonArray jsonUserRoles = parseAndValidateField(userData, "roles", ValueType.ARRAY_OF_STRING, false,
JsonArray.class, ".");

if (jsonUserRoles == null) {
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
return null;
}

ArrayList<String> userRoles = new ArrayList<>();
jsonUserRoles.forEach(role -> userRoles.add(role.getAsString()));
return userRoles;
}

private ArrayList<TotpDevice> getParsedTotpDevices(JsonObject userData) {
JsonArray jsonTotpDevices = parseAndValidateField(userData, "totp", ValueType.ARRAY_OF_OBJECT, false,
JsonArray.class, ".");
if (jsonTotpDevices == null) {
return null;
}

ArrayList<TotpDevice> totpDevices = new ArrayList<>();
for (JsonElement jsonTotpDevice : jsonTotpDevices) {
totpDevices.add(new TotpDevice(jsonTotpDevice.getAsJsonObject()));
}
return totpDevices;
}

private ArrayList<LoginMethod> getParsedLoginMethods(JsonObject userData) {
JsonArray jsonLoginMethods = parseAndValidateField(userData, "loginMethods", ValueType.ARRAY_OF_OBJECT, true,
JsonArray.class, ".");

if (jsonLoginMethods == null) {
return new ArrayList<>();
}

if (jsonLoginMethods.size() == 0) {
errors.add("At least one loginMethod is required.");
return new ArrayList<>();
}

Boolean hasPrimaryLoginMethod = false;

ArrayList<LoginMethod> loginMethods = new ArrayList<>();
for (JsonElement jsonLoginMethod : jsonLoginMethods) {
JsonObject jsonLoginMethodObj = jsonLoginMethod.getAsJsonObject();

if (validateJsonFieldType(jsonLoginMethodObj, "isPrimary", ValueType.BOOLEAN)) {
if (jsonLoginMethodObj.get("isPrimary").getAsBoolean()) {
if (hasPrimaryLoginMethod) {
errors.add("No two loginMethods can have isPrimary as true.");
}
hasPrimaryLoginMethod = true;
}
}

loginMethods.add(new LoginMethod(jsonLoginMethodObj));
}

return loginMethods;
}

@SuppressWarnings("unchecked")
private <T> T parseAndValidateField(JsonObject jsonObject, String key, ValueType expectedType, boolean isRequired,
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
Class<T> targetType, String errorSuffix) {
if (jsonObject.has(key)) {
if (validateJsonFieldType(jsonObject, key, expectedType)) {
T value;
switch (expectedType) {
case STRING:
value = (T) jsonObject.get(key).getAsString();
break;
case NUMBER:
value = (T) jsonObject.get(key).getAsNumber();
break;
case BOOLEAN:
Boolean boolValue = jsonObject.get(key).getAsBoolean();
value = (T) boolValue;
break;
case OBJECT:
value = (T) jsonObject.get(key).getAsJsonObject();
break;
case ARRAY_OF_OBJECT, ARRAY_OF_STRING:
value = (T) jsonObject.get(key).getAsJsonArray();
break;
default:
value = null;
break;
}
if (value != null) {
return targetType.cast(value);
} else {
errors.add(key + " should be of type " + getTypeForErrorMessage(expectedType) + errorSuffix);
}
} else {
errors.add(key + " should be of type " + getTypeForErrorMessage(expectedType) + errorSuffix);
}
} else if (isRequired) {
errors.add(key + " is required" + errorSuffix);
}
return null;
}

public enum ValueType {
STRING,
NUMBER,
BOOLEAN,
OBJECT,
ARRAY_OF_STRING,
ARRAY_OF_OBJECT
}

private String getTypeForErrorMessage(ValueType type) {
return switch (type) {
case STRING -> "string";
case NUMBER -> "number";
case BOOLEAN -> "boolean";
case OBJECT -> "object";
case ARRAY_OF_STRING -> "array of string";
case ARRAY_OF_OBJECT -> "array of object";
};
}

private Boolean validateJsonFieldType(JsonObject jsonObject, String key, ValueType expectedType) {
if (jsonObject.has(key)) {
return switch (expectedType) {
case STRING -> jsonObject.get(key).isJsonPrimitive() && jsonObject.getAsJsonPrimitive(key).isString();
case NUMBER -> jsonObject.get(key).isJsonPrimitive() && jsonObject.getAsJsonPrimitive(key).isNumber();
case BOOLEAN -> jsonObject.get(key).isJsonPrimitive() && jsonObject.getAsJsonPrimitive(key).isBoolean();
case OBJECT -> jsonObject.get(key).isJsonObject();
case ARRAY_OF_OBJECT, ARRAY_OF_STRING -> jsonObject.get(key).isJsonArray()
&& validateArrayElements(jsonObject.getAsJsonArray(key), expectedType);
default -> false;
};
}
return false;
}

private boolean validateArrayElements(JsonArray array, ValueType expectedType) {
List<JsonElement> elements = new ArrayList<>();
array.forEach(elements::add);

return switch (expectedType) {
case ARRAY_OF_OBJECT -> elements.stream().allMatch(JsonElement::isJsonObject);
case ARRAY_OF_STRING ->
elements.stream().allMatch(el -> el.isJsonPrimitive() && el.getAsJsonPrimitive().isString());
default -> false;
};
}

public class TotpDevice {
public String secretKey;
public Number period;
public Number skew;
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
public String deviceName;

public TotpDevice(JsonObject jsonTotpDevice) {
this.secretKey = parseAndValidateField(jsonTotpDevice, "secretKey", ValueType.STRING, true, String.class,
" for a totp device.");
this.period = parseAndValidateField(jsonTotpDevice, "period", ValueType.NUMBER, true, Number.class,
" for a totp device.");
this.skew = parseAndValidateField(jsonTotpDevice, "skew", ValueType.NUMBER, true, Number.class,
" for a totp device.");
this.deviceName = parseAndValidateField(jsonTotpDevice, "deviceName", ValueType.STRING, false, String.class,
" for a totp device.");
}
}

public class LoginMethod {
public String tenantId;
public Boolean isVerified;
public Boolean isPrimary;
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
public long timeJoinedInMSSinceEpoch;
public String recipeId;

public EmailPasswordLoginMethod emailPasswordLoginMethod;
public ThirdPartyLoginMethod thirdPartyLoginMethod;
public PasswordlessLoginMethod passwordlessLoginMethod;

public LoginMethod(JsonObject jsonLoginMethod) {
this.recipeId = parseAndValidateField(jsonLoginMethod, "recipeId", ValueType.STRING, true, String.class,
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
" for a loginMethod.");
this.tenantId = parseAndValidateField(jsonLoginMethod, "tenantId", ValueType.STRING, false, String.class,
" for a loginMethod.");
this.isVerified = parseAndValidateField(jsonLoginMethod, "isVerified", ValueType.BOOLEAN, false,
Boolean.class, " for a loginMethod.");
this.isPrimary = parseAndValidateField(jsonLoginMethod, "isPrimary", ValueType.BOOLEAN, false,
Boolean.class, " for a loginMethod.");
Number timeJoined = parseAndValidateField(jsonLoginMethod, "timeJoinedInMSSinceEpoch", ValueType.NUMBER,
false, Number.class, " for a loginMethod");
this.timeJoinedInMSSinceEpoch = timeJoined != null ? timeJoined.longValue() : 0;

if ("emailpassword".equals(this.recipeId)) {
this.emailPasswordLoginMethod = new EmailPasswordLoginMethod(jsonLoginMethod);
} else if ("thirdparty".equals(this.recipeId)) {
this.thirdPartyLoginMethod = new ThirdPartyLoginMethod(jsonLoginMethod);
} else if ("passwordless".equals(this.recipeId)) {
this.passwordlessLoginMethod = new PasswordlessLoginMethod(jsonLoginMethod);
} else if (this.recipeId != null) {
errors.add(
"Invalid recipeId for loginMethod. Pass one of emailpassword, thirdparty or, passwordless!");
}
}

public class EmailPasswordLoginMethod {
public String email;
public String passwordHash;
public String hashingAlgorithm;

public EmailPasswordLoginMethod(JsonObject jsonLoginMethod) {
this.email = parseAndValidateField(jsonLoginMethod, "email", ValueType.STRING, true, String.class,
" for an emailpassword recipe.");
this.passwordHash = parseAndValidateField(jsonLoginMethod, "passwordHash", ValueType.STRING, true,
String.class, " for an emailpassword recipe.");
this.hashingAlgorithm = parseAndValidateField(jsonLoginMethod, "hashingAlgorithm", ValueType.STRING,
true, String.class, " for an emailpassword recipe.");
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved

if (this.hashingAlgorithm != null && !Arrays.asList("bcrypt", "argon2", "firebase_scrypt").contains(hashingAlgorithm)) {
errors.add(
"Invalid hashingAlgorithm for emailpassword recipe. Pass one of bcrypt, argon2 or, firebase_scrypt!");
}
}
}

public class ThirdPartyLoginMethod {
public String email;
public String thirdPartyId;
public String thirdPartyUserId;

public ThirdPartyLoginMethod(JsonObject jsonObject) {
this.email = parseAndValidateField(jsonObject, "email", ValueType.STRING, true, String.class,
" for a thirdparty recipe.");
this.thirdPartyId = parseAndValidateField(jsonObject, "thirdPartyId", ValueType.STRING, true,
String.class, " for a thirdparty recipe.");
this.thirdPartyUserId = parseAndValidateField(jsonObject, "thirdPartyUserId", ValueType.STRING, true,
String.class, " for a thirdparty recipe.");
}
}

public class PasswordlessLoginMethod {
public String email;
public String phoneNumber;

public PasswordlessLoginMethod(JsonObject jsonObject) {
this.email = parseAndValidateField(jsonObject, "email", ValueType.STRING, false, String.class,
" for a passwordless recipe.");
this.phoneNumber = parseAndValidateField(jsonObject, "phoneNumber", ValueType.STRING, false,
String.class, " for a passwordless recipe.");

if ((email != null && email.isEmpty()) && (phoneNumber != null && phoneNumber.isEmpty())) {
errors.add(
"Either email or phoneNumber is required for a passwordless recipe.");
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package io.supertokens.pluginInterface.bulkimport.exceptions;

public class DuplicateUserIdException extends Exception {
private static final long serialVersionUID = 6848053563771647272L;
}
Loading
Loading