From 91e0b7307f8b6cc5d210f8bfc924d2899129a060 Mon Sep 17 00:00:00 2001 From: Kristian Madsen <110046241+kmadsenbz@users.noreply.github.com> Date: Fri, 12 Apr 2024 14:37:11 -0600 Subject: [PATCH] DEV-23981: Make public release b2-sdk-java with event notifications feature (#2) Merging private changes to public SDK Added support for Event Notifications Lifecycle rule for cancelling unfinished large files Performance improvements --- CHANGELOG.md | 47 +- build.gradle.kts | 2 +- buildSrc/src/main/kotlin/b2sdk.gradle.kts | 2 +- check_code | 2 +- core-test-jdk17/build.gradle.kts | 29 + core-test-jdk17/src/main/.gitignore | 4 + .../json/B2JsonDeserializationUtilTest.java | 36 ++ .../com/backblaze/b2/json/B2JsonTest.java | 211 ++++++++ .../com/backblaze/b2/util/B2BaseTest.java | 29 + .../client/B2AccountAuthorizationCache.java | 56 +- .../b2/client/B2AlreadyStoredPartStorer.java | 4 +- .../B2ByteProgressFilteringListener.java | 4 +- .../backblaze/b2/client/B2ClientConfig.java | 39 +- .../b2/client/B2CopyingPartStorer.java | 4 +- .../b2/client/B2LargeFileStorer.java | 269 +++++++--- .../com/backblaze/b2/client/B2PartStorer.java | 5 +- .../com/backblaze/b2/client/B2Retryer.java | 3 +- .../java/com/backblaze/b2/client/B2Sdk.java | 3 +- .../backblaze/b2/client/B2StorageClient.java | 158 +++++- .../b2/client/B2StorageClientImpl.java | 64 ++- .../b2/client/B2StorageClientWebifier.java | 14 +- .../client/B2StorageClientWebifierImpl.java | 34 +- .../b2/client/B2UploadingPartStorer.java | 4 +- .../b2/client/contentSources/B2Headers.java | 2 + .../B2SignatureVerificationException.java | 18 + .../b2/client/structures/B2Bucket.java | 11 +- .../b2/client/structures/B2Capabilities.java | 5 +- .../structures/B2CreateBucketRequest.java | 2 +- .../structures/B2CreateBucketRequestReal.java | 5 +- .../structures/B2EventNotification.java | 175 ++++++ .../structures/B2EventNotificationEvent.java | 190 +++++++ .../structures/B2EventNotificationRule.java | 175 ++++++ ...2EventNotificationTargetConfiguration.java | 23 + .../B2GetBucketNotificationRulesRequest.java | 60 +++ .../B2GetBucketNotificationRulesResponse.java | 76 +++ .../b2/client/structures/B2LifecycleRule.java | 56 +- .../B2SetBucketNotificationRulesRequest.java | 77 +++ .../B2SetBucketNotificationRulesResponse.java | 76 +++ .../structures/B2StartLargeFileRequest.java | 34 +- .../structures/B2StoreLargeFileRequest.java | 63 ++- .../structures/B2UpdateBucketRequest.java | 6 +- .../structures/B2UploadFileRequest.java | 21 +- .../structures/B2WebhookConfiguration.java | 94 ++++ .../structures/B2WebhookCustomHeader.java | 85 +++ .../java/com/backblaze/b2/json/B2Json.java | 229 ++++++-- .../b2/json/B2JsonAtomicLongArrayHandler.java | 69 +++ .../b2/json/B2JsonDeserializationUtil.java | 78 +++ .../backblaze/b2/json/B2JsonHandlerMap.java | 220 ++++---- .../b2/json/B2JsonInitializedTypeHandler.java | 18 +- .../b2/json/B2JsonObjectHandler.java | 203 ++++--- .../com/backblaze/b2/json/B2JsonReader.java | 2 +- .../json/B2JsonTypeHandlerWithDefaults.java | 17 +- .../b2/json/B2JsonUnionBaseHandler.java | 74 ++- .../java/com/backblaze/b2/json/FieldInfo.java | 29 +- .../java/com/backblaze/b2/util/B2Clock.java | 10 +- .../com/backblaze/b2/util/B2DateTimeUtil.java | 61 ++- .../com/backblaze/b2/util/B2StringUtil.java | 2 +- .../b2/client/B2CopyingPartStorerTest.java | 6 +- .../b2/client/B2LargeFileStorerTest.java | 251 +++++++-- .../b2/client/B2StorageClientImplTest.java | 125 ++++- .../B2StorageClientWebifierImplTest.java | 139 ++++- .../backblaze/b2/client/B2TestHelpers.java | 3 +- .../b2/client/B2UploadingPartStorerTest.java | 4 +- .../structures/B2ApplicationKeyTest.java | 2 + .../b2/client/structures/B2BucketTest.java | 5 +- .../B2CreateBucketRequestRealTest.java | 3 +- .../structures/B2CustomWebhookHeaderTest.java | 34 ++ .../B2EventNotificationEventTest.java | 175 ++++++ .../B2EventNotificationRuleTest.java | 78 +++ .../structures/B2EventNotificationTest.java | 232 ++++++++ ...GetBucketNotificationRulesRequestTest.java | 42 ++ ...etBucketNotificationRulesResponseTest.java | 93 ++++ .../structures/B2LifecycleRuleTest.java | 17 +- ...SetBucketNotificationRulesRequestTest.java | 115 ++++ ...etBucketNotificationRulesResponseTest.java | 118 ++++ .../structures/B2UpdateBucketRequestTest.java | 3 +- .../B2WebhookConfigurationTest.java | 80 +++ .../json/B2JsonDeserializationUtilTest.java | 151 ++++++ .../b2/json/B2JsonInferredParametersTest.java | 21 + .../com/backblaze/b2/json/B2JsonTest.java | 502 ++++++++++++++++-- .../b2/json/B2JsonUnionBaseHandlerTest.java | 128 +++++ gradle.properties | 2 +- settings.gradle.kts | 2 +- 83 files changed, 5105 insertions(+), 515 deletions(-) create mode 100644 core-test-jdk17/build.gradle.kts create mode 100644 core-test-jdk17/src/main/.gitignore create mode 100644 core-test-jdk17/src/test/java/com/backblaze/b2/json/B2JsonDeserializationUtilTest.java create mode 100644 core-test-jdk17/src/test/java/com/backblaze/b2/json/B2JsonTest.java create mode 100644 core-test-jdk17/src/test/java/com/backblaze/b2/util/B2BaseTest.java create mode 100644 core/src/main/java/com/backblaze/b2/client/exceptions/B2SignatureVerificationException.java create mode 100644 core/src/main/java/com/backblaze/b2/client/structures/B2EventNotification.java create mode 100644 core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationEvent.java create mode 100644 core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationRule.java create mode 100644 core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationTargetConfiguration.java create mode 100644 core/src/main/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesRequest.java create mode 100644 core/src/main/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesResponse.java create mode 100644 core/src/main/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesRequest.java create mode 100644 core/src/main/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesResponse.java create mode 100644 core/src/main/java/com/backblaze/b2/client/structures/B2WebhookConfiguration.java create mode 100644 core/src/main/java/com/backblaze/b2/client/structures/B2WebhookCustomHeader.java create mode 100644 core/src/main/java/com/backblaze/b2/json/B2JsonAtomicLongArrayHandler.java create mode 100644 core/src/main/java/com/backblaze/b2/json/B2JsonDeserializationUtil.java create mode 100644 core/src/test/java/com/backblaze/b2/client/structures/B2CustomWebhookHeaderTest.java create mode 100644 core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationEventTest.java create mode 100644 core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationRuleTest.java create mode 100644 core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationTest.java create mode 100644 core/src/test/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesRequestTest.java create mode 100644 core/src/test/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesResponseTest.java create mode 100644 core/src/test/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesRequestTest.java create mode 100644 core/src/test/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesResponseTest.java create mode 100644 core/src/test/java/com/backblaze/b2/client/structures/B2WebhookConfigurationTest.java create mode 100644 core/src/test/java/com/backblaze/b2/json/B2JsonDeserializationUtilTest.java create mode 100644 core/src/test/java/com/backblaze/b2/json/B2JsonUnionBaseHandlerTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 890162d80..e342058db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,50 @@ # Changelog ## [Unreleased] - TBD +### Added +* Added support to specify B2Json union types using annotations. Annotation support for union types is required because + Java records do not support inheritance. Example usage: + ```java + @B2Json.union(typeField = "type") + @B2Json.unionSubtypes({ + @B2Json.unionSubtypes.type(name = "email", clazz = Email.class), + @B2Json.unionSubtypes.type(name = "sms", clazz = Sms.class) + }) + sealed interface Message permits Email, Sms { + String subject(); + } + + @B2Json.type + private record Email(@B2Json.required String subject, @B2Json.required String email) implements Message { + } + + @B2Json.type + private record Sms(@B2Json.required String subject, @B2Json.required String phoneNumber) implements Message { + } + ``` +* Added `@B2Json.type` annotation that can be used with Java records. Using `@B2Json.type` allows for the implicit + Java constructor of Java records to not require the `@B2Json.constructor` annotation. Example usage: + ```java + @B2Json.type + record Point(@B2Json.required int x, @B2Json.required int y) { } + ``` +* Optimized B2DateTimeUtil.formatFguidDateTime +* Reduced memory allocation for small input when deserializing byte[] to JSON +* Reduced lock contention in B2Clock +* Added support for B2 Event Notifications +* Added B2Json `fromJson` methods that take a `java.io.Reader` as input for JSON +* Updated B2Json `fromJson` methods to utilize a BufferedReader when deserializing JSON for performance improvement +* Added B2StorageClient.storePartsForLargeFile +* Added support for daysFromStartingToCancelingUnfinishedLargeFiles to B2LifecycleRule +* Reduced lock contention in B2AccountAuthorizationCache +* Added the `serializedName` annotation to rename the serialized Json member name +* Added support for AtomicLongArray in B2Json +* Reduced lock contention in B2Json +* Updated internal python for building to python3 +* Added support for custom upload timestamps + +### Fixed +* Fixed union types to ignore extra and discarded fields when deserializing JSON to Java objects ## [6.1.1] - 2022-11-10 ### Added @@ -11,8 +55,9 @@ * Fixed B2ListFilesIterableBase assuming a response with 0 results was the end. It now looks for `nextFileName` being null to indicate the end. -## [6.1.0] - 2022-09-19 +### [6.1.0] - 2022-09-19 ### Added +* Added support for custom upload timestamps * Added support for Java 8's `-parameters` option so constructor parameters do not need to be reiterated in `B2Json.constructor#params` * Added `fileLockEnabled` to `B2UpdateBucketRequest` to support enabling file lock on existing buckets diff --git a/build.gradle.kts b/build.gradle.kts index a334fd1fa..e05f48a78 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,7 @@ // License https://www.backblaze.com/using_b2_code.html plugins { - id("io.github.gradle-nexus.publish-plugin") version "1.1.0" + id("io.github.gradle-nexus.publish-plugin") version "1.3.0" } nexusPublishing { diff --git a/buildSrc/src/main/kotlin/b2sdk.gradle.kts b/buildSrc/src/main/kotlin/b2sdk.gradle.kts index ef405ac51..a53ab6284 100644 --- a/buildSrc/src/main/kotlin/b2sdk.gradle.kts +++ b/buildSrc/src/main/kotlin/b2sdk.gradle.kts @@ -78,7 +78,7 @@ tasks.build { val checkCode by tasks.registering(Exec::class) { val script = rootProject.layout.projectDirectory.file("check_code").asFile.absolutePath val targetDir = layout.projectDirectory.dir("src/main").asFile.absolutePath - commandLine("python", script, targetDir) + commandLine("python3", script, targetDir) } tasks.classes { dependsOn(checkCode) diff --git a/check_code b/check_code index d79478782..729c02c29 100755 --- a/check_code +++ b/check_code @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 ###################################################################### # diff --git a/core-test-jdk17/build.gradle.kts b/core-test-jdk17/build.gradle.kts new file mode 100644 index 000000000..58b70c2da --- /dev/null +++ b/core-test-jdk17/build.gradle.kts @@ -0,0 +1,29 @@ +// Copyright 2024, Backblaze Inc. All Rights Reserved. +// License https://www.backblaze.com/using_b2_code.html + +plugins { + `java-library` + b2sdk + idea +} + +description = "JDK 17 testing of b2-sdk-core" + +b2sdk { + pomName.set("JDK 17 testing of b2-sdk-core") + description.set(project.description) +} + +dependencies { + testImplementation(projects.b2SdkCore) +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +tasks.withType().configureEach { + options.release.set(17) +} \ No newline at end of file diff --git a/core-test-jdk17/src/main/.gitignore b/core-test-jdk17/src/main/.gitignore new file mode 100644 index 000000000..8cd0ac596 --- /dev/null +++ b/core-test-jdk17/src/main/.gitignore @@ -0,0 +1,4 @@ +/* + * Copyright 2024, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ \ No newline at end of file diff --git a/core-test-jdk17/src/test/java/com/backblaze/b2/json/B2JsonDeserializationUtilTest.java b/core-test-jdk17/src/test/java/com/backblaze/b2/json/B2JsonDeserializationUtilTest.java new file mode 100644 index 000000000..5c7a68cbc --- /dev/null +++ b/core-test-jdk17/src/test/java/com/backblaze/b2/json/B2JsonDeserializationUtilTest.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.json; + +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import java.lang.reflect.Constructor; + +import static org.junit.Assert.*; + +public class B2JsonDeserializationUtilTest extends B2BaseTest { + + @Test + public void findConstructor_withJavaRecord() throws B2JsonException { + final Constructor constructor = B2JsonDeserializationUtil.findConstructor(B2JsonRecord.class); + assertNotNull(constructor); + } + + @Test + public void findConstructor_withJavaRecordWithoutB2JsonTypeAnnotation() { + final B2JsonException exception = assertThrows(B2JsonException.class, () -> B2JsonDeserializationUtil.findConstructor(B2JsonRecordWithoutTypeAnnotation.class)); + assertTrue(exception.getMessage().contains("has no constructor annotated with B2Json.constructor")); + } + + @B2Json.type + public record B2JsonRecord(@B2Json.required String name) { + + } + + public record B2JsonRecordWithoutTypeAnnotation(@B2Json.required String name) { + + } +} \ No newline at end of file diff --git a/core-test-jdk17/src/test/java/com/backblaze/b2/json/B2JsonTest.java b/core-test-jdk17/src/test/java/com/backblaze/b2/json/B2JsonTest.java new file mode 100644 index 000000000..ed476d4b5 --- /dev/null +++ b/core-test-jdk17/src/test/java/com/backblaze/b2/json/B2JsonTest.java @@ -0,0 +1,211 @@ +/* + * Copyright 2024, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ + +package com.backblaze.b2.json; + +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +import static org.junit.Assert.*; + +/** + * Unit tests for B2Json utilizing Java 17 features. + */ +public class B2JsonTest extends B2BaseTest { + + private static final B2Json b2Json = B2Json.get(); + + @Test + public void testRecord() throws B2JsonException { + final String json = """ + { + "@d": "goodbye", + "a": 41, + "b": "hello" + }"""; + final RecordContainer obj = new RecordContainer(41, "hello", "goodbye"); + assertEquals(json, b2Json.toJson(obj)); + assertEquals(obj, b2Json.fromJson(json, RecordContainer.class)); + + final String alternateJson = """ + { + "a": 41, + "b": "hello", + "\\u0040d": "goodbye" + } + """; + assertEquals(obj, b2Json.fromJson(alternateJson, RecordContainer.class)); + } + + @Test + public void testRecordUnionWithTypeFieldLast() throws IOException, B2JsonException { + final String json = """ + { + "a": 5, + "b": null, + "type": "a" + }"""; + checkDeserializeSerialize(json, UnionRecordAZ.class); + } + + @B2Json.union(typeField = "type") + interface UnionRecordWithOutSubtypes { + } + + @Test + public void testRecordUnionWithNoSubtypes() { + final String json = """ + { + "a": 5, + "b": null, + "type": "a" + }"""; + final B2JsonException exception = assertThrows(B2JsonException.class, () -> b2Json.fromJson(json, UnionRecordWithOutSubtypes.class)); + assertEquals("union base class interface com.backblaze.b2.json.B2JsonTest$UnionRecordWithOutSubtypes does not have a method getUnionTypeMap", exception.getMessage()); + } + + @B2Json.union(typeField = "type") + sealed interface UnionRecordNoSubtypes permits SubtypeRecord { + } + + @B2Json.type + private record SubtypeRecord(@B2Json.required int a, + @B2Json.optional Set b) implements UnionRecordNoSubtypes { + } + + @Test + public void testRecordUnionWithNoSubtypes_toJson() { + final B2JsonException exception = assertThrows(B2JsonException.class, () -> b2Json.toJson(new SubtypeRecord(5, null))); + assertEquals("interface com.backblaze.b2.json.B2JsonTest$UnionRecordNoSubtypes has B2Json.union annotation, but does not have @B2Json.unionSubtypes annotation", exception.getMessage()); + } + + @B2Json.union(typeField = "type") + @B2Json.unionSubtypes({ + @B2Json.unionSubtypes.type(name = "a", clazz = UnionRecordWithUnknownType.definedSubtype.class) + }) + interface UnionRecordWithUnknownType { + @B2Json.type + record definedSubtype(@B2Json.required int a, + @B2Json.optional Set b) implements UnionRecordWithUnknownType { + } + + @B2Json.type + record UndefinedSubtype(@B2Json.required int a, + @B2Json.optional Set b) implements UnionRecordWithUnknownType { + + } + } + + @Test + public void testRecordUnionWithNoMatchingSubtype_fromJson() { + final String json = """ + { + "a": 5, + "b": null, + "type": "b" + }"""; + final B2JsonException exception = assertThrows(B2JsonException.class, () -> b2Json.fromJson(json, UnionRecordWithUnknownType.class)); + assertEquals("unknown 'type' in UnionRecordWithUnknownType: 'b'", exception.getMessage()); + } + + @Test + public void testRecordUnionWithNoMatchingSubtype_toJson() { + final B2JsonException exception = assertThrows(B2JsonException.class, () -> b2Json.toJson(new UnionRecordWithUnknownType.UndefinedSubtype(5, null))); + assertEquals("interface com.backblaze.b2.json.B2JsonTest$UnionRecordWithUnknownType does not contain mapping for class com.backblaze.b2.json.B2JsonTest$UnionRecordWithUnknownType$UndefinedSubtype in the @B2Json.unionSubtypes annotation", exception.getMessage()); + } + + @B2Json.union(typeField = "type") + @B2Json.unionSubtypes({ + }) + interface UnionRecordWithEmptySubtypes { + } + + @B2Json.type + private record SubclassRecordWithEmptySubtypes(@B2Json.required int a, + @B2Json.optional Set b) implements UnionRecordWithEmptySubtypes { + } + + @Test + public void testRecordUnionWithEmptySubtypes_fromJson() { + final String json = """ + { + "a": 5, + "b": null, + "type": "b" + }"""; + final B2JsonException exception = assertThrows(B2JsonException.class, () -> b2Json.fromJson(json, SubclassRecordWithEmptySubtypes.class)); + assertEquals("UnionRecordWithEmptySubtypes - at least one type must be configured set in @B2Json.unionSubtypes", exception.getMessage()); + } + + @Test + public void testRecordUnionWithEmptySubtypes_toJson() { + final B2JsonException exception = assertThrows(B2JsonException.class, () -> b2Json.toJson(new SubclassRecordWithEmptySubtypes(5, null))); + assertEquals("UnionRecordWithEmptySubtypes - at least one type must be configured set in @B2Json.unionSubtypes", exception.getMessage()); + } + + @B2Json.union(typeField = "type") + @B2Json.unionSubtypes({ + @B2Json.unionSubtypes.type(name = "a", clazz = SubclassRecordA.class), + @B2Json.unionSubtypes.type(name = "z", clazz = SubclassRecordZ.class) + }) + sealed interface UnionRecordAZ permits SubclassRecordA, SubclassRecordZ { + } + + @B2Json.type + private record SubclassRecordA(@B2Json.required int a, @B2Json.optional Set b) implements UnionRecordAZ { + } + + @B2Json.type + private record SubclassRecordZ(@B2Json.required String z) implements UnionRecordAZ { + } + + @Test + public void testRecordContainingRecord() throws B2JsonException, IOException { + final String json = """ + { + "age": 10, + "name": "Sam", + "record": { + "@d": "b", + "a": 5, + "b": "test" + } + }"""; + checkDeserializeSerialize(json, RecordContainingRecord.class); + } + + @B2Json.type + record RecordContainingRecord(@B2Json.required int age, + @B2Json.optional String name, + @B2Json.optional RecordContainer record) { + } + + record RecordContainer(@B2Json.required int a, + @B2Json.optional String b, + @B2Json.ignored int c, + @B2Json.optional @B2Json.serializedName(value = "@d") String d) { + @B2Json.constructor + RecordContainer(int a, String b, String d) { + this(a, b, 5, d); + } + } + + private void checkDeserializeSerialize(String json, Class clazz) throws IOException, B2JsonException { + final T obj = b2Json.fromJson(json, clazz); + assertEquals(json, b2Json.toJson(obj)); + + final byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + final T obj2 = b2Json.fromJson(bytes, clazz); + assertArrayEquals(bytes, b2Json.toJsonUtf8Bytes(obj2)); + + final T obj3 = b2Json.fromJson(bytes, clazz); + final byte[] bytesWithNewline = (json + "\n").getBytes(StandardCharsets.UTF_8); + assertArrayEquals(bytesWithNewline, b2Json.toJsonUtf8BytesWithNewline(obj3)); + } +} diff --git a/core-test-jdk17/src/test/java/com/backblaze/b2/util/B2BaseTest.java b/core-test-jdk17/src/test/java/com/backblaze/b2/util/B2BaseTest.java new file mode 100644 index 000000000..24af6ec19 --- /dev/null +++ b/core-test-jdk17/src/test/java/com/backblaze/b2/util/B2BaseTest.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.util; + +import org.junit.BeforeClass; + +import static com.backblaze.b2.util.B2DateTimeUtil.parseDateTime; + +/** + * This class is the base class for all of our unit tests. + * It provides some useful "before" functionality and + * it can provide some common helpers if that becomes + * useful. + */ +public class B2BaseTest { + /** + * We want to be sure that the unit tests use the simulated clock. + * If we don't ensure this, some test might inadvertently create + * a real clock and then, when another test wants a clock simulator, + * we'll hit an exception saying we're already using a real clock. + * So...we pre-emptively create a simulator. + */ + @BeforeClass + public static void makeSureTheClockIsSimulator() { + B2Clock.useSimulator(parseDateTime("2018-04-27 00:00:00")); + } +} diff --git a/core/src/main/java/com/backblaze/b2/client/B2AccountAuthorizationCache.java b/core/src/main/java/com/backblaze/b2/client/B2AccountAuthorizationCache.java index 74f3ef002..5a3e01047 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2AccountAuthorizationCache.java +++ b/core/src/main/java/com/backblaze/b2/client/B2AccountAuthorizationCache.java @@ -11,15 +11,15 @@ /** * The B2AccountAuthorizationCache holds the most recent account authorization * and can be cleared when it appears to have become invalid. - * + *

* REQUIRES: the provided accountAuthorizer must be thread-safe. - * + *

* THREAD-SAFETY: this class may be used from multiple threads safely. */ class B2AccountAuthorizationCache { private final B2StorageClientWebifier webifier; private final B2AccountAuthorizer accountAuthorizer; - private B2AccountAuthorization authorization; + private volatile B2AccountAuthorization authorization; /** * The authorize() call from the authorizer should always @@ -27,7 +27,7 @@ class B2AccountAuthorizationCache { * the first successful authorization, we hold on to the accountId * and use it to make sure we are always authenticating with the same account. */ - private String accountId; + private volatile String accountId; B2AccountAuthorizationCache(B2StorageClientWebifier webifier, B2AccountAuthorizer accountAuthorizer) { @@ -38,28 +38,38 @@ class B2AccountAuthorizationCache { /** * @return a B2AccountAuthorization. it does *NOT* retry on its own. - * + *

* THREADING: note that we intentionally block all other threads while * waiting for an answer from the server. if the first thread * succeeds, they'll all benefit. if one of them fails, * the next one that asks will try again. no need to ask * multiple times in parallel. */ - synchronized B2AccountAuthorization get() throws B2Exception { - if (authorization == null) { - authorization = accountAuthorizer.authorize(webifier); + B2AccountAuthorization get() throws B2Exception { + // Store a local copy of authorization in case clear() is called concurrently after the null check + B2AccountAuthorization localAuthorization = authorization; + if (localAuthorization != null) { + return localAuthorization; + } - final String accountIdFromAuthorization = authorization.getAccountId(); - if (accountId == null) { - accountId = accountIdFromAuthorization; - } else { - if (!accountId.equals(accountIdFromAuthorization)) { - throw new B2LocalException("unauthorized", "authorized as " + accountIdFromAuthorization + - "but previously authorized as accountId " + accountId); + synchronized (this) { + localAuthorization = authorization; + if (localAuthorization == null) { + localAuthorization = accountAuthorizer.authorize(webifier); + authorization = localAuthorization; + + final String accountIdFromAuthorization = localAuthorization.getAccountId(); + if (accountId == null) { + accountId = accountIdFromAuthorization; + } else { + if (!accountId.equals(accountIdFromAuthorization)) { + throw new B2LocalException("unauthorized", "authorized as " + accountIdFromAuthorization + + "but previously authorized as accountId " + accountId); + } } } + return localAuthorization; } - return authorization; } /** @@ -68,14 +78,20 @@ synchronized B2AccountAuthorization get() throws B2Exception { * @return the accountId from a successful authorization * @throws B2Exception thrown from any B2Exception thrown during 'authorization' -> get() */ - synchronized String getAccountId() throws B2Exception{ - if (accountId == null) { - get(); + String getAccountId() throws B2Exception { + if (accountId != null) { + return accountId; + } + + synchronized (this) { + if (accountId == null) { + get(); + } } return accountId; } - synchronized void clear() { + void clear() { authorization = null; } } diff --git a/core/src/main/java/com/backblaze/b2/client/B2AlreadyStoredPartStorer.java b/core/src/main/java/com/backblaze/b2/client/B2AlreadyStoredPartStorer.java index c39ff1a8a..3c957d308 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2AlreadyStoredPartStorer.java +++ b/core/src/main/java/com/backblaze/b2/client/B2AlreadyStoredPartStorer.java @@ -39,11 +39,11 @@ public int getPartNumber() { @Override public B2Part storePart( - B2LargeFileStorer largeFileCreationManager, + B2LargeFileStorer largeFileStorer, B2UploadListener uploadListener, B2CancellationToken cancellationToken) { - largeFileCreationManager.updateProgress( + largeFileStorer.updateProgress( uploadListener, part.getPartNumber(), part.getContentLength(), diff --git a/core/src/main/java/com/backblaze/b2/client/B2ByteProgressFilteringListener.java b/core/src/main/java/com/backblaze/b2/client/B2ByteProgressFilteringListener.java index e9d3c9313..fc86e6671 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2ByteProgressFilteringListener.java +++ b/core/src/main/java/com/backblaze/b2/client/B2ByteProgressFilteringListener.java @@ -34,6 +34,8 @@ class B2ByteProgressFilteringListener implements B2ByteProgressListener { // what's the most recent bytesSoFar we've seen? private long bytesSoFar; + private static final B2Clock b2Clock = B2Clock.get(); + /** * @param listener the listener to forward notifications to. * @param nMillisBetween if this much time has passed since previous progress() was forwarded, forward it. @@ -55,7 +57,7 @@ private B2ByteProgressFilteringListener(B2ByteProgressListener listener, public void progress(long nBytesSoFar) { this.bytesSoFar = nBytesSoFar; - final long monoMillis = B2Clock.get().monotonicMillis(); + final long monoMillis = b2Clock.monotonicMillis(); // only send if enough time has gone by. if (monoMillis >= millisThreshold) { diff --git a/core/src/main/java/com/backblaze/b2/client/B2ClientConfig.java b/core/src/main/java/com/backblaze/b2/client/B2ClientConfig.java index e95b7bd9d..ca4245761 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2ClientConfig.java +++ b/core/src/main/java/com/backblaze/b2/client/B2ClientConfig.java @@ -7,7 +7,6 @@ import com.backblaze.b2.client.structures.B2TestMode; import com.backblaze.b2.util.B2Preconditions; -import java.io.Closeable; import java.util.Objects; /** @@ -21,15 +20,24 @@ public class B2ClientConfig { private final String masterUrl; private final B2TestMode testModeOrNull; + /** + * Must large file part numbers start with 1 and be contiguous? This is only for internal use; regardless + * of this variable's value, the B2 Native API requires part numbers start with 1 and be contiguous when + * finishing a large file. + */ + private final boolean partNumberGapsAllowed; + private B2ClientConfig(B2AccountAuthorizer accountAuthorizer, String userAgent, String masterUrl, - B2TestMode testModeOrNull) { - B2Preconditions.checkArgument(userAgent != null && userAgent.length() > 0); + B2TestMode testModeOrNull, + boolean partNumberGapsAllowed) { + B2Preconditions.checkArgument(userAgent != null && !userAgent.isEmpty()); this.accountAuthorizer = accountAuthorizer; this.userAgent = userAgent; this.masterUrl = masterUrl; this.testModeOrNull = testModeOrNull; + this.partNumberGapsAllowed = partNumberGapsAllowed; } public B2AccountAuthorizer getAccountAuthorizer() { @@ -48,6 +56,10 @@ public B2TestMode getTestModeOrNull() { return testModeOrNull; } + public boolean isPartNumberGapsAllowed() { + return partNumberGapsAllowed; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -56,12 +68,19 @@ public boolean equals(Object o) { return Objects.equals(getAccountAuthorizer(), that.getAccountAuthorizer()) && Objects.equals(getUserAgent(), that.getUserAgent()) && Objects.equals(getMasterUrl(), that.getMasterUrl()) && - getTestModeOrNull() == that.getTestModeOrNull(); + getTestModeOrNull() == that.getTestModeOrNull() && + isPartNumberGapsAllowed() == that.isPartNumberGapsAllowed(); + } @Override public int hashCode() { - return Objects.hash(getAccountAuthorizer(), getUserAgent(), getMasterUrl(), getTestModeOrNull()); + return Objects.hash( + getAccountAuthorizer(), + getUserAgent(), + getMasterUrl(), + getTestModeOrNull(), + isPartNumberGapsAllowed()); } public static Builder builder(B2AccountAuthorizer accountAuthorizer, String userAgent) { @@ -83,6 +102,7 @@ public static class Builder { private final String userAgent; private String masterUrl; private B2TestMode testModeOrNull; + private boolean partNumberGapsAllowed = false; public Builder(B2AccountAuthorizer accountAuthorizer, String userAgent) { @@ -100,12 +120,19 @@ public Builder setTestModeOrNull(B2TestMode testModeOrNull) { return this; } + @SuppressWarnings("unused") + public Builder setPartNumberGapsAllowed(boolean partNumberGapsAllowed) { + this.partNumberGapsAllowed = partNumberGapsAllowed; + return this; + } + public B2ClientConfig build() { return new B2ClientConfig( accountAuthorizer, userAgent, masterUrl, - testModeOrNull); + testModeOrNull, + partNumberGapsAllowed); } } } diff --git a/core/src/main/java/com/backblaze/b2/client/B2CopyingPartStorer.java b/core/src/main/java/com/backblaze/b2/client/B2CopyingPartStorer.java index f7a0bb03d..2b27631b0 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2CopyingPartStorer.java +++ b/core/src/main/java/com/backblaze/b2/client/B2CopyingPartStorer.java @@ -44,11 +44,11 @@ public long getPartSizeOrThrow() throws B2CannotComputeException { @Override public B2Part storePart( - B2LargeFileStorer largeFileCreationManager, + B2LargeFileStorer largeFileStorer, B2UploadListener uploadListener, B2CancellationToken cancellationToken) throws B2Exception { - return largeFileCreationManager.copyPart(partNumber, sourceFileId, byteRangeOrNull, uploadListener, cancellationToken); + return largeFileStorer.copyPart(partNumber, sourceFileId, byteRangeOrNull, uploadListener); } @Override diff --git a/core/src/main/java/com/backblaze/b2/client/B2LargeFileStorer.java b/core/src/main/java/com/backblaze/b2/client/B2LargeFileStorer.java index d03aafd9f..26e0882db 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2LargeFileStorer.java +++ b/core/src/main/java/com/backblaze/b2/client/B2LargeFileStorer.java @@ -27,15 +27,11 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.*; import java.util.function.Supplier; -import static com.backblaze.b2.client.structures.B2ServerSideEncryptionMode.SSE_C; - /** * A class for handling the creation of large files. * @@ -46,9 +42,9 @@ public class B2LargeFileStorer { /** - * The B2FileVersion for the large file that is being created. + * The file ID of the large file that is being created. */ - private final B2FileVersion fileVersion; + private final String largeFileId; /** * The B2FileSseForRequest for the large file that is being created. @@ -69,6 +65,12 @@ public class B2LargeFileStorer { */ private final List startingBytePositions; + /** + * Map from part number to index into the partStorers and + * startingBytePositions lists. + */ + private final Map indexesByPartNumber; + /** * The cancellation token used to abort uploads in progress. */ @@ -88,42 +90,57 @@ public class B2LargeFileStorer { B2StorageClientWebifier webifier, B2Retryer retryer, Supplier retryPolicySupplier, - ExecutorService executor) { + ExecutorService executor, + boolean partNumberGapsAllowed) { B2Preconditions.checkArgumentIsNotNull(storeLargeFileRequest, "storeLargeFileRequest"); - this.fileVersion = storeLargeFileRequest.getFileVersion(); + this.largeFileId = storeLargeFileRequest.getFileId(); this.serverSideEncryptionOrNull = storeLargeFileRequest.getServerSideEncryption(); - this.partStorers = validateAndSortPartStorers(new ArrayList<>(partStorers)); + this.partStorers = validateAndSortPartStorers(new ArrayList<>(partStorers), partNumberGapsAllowed); this.startingBytePositions = computeStartingBytePositions(partStorers); + this.indexesByPartNumber = computeIndexesByPartNumbers(partStorers); this.accountAuthCache = accountAuthCache; - this.uploadPartUrlCache = new B2UploadPartUrlCache(webifier, accountAuthCache, fileVersion.getFileId()); + this.uploadPartUrlCache = new B2UploadPartUrlCache(webifier, accountAuthCache, largeFileId); this.webifier = webifier; this.retryer = retryer; this.retryPolicySupplier = retryPolicySupplier; this.executor = executor; } - private List validateAndSortPartStorers(List partStorers) { + private List validateAndSortPartStorers(List partStorers, + boolean partNumberGapsAllowed) { partStorers.sort(Comparator.comparingInt(B2PartStorer::getPartNumber)); + validatePartStorers(partStorers, partNumberGapsAllowed); + + return partStorers; + } + + private void validatePartStorers(List partStorers, boolean partNumberGapsAllowed) { // Go through the parts - throw if there are duplicates or gaps. - for (int i = 0; i < partStorers.size(); i++) { - final int expectedPartNumber = i + 1; - final int partNumber = partStorers.get(i).getPartNumber(); + int expectedPartNumber = 0; + for (B2PartStorer partStorer : partStorers) { + expectedPartNumber++; + final int partNumber = partStorer.getPartNumber(); if (partNumber < 1) { throw new IllegalArgumentException("invalid part number: " + partNumber); } if (partNumber < expectedPartNumber) { - throw new IllegalArgumentException("part number " + partNumber + " has multiple part storers"); + throw new IllegalArgumentException( + "part number " + partNumber + " has multiple part storers"); } if (partNumber > expectedPartNumber) { - throw new IllegalArgumentException("part number " + expectedPartNumber + " has no part storers"); + if (partNumberGapsAllowed) { + // for internal use only: do not enforce requirement that part numbers must start with 1 and + // be contiguous + expectedPartNumber = partNumber; + } else { + throw new IllegalArgumentException("part number " + expectedPartNumber + " has no part storers"); + } } } - - return partStorers; } private static List computeStartingBytePositions(List partStorers) { @@ -144,16 +161,36 @@ private static List computeStartingBytePositions(List partSt return startingPositions; } + private Map computeIndexesByPartNumbers(List partStorers) { + final Map indexes = new TreeMap<>(); + + for (int i = 0; i < partStorers.size(); i++) { + final B2PartStorer partStorer = partStorers.get(i); + indexes.put(partStorer.getPartNumber(), i); + } + + return indexes; + } + List getPartStorers() { return partStorers; } + private int getIndexForPartNumber(int partNumber) { + Integer indexOrNull = indexesByPartNumber.get(partNumber); + if (indexOrNull == null) { + throw new IllegalArgumentException("invalid part number: " + partNumber); + } + + return indexOrNull; + } + /** * @return The start byte for the part, or UNKNOWN_PART_START_BYTE if not known. */ long getStartByteOrUnknown(int partNumber) { - return startingBytePositions.get(partNumber - 1); + return startingBytePositions.get(getIndexForPartNumber(partNumber)); } public static B2LargeFileStorer forLocalContent( B2FileVersion largeFileVersion, @@ -165,7 +202,7 @@ public static B2LargeFileStorer forLocalContent( Supplier retryPolicySupplier, ExecutorService executor) throws B2Exception { return forLocalContent( - B2StoreLargeFileRequest.builder(largeFileVersion).build(), + B2StoreLargeFileRequest.builder(largeFileVersion.getFileId()).build(), contentSource, partSizes, accountAuthCache, @@ -184,6 +221,30 @@ public static B2LargeFileStorer forLocalContent( B2Retryer retryer, Supplier retryPolicySupplier, ExecutorService executor) throws B2Exception { + return forLocalContent( + storeLargeFileRequest, + contentSource, + partSizes, + accountAuthCache, + webifier, + retryer, + retryPolicySupplier, + executor, + false); + } + + // NOTE: Setting allowGaps to true is only intended for internal B2 use; even if it's set to true, the + // B2 Native API will not allow gaps between part numbers for large file uploads. + public static B2LargeFileStorer forLocalContent( + B2StoreLargeFileRequest storeLargeFileRequest, + B2ContentSource contentSource, + B2PartSizes partSizes, + B2AccountAuthorizationCache accountAuthCache, + B2StorageClientWebifier webifier, + B2Retryer retryer, + Supplier retryPolicySupplier, + ExecutorService executor, + boolean allowGaps) throws B2Exception { B2Preconditions.checkArgumentIsNotNull(storeLargeFileRequest, "storeLargeFileRequest"); // Convert the contentSource into a list of B2PartStorer objects. @@ -207,7 +268,8 @@ public static B2LargeFileStorer forLocalContent( webifier, retryer, retryPolicySupplier, - executor); + executor, + allowGaps); } B2FileVersion storeFile(B2UploadListener uploadListenerOrNull) throws B2Exception { @@ -240,6 +302,43 @@ B2FileVersion storeFile(B2UploadListener uploadListenerOrNull) throws B2Exceptio * @return CompletableFuture that returns the finished file's B2FileVersion */ CompletableFuture storeFileAsync(B2UploadListener uploadListenerOrNull) { + final CompletableFuture> partsFuture = storePartsAsync(uploadListenerOrNull); + + final CompletableFuture retval = partsFuture + .thenApplyAsync(parts -> finishLargeFileFromB2PartsInCompletionStage(largeFileId, parts), executor); + + // The caller can call cancel on the future that we give them, but that will only + // stop futures chained to the end of this future from running; it does not stop + // processing the part uploads that are started by partsFuture. So we add our own + // handler to detect this, propagating the cancellation to partsFuture to try + // to cancel any not-yet-started uploads, and setting the cancellationToken to try to + // fail any in-progress uploads. + retval.whenComplete((result, error) -> { + if (error instanceof CancellationException) { + cancellationToken.cancel(); + partsFuture.cancel(true); + } + }); + + return retval; + } + + List storeParts(B2UploadListener uploadListenerOrNull) throws B2Exception { + try { + return storePartsAsync(uploadListenerOrNull).get(); + } catch (ExecutionException e) { + final Throwable cause = e.getCause(); + if (cause instanceof B2Exception) { + throw (B2Exception) cause; + } else { + throw new B2LocalException("trouble", "exception while trying to upload parts: " + cause, cause); + } + } catch (InterruptedException e) { + throw new B2LocalException("trouble", "interrupted exception"); + } + } + + CompletableFuture> storePartsAsync(B2UploadListener uploadListenerOrNull) { final B2UploadListener uploadListener; if (uploadListenerOrNull == null) { uploadListener = B2UploadListener.noopListener(); @@ -253,7 +352,7 @@ CompletableFuture storeFileAsync(B2UploadListener uploadListenerO for (final B2PartStorer partStorer : partStorers) { CompletableFuture future = CompletableFuture.supplyAsync( adaptB2Supplier(() -> partStorer.storePart(this, uploadListener, cancellationToken)), - executor); + executor); completableFutures.add(future); } @@ -264,8 +363,8 @@ CompletableFuture storeFileAsync(B2UploadListener uploadListenerO final List> partFutures = new ArrayList<>(completableFutures); // this is the future to return to the caller - final CompletableFuture retval = allPartsCompletedFuture - .thenApplyAsync((voidParam) -> finishLargeFileFromB2PartFuturesInCompletionStage(fileVersion, partFutures), executor); + final CompletableFuture> retval = allPartsCompletedFuture + .thenApplyAsync((voidParam) -> getB2PartListFromB2PartFuturesInCompletionStage(partFutures), executor); // The caller can call cancel on the future that we give them, but that will only // stop futures chained to the end of this future from running; it does not stop @@ -273,9 +372,9 @@ CompletableFuture storeFileAsync(B2UploadListener uploadListenerO // to detect this and cancel any remaining part uploads so they don't start, and // flag the cancellation token to try to fail any in-progress uploads. retval.whenComplete((result, error) -> { - if (error != null) { - completableFutures.forEach(x -> x.cancel(true)); + if (error instanceof CancellationException) { cancellationToken.cancel(); + completableFutures.forEach(x -> x.cancel(true)); } }); @@ -283,18 +382,59 @@ CompletableFuture storeFileAsync(B2UploadListener uploadListenerO } /** - * Adapts finishLargeFileFromB2PartFutures to be used in completion stages. These - * functions cannot return B2Exceptions, so those must be caught here and converted + * Adapts finishLargeFileFromB2Parts to be used in completion stages. + *

+ * These functions cannot return B2Exceptions, so those must be caught here and converted + * to CompletionExceptions. + */ + private B2FileVersion finishLargeFileFromB2PartsInCompletionStage(String largeFileId, + List parts) { + return callSupplierAndConvertErrorsForCompletableFutures( + () -> finishLargeFileFromB2Parts(largeFileId, parts) + ); + } + + /** + * Adapts getB2PartListFromB2PartFutures to be used in completion stages. + *

+ * These functions cannot return B2Exceptions, so those must be caught here and converted * to CompletionExceptions. - * - * @param largeFileVersion - * @param partFutures - * @return */ - private B2FileVersion finishLargeFileFromB2PartFuturesInCompletionStage(B2FileVersion largeFileVersion, - List> partFutures) { + private List getB2PartListFromB2PartFuturesInCompletionStage(List> partFutures) { return callSupplierAndConvertErrorsForCompletableFutures( - () -> finishLargeFileFromB2PartFutures(largeFileVersion, partFutures)); + () -> getB2PartListFromB2PartFutures(partFutures) + ); + } + + private List getB2PartListFromB2PartFutures(List> partFutures) throws B2Exception { + cancellationToken.throwIfCancelled(); + + final List parts = new ArrayList<>(); + try { + for (final Future partFuture : partFutures) { + parts.add(partFuture.get()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new B2LocalException("interrupted", "interrupted while trying to copy parts: " + e, e); + + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof B2Exception) { + throw (B2Exception) e.getCause(); + } else { + throw new B2LocalException("trouble", "exception while trying to upload parts: " + cause, cause); + } + } finally { + // we've either called get() on all of the futures, or we've hit an exception and + // we aren't going to wait for the others. let's call cancel on all of them. + // the ones that have finished already won't mind and the others will be stopped. + for (Future future : partFutures) { + future.cancel(true); + } + } + + return parts; } /** @@ -334,45 +474,26 @@ private Supplier adaptB2Supplier(B2Supplier supplier) { return () -> callSupplierAndConvertErrorsForCompletableFutures(supplier); } - private B2FileVersion finishLargeFileFromB2PartFutures(B2FileVersion largeFileVersion, - List> partFutures) throws B2Exception { - + private B2FileVersion finishLargeFileFromB2Parts(String largeFileId, + List parts) throws B2Exception { cancellationToken.throwIfCancelled(); final List partSha1s = new ArrayList<>(); - try { - for (final Future partFuture : partFutures) { - final B2Part part = partFuture.get(); - partSha1s.add(part.getContentSha1()); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new B2LocalException("interrupted", "interrupted while trying to copy parts: " + e, e); - - } catch (ExecutionException e) { - Throwable cause = e.getCause(); - if (cause instanceof B2Exception) { - throw (B2Exception) e.getCause(); - } else { - throw new B2LocalException("trouble", "exception while trying to upload parts: " + cause, cause); - } - } finally { - // we've either called get() on all of the futures, or we've hit an exception and - // we aren't going to wait for the others. let's call cancel on all of them. - // the ones that have finished already won't mind and the others will be stopped. - for (Future future : partFutures) { - future.cancel(true); - } + for (final B2Part part : parts) { + partSha1s.add(part.getContentSha1()); } // finish the large file. B2FinishLargeFileRequest finishRequest = B2FinishLargeFileRequest - .builder(largeFileVersion.getFileId(), partSha1s) + .builder(largeFileId, partSha1s) .build(); return retryer.doRetry( "b2_finish_large_file", accountAuthCache, - () -> webifier.finishLargeFile(accountAuthCache.get(), finishRequest), + () -> { + cancellationToken.throwIfCancelled(); + return webifier.finishLargeFile(accountAuthCache.get(), finishRequest); + }, retryPolicySupplier.get()); } @@ -385,7 +506,7 @@ void updateProgress( uploadListener.progress( new B2UploadProgress( - partNumber - 1, + getIndexForPartNumber(partNumber), partStorers.size(), getStartByteOrUnknown(partNumber), partLength, @@ -396,10 +517,11 @@ void updateProgress( /** * Stores a part by uploading the bytes from a content source. */ - B2Part uploadPart( + public B2Part uploadPart( int partNumber, B2ContentSource contentSource, - B2UploadListener uploadListener, B2CancellationToken cancellationToken) throws IOException, B2Exception { + B2UploadListener uploadListener) throws IOException, B2Exception { + cancellationToken.throwIfCancelled(); updateProgress( uploadListener, @@ -411,7 +533,7 @@ B2Part uploadPart( // Set up the listener for the part upload. final B2ByteProgressListener progressAdapter = new B2UploadProgressAdapter( uploadListener, - partNumber - 1, + getIndexForPartNumber(partNumber), partStorers.size(), getStartByteOrUnknown(partNumber), contentSource.getContentLength()); @@ -480,8 +602,8 @@ B2Part copyPart( int partNumber, String sourceFileId, B2ByteRange byteRangeOrNull, - B2UploadListener uploadListener, - B2CancellationToken cancellationToken) throws B2Exception { + B2UploadListener uploadListener) throws B2Exception { + cancellationToken.throwIfCancelled(); updateProgress( uploadListener, @@ -491,7 +613,7 @@ B2Part copyPart( B2UploadState.WAITING_TO_START); final B2CopyPartRequest copyPartRequest = B2CopyPartRequest - .builder(partNumber, sourceFileId, fileVersion.getFileId()) + .builder(partNumber, sourceFileId, largeFileId) .setRange(byteRangeOrNull) .build(); @@ -546,4 +668,3 @@ static B2ContentSource createRangedContentSource( } } - diff --git a/core/src/main/java/com/backblaze/b2/client/B2PartStorer.java b/core/src/main/java/com/backblaze/b2/client/B2PartStorer.java index 9ae2dd129..48ee7f5ac 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2PartStorer.java +++ b/core/src/main/java/com/backblaze/b2/client/B2PartStorer.java @@ -31,15 +31,14 @@ public interface B2PartStorer { /** * Store the part this B2PartStorer is responsible for. * - * @param largeFileCreationManager The object managing the storage of the whole - * large file. + * @param largeFileStorer The object managing the storage of the whole large file. * @param uploadListener The listener that tracks upload progress events. * @param cancellationToken token to check whether the action has been cancelled * @return The part that is stored, if successful. * @throws B2Exception if there's trouble. */ B2Part storePart( - B2LargeFileStorer largeFileCreationManager, + B2LargeFileStorer largeFileStorer, B2UploadListener uploadListener, B2CancellationToken cancellationToken) throws IOException, B2Exception; diff --git a/core/src/main/java/com/backblaze/b2/client/B2Retryer.java b/core/src/main/java/com/backblaze/b2/client/B2Retryer.java index 33de085b0..54e3b88dc 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2Retryer.java +++ b/core/src/main/java/com/backblaze/b2/client/B2Retryer.java @@ -23,6 +23,8 @@ class B2Retryer { private final B2Sleeper sleeper; + private static final B2Clock clock = B2Clock.get(); + B2Retryer(B2Sleeper sleeper) { this.sleeper = sleeper; } @@ -65,7 +67,6 @@ T doRetry(String operation, B2AccountAuthorizationCache accountAuthCache, RetryableCallable callable, B2RetryPolicy retryPolicy) throws B2Exception { - final B2Clock clock = B2Clock.get(); // keeps trying until we hit an unretryable exception or the retryPolicy says to stop. int attemptsSoFar = 0; // we haven't attempted it at all yet. diff --git a/core/src/main/java/com/backblaze/b2/client/B2Sdk.java b/core/src/main/java/com/backblaze/b2/client/B2Sdk.java index 2d568e423..5f96beaa2 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2Sdk.java +++ b/core/src/main/java/com/backblaze/b2/client/B2Sdk.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; public class B2Sdk { // caches the answer to getVersion(). only access with getVersion(). @@ -18,7 +19,7 @@ public class B2Sdk { private static String readVersion() { try (final InputStream in = B2Sdk.class.getClassLoader().getResourceAsStream("b2-sdk-core/version.txt"); - final BufferedReader reader = new BufferedReader(new InputStreamReader(in, B2StringUtil.UTF8))) { + final BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { final String version = reader.readLine().trim(); B2Preconditions.checkState(!version.isEmpty()); return version; diff --git a/core/src/main/java/com/backblaze/b2/client/B2StorageClient.java b/core/src/main/java/com/backblaze/b2/client/B2StorageClient.java index ca71e9ff6..c68b1d044 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2StorageClient.java +++ b/core/src/main/java/com/backblaze/b2/client/B2StorageClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ @@ -22,8 +22,11 @@ import com.backblaze.b2.client.structures.B2DownloadAuthorization; import com.backblaze.b2.client.structures.B2DownloadByIdRequest; import com.backblaze.b2.client.structures.B2DownloadByNameRequest; +import com.backblaze.b2.client.structures.B2EventNotificationRule; import com.backblaze.b2.client.structures.B2FileVersion; import com.backblaze.b2.client.structures.B2FinishLargeFileRequest; +import com.backblaze.b2.client.structures.B2GetBucketNotificationRulesRequest; +import com.backblaze.b2.client.structures.B2GetBucketNotificationRulesResponse; import com.backblaze.b2.client.structures.B2GetDownloadAuthorizationRequest; import com.backblaze.b2.client.structures.B2GetFileInfoByNameRequest; import com.backblaze.b2.client.structures.B2GetFileInfoRequest; @@ -37,6 +40,9 @@ import com.backblaze.b2.client.structures.B2ListKeysRequest; import com.backblaze.b2.client.structures.B2ListPartsRequest; import com.backblaze.b2.client.structures.B2ListUnfinishedLargeFilesRequest; +import com.backblaze.b2.client.structures.B2Part; +import com.backblaze.b2.client.structures.B2SetBucketNotificationRulesRequest; +import com.backblaze.b2.client.structures.B2SetBucketNotificationRulesResponse; import com.backblaze.b2.client.structures.B2StartLargeFileRequest; import com.backblaze.b2.client.structures.B2StoreLargeFileRequest; import com.backblaze.b2.client.structures.B2UpdateBucketRequest; @@ -247,9 +253,9 @@ B2FileVersion uploadLargeFile(B2UploadFileRequest request, * * This method assumes you have already called startLargeFile(). The return value * of that call needs to be passed into this method. However, this method will - * currently call finish file. + * call finish file. * - * XXX: should we switch to letting the caller finish the large file? + * XXX: add storePartsForLargeFileFromLocalContent that stores parts but doesn't finish the file. * * @param fileVersion The B2FileVersion for the large file getting stored. * This is the return value of startLargeFile(). @@ -276,9 +282,9 @@ B2FileVersion storeLargeFileFromLocalContent( * * This method assumes you have already called startLargeFile(). The return value * of that call needs to be passed into this method as part of a - * B2StoreLargeFileRequest. However, this method will currently call finish file. + * B2StoreLargeFileRequest. However, this method will call finish file. * - * XXX: should we switch to letting the caller finish the large file? + * XXX: add storePartsForLargeFileFromLocalContent that stores parts but doesn't finish the file. * * @param storeLargeFileRequest The B2StoreLargeFileRequest for the large file * getting stored. This is built from the return @@ -371,22 +377,20 @@ CompletableFuture storeLargeFileFromLocalContentAsync( /** * Stores a large file, where storing each part may involve different behavior * or byte sources. - * + *

* For example, this method supports the use case of making a copy of a file * that mostly has not changed, and the user only wishes to upload the parts * that have changed. In this case partStorers would be a mix of * B2CopyingPartStorers and one or more B2UploadingPartStorers. - * + *

* Another use case would be reattempting an upload of a large file where some * parts have completed, and some haven't. In this case, partStorers would * be a mix of B2AlreadyStoredPartStorer and B2UploadingPartStorers. - * + *

* This method assumes you have already called startLargeFile(). The return value * of that call needs to be passed into this method. However, this method will - * currently call finish file. Note that each part, whether copied or uploaded, - * is still subject to the minimum part size. - * - * XXX: should we switch to letting the caller finish the large file? + * call finish file. Note that each part, whether copied or uploaded, is still + * subject to the minimum part size. * * @param fileVersion The B2FileVersion for the large file getting stored. * This is the return value of startLargeFile(). @@ -411,19 +415,19 @@ B2FileVersion storeLargeFile( * Stores a large file, where storing each part may involve different behavior * or byte sources, optionally allowing caller to pass SSE-C parameters to match * those given to startLargeFile(). - * + *

* For example, this method supports the use case of making a copy of a file * that mostly has not changed, and the user only wishes to upload the parts * that have changed. In this case partStorers would be a mix of * B2CopyingPartStorers and one or more B2UploadingPartStorers. - * + *

* Another use case would be reattempting an upload of a large file where some * parts have completed, and some haven't. In this case, partStorers would * be a mix of B2AlreadyStoredPartStorer and B2UploadingPartStorers. - * + *

* This method assumes you have already called startLargeFile(). The return value * of that call needs to be passed into this method as part of a - * B2StoreLargeFileRequest. However, this method will currently call finish file. + * B2StoreLargeFileRequest. However, this method will call finish file. * Note that each part, whether copied or uploaded, * is still subject to the minimum part size. * @@ -448,6 +452,84 @@ B2FileVersion storeLargeFile( B2UploadListener uploadListenerOrNull, ExecutorService executor) throws B2Exception; + /** + * Stores large file parts, where storing each part may involve different behavior + * or byte sources. + *

+ * For example, this method supports the use case of preparing to make a copy + * of a file that mostly has not changed, and the user only wishes to upload + * the parts that have changed. In this case partStorers would be a mix of + * B2CopyingPartStorers and one or more B2UploadingPartStorers. + *

+ * Another use case would be reattempting some part uploads for an unfinished + * large file for which some parts have completed, and some haven't. In this case, + * partStorers would be a mix of B2AlreadyStoredPartStorer and B2UploadingPartStorers. + *

+ * This method assumes you have already called startLargeFile(). The return value + * of that call needs to be passed into this method. Note that each part, whether + * copied or uploaded, is still subject to the minimum part size. Note also that + * this method does not finish the large file. + * + * @param fileVersion The B2FileVersion for the large file for which these parts + * are being uploaded. This is the return value of startLargeFile(). + * @param partStorers The list of objects that know how to store the part + * they are responsible for. + * @param uploadListenerOrNull The object that handles upload progress events. + * This may be null if you do not need to be notified + * of progress events. + * @param executor The executor for uploading parts in parallel. The caller + * retains ownership of the executor and is responsible for + * shutting it down. + * @return The list of parts after all have been stored. + * @throws B2Exception If there's trouble. + */ + List storePartsForLargeFile( + B2FileVersion fileVersion, + List partStorers, + B2UploadListener uploadListenerOrNull, + ExecutorService executor) throws B2Exception; + + /** + * Stores large file parts, where storing each part may involve different behavior + * or byte sources, optionally allowing caller to pass SSE-C parameters to match + * those given to startLargeFile(). + *

+ * For example, this method supports the use case of making a copy of a file + * that mostly has not changed, and the user only wishes to upload the parts + * that have changed. In this case partStorers would be a mix of + * B2CopyingPartStorers and one or more B2UploadingPartStorers. + *

+ * Another use case would be reattempting some part uploads for an unfinished + * large file for which some parts have completed, and some haven't. In this case, + * partStorers would be a mix of B2AlreadyStoredPartStorer and B2UploadingPartStorers. + *

+ * This method assumes you have already called startLargeFile(). The return value + * of that call needs to be passed into this method as part of a + * B2StoreLargeFileRequest. Note that each part, whether copied or uploaded, + * is still subject to the minimum part size. Note also that this method does + * not finish the large file. + * + * @param storeLargeFileRequest The B2StoreLargeFileRequest for the large file + * for which these parts are being stored. This is + * built from the return value of startLargeFile() and + * any other relevant parameters. + * @param partStorers The list of objects that know how to store the part + * they are responsible for. + * @param uploadListenerOrNull The object that handles upload progress events. + * This may be null if you do not need to be notified + * of progress events. + * @param executor The executor for uploading parts in parallel. The caller + * retains ownership of the executor and is responsible for + * shutting it down. + * @return The list of parts after all have been stored. + * @throws B2Exception If there's trouble. + */ + List storePartsForLargeFile( + B2StoreLargeFileRequest storeLargeFileRequest, + List partStorers, + B2UploadListener uploadListenerOrNull, + ExecutorService executor) throws B2Exception; + /** * Verifies that the given fileVersion represents an unfinished large file * and that the specified content is compatible-enough with the information @@ -1008,6 +1090,50 @@ default String getDownloadByNameUrl(String bucketName, */ B2UpdateFileRetentionResponse updateFileRetention(B2UpdateFileRetentionRequest request) throws B2Exception; + /** + * Sets the bucket's event notification rules as described by the request. + * + * @param request specifies which bucket to update and how to update the event notification rules. + * @return the new state of the bucket's event notification rules + * @throws B2Exception if there's any trouble. + * @see b2_set_bucket_notification_rules + */ + B2SetBucketNotificationRulesResponse setBucketNotificationRules(B2SetBucketNotificationRulesRequest request) throws B2Exception; + + /** + * Gets the bucket's event notification rules as described by the request. + * + * @param request specifies which bucket to get the event notification rules from. + * @return the current state of the bucket's event notification rules + * @throws B2Exception if there's any trouble. + * @see b2_get_bucket_notification_rules + */ + B2GetBucketNotificationRulesResponse getBucketNotificationRules(B2GetBucketNotificationRulesRequest request) throws B2Exception; + + /** + * Just like setBucketNotificationRules(request), except that the request is created from + * the specified bucketId and eventNotificationRules. + * + * @param bucketId the bucket whose eventNotificationRules you want to set. + * @param eventNotificationRules the eventNotificationRules to set on the bucket. + * @throws B2Exception if there's any trouble. + */ + default B2SetBucketNotificationRulesResponse setBucketNotificationRules(String bucketId, + List eventNotificationRules) throws B2Exception { + return setBucketNotificationRules(B2SetBucketNotificationRulesRequest.builder(bucketId, eventNotificationRules).build()); + } + + /** + * Just like getBucketNotificationRules(request), except that the request is created from + * the specified bucketId. + * + * @param bucketId the bucket whose eventNotificationRules you want to get. + * @throws B2Exception if there's any trouble. + */ + default B2GetBucketNotificationRulesResponse getBucketNotificationRules(String bucketId) throws B2Exception { + return getBucketNotificationRules(B2GetBucketNotificationRulesRequest.builder(bucketId).build()); + } + /** * Closes this instance, releasing resources. */ diff --git a/core/src/main/java/com/backblaze/b2/client/B2StorageClientImpl.java b/core/src/main/java/com/backblaze/b2/client/B2StorageClientImpl.java index b4741c7bb..3c56d6c61 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2StorageClientImpl.java +++ b/core/src/main/java/com/backblaze/b2/client/B2StorageClientImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client; @@ -27,6 +27,8 @@ import com.backblaze.b2.client.structures.B2DownloadByNameRequest; import com.backblaze.b2.client.structures.B2FileVersion; import com.backblaze.b2.client.structures.B2FinishLargeFileRequest; +import com.backblaze.b2.client.structures.B2GetBucketNotificationRulesRequest; +import com.backblaze.b2.client.structures.B2GetBucketNotificationRulesResponse; import com.backblaze.b2.client.structures.B2GetDownloadAuthorizationRequest; import com.backblaze.b2.client.structures.B2GetFileInfoByNameRequest; import com.backblaze.b2.client.structures.B2GetFileInfoRequest; @@ -47,6 +49,8 @@ import com.backblaze.b2.client.structures.B2ListUnfinishedLargeFilesRequest; import com.backblaze.b2.client.structures.B2ListUnfinishedLargeFilesResponse; import com.backblaze.b2.client.structures.B2Part; +import com.backblaze.b2.client.structures.B2SetBucketNotificationRulesRequest; +import com.backblaze.b2.client.structures.B2SetBucketNotificationRulesResponse; import com.backblaze.b2.client.structures.B2StartLargeFileRequest; import com.backblaze.b2.client.structures.B2StoreLargeFileRequest; import com.backblaze.b2.client.structures.B2UpdateBucketRequest; @@ -81,7 +85,7 @@ public class B2StorageClientImpl implements B2StorageClient { private final B2AccountAuthorizationCache accountAuthCache; private final B2UploadUrlCache uploadUrlCache; - + private final boolean contiguousPartNumberingRequired; // protected by synchronized(this) // starts out false. it is changed to true when close() is called. @@ -116,6 +120,7 @@ public B2StorageClientImpl(B2StorageClientWebifier webifier, this.retryer = retryer; this.accountAuthCache = new B2AccountAuthorizationCache(webifier, config.getAccountAuthorizer()); this.uploadUrlCache = new B2UploadUrlCache(webifier, accountAuthCache); + this.contiguousPartNumberingRequired = config.isPartNumberGapsAllowed(); } /** @@ -245,7 +250,7 @@ public B2FileVersion storeLargeFileFromLocalContent( B2UploadListener uploadListener, ExecutorService executor) throws B2Exception { - return storeLargeFileFromLocalContent(B2StoreLargeFileRequest.builder(fileVersion).build(), contentSource, uploadListener, executor); + return storeLargeFileFromLocalContent(B2StoreLargeFileRequest.builder(fileVersion.getFileId()).build(), contentSource, uploadListener, executor); } @Override @@ -273,7 +278,7 @@ public CompletableFuture storeLargeFileFromLocalContentAsync( B2UploadListener uploadListenerOrNull, ExecutorService executor) throws B2Exception { - return storeLargeFileFromLocalContentAsync(B2StoreLargeFileRequest.builder(fileVersion).build(), contentSource, uploadListenerOrNull, executor); + return storeLargeFileFromLocalContentAsync(B2StoreLargeFileRequest.builder(fileVersion.getFileId()).build(), contentSource, uploadListenerOrNull, executor); } @Override @@ -302,7 +307,7 @@ public B2FileVersion storeLargeFile( List partStorers, B2UploadListener uploadListenerOrNull, ExecutorService executor) throws B2Exception { - return storeLargeFile(B2StoreLargeFileRequest.builder(fileVersion).build(), partStorers, uploadListenerOrNull, executor); + return storeLargeFile(B2StoreLargeFileRequest.builder(fileVersion.getFileId()).build(), partStorers, uploadListenerOrNull, executor); } @Override @@ -320,7 +325,38 @@ public B2FileVersion storeLargeFile( webifier, retryer, retryPolicySupplier, - executor).storeFile(uploadListenerOrNull); + executor, + contiguousPartNumberingRequired).storeFile(uploadListenerOrNull); + } + + @Override + public List storePartsForLargeFile( + B2FileVersion fileVersion, + List partStorers, + B2UploadListener uploadListenerOrNull, + ExecutorService executor) throws B2Exception { + return storePartsForLargeFile( + B2StoreLargeFileRequest.builder(fileVersion.getFileId()).build(), + partStorers, + uploadListenerOrNull, + executor); + } + + @Override + public List storePartsForLargeFile( + B2StoreLargeFileRequest storeLargeFileRequest, + List partStorers, + B2UploadListener uploadListenerOrNull, + ExecutorService executor) throws B2Exception { + return new B2LargeFileStorer( + storeLargeFileRequest, + partStorers, + accountAuthCache, + webifier, + retryer, + retryPolicySupplier, + executor, + contiguousPartNumberingRequired).storeParts(uploadListenerOrNull); } private B2FileVersion uploadLargeFileGuts(ExecutorService executor, @@ -370,7 +406,7 @@ public B2ListFilesIterable fileNames(B2ListFileNamesRequest request) throws B2Ex } @Override - public B2ListFilesIterable unfinishedLargeFiles(B2ListUnfinishedLargeFilesRequest request) throws B2Exception { + public B2ListFilesIterable unfinishedLargeFiles(B2ListUnfinishedLargeFilesRequest request) { return new B2ListUnfinishedLargeFilesIterable(this, request); } @@ -572,4 +608,18 @@ B2ListUnfinishedLargeFilesResponse listUnfinishedLargeFiles(B2ListUnfinishedLarg B2ListPartsResponse listParts(B2ListPartsRequest request) throws B2Exception { return retryer.doRetry("b2_list_parts", accountAuthCache, () -> webifier.listParts(accountAuthCache.get(), request), retryPolicySupplier.get()); } + + @Override + public B2SetBucketNotificationRulesResponse setBucketNotificationRules(B2SetBucketNotificationRulesRequest request) throws B2Exception { + return retryer.doRetry("b2_set_bucket_notification_rules", accountAuthCache, + () -> webifier.setBucketNotificationRules(accountAuthCache.get(), request), + retryPolicySupplier.get()); + } + + @Override + public B2GetBucketNotificationRulesResponse getBucketNotificationRules(B2GetBucketNotificationRulesRequest request) throws B2Exception { + return retryer.doRetry("b2_get_bucket_notification_rules", accountAuthCache, + () -> webifier.getBucketNotificationRules(accountAuthCache.get(), request), + retryPolicySupplier.get()); + } } diff --git a/core/src/main/java/com/backblaze/b2/client/B2StorageClientWebifier.java b/core/src/main/java/com/backblaze/b2/client/B2StorageClientWebifier.java index 151277cb9..2be3a3936 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2StorageClientWebifier.java +++ b/core/src/main/java/com/backblaze/b2/client/B2StorageClientWebifier.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client; @@ -12,8 +12,8 @@ import com.backblaze.b2.client.structures.B2Bucket; import com.backblaze.b2.client.structures.B2CancelLargeFileRequest; import com.backblaze.b2.client.structures.B2CancelLargeFileResponse; -import com.backblaze.b2.client.structures.B2CopyPartRequest; import com.backblaze.b2.client.structures.B2CopyFileRequest; +import com.backblaze.b2.client.structures.B2CopyPartRequest; import com.backblaze.b2.client.structures.B2CreateBucketRequestReal; import com.backblaze.b2.client.structures.B2CreateKeyRequestReal; import com.backblaze.b2.client.structures.B2CreatedApplicationKey; @@ -26,6 +26,8 @@ import com.backblaze.b2.client.structures.B2DownloadByNameRequest; import com.backblaze.b2.client.structures.B2FileVersion; import com.backblaze.b2.client.structures.B2FinishLargeFileRequest; +import com.backblaze.b2.client.structures.B2GetBucketNotificationRulesRequest; +import com.backblaze.b2.client.structures.B2GetBucketNotificationRulesResponse; import com.backblaze.b2.client.structures.B2GetDownloadAuthorizationRequest; import com.backblaze.b2.client.structures.B2GetFileInfoByNameRequest; import com.backblaze.b2.client.structures.B2GetFileInfoRequest; @@ -45,6 +47,8 @@ import com.backblaze.b2.client.structures.B2ListUnfinishedLargeFilesRequest; import com.backblaze.b2.client.structures.B2ListUnfinishedLargeFilesResponse; import com.backblaze.b2.client.structures.B2Part; +import com.backblaze.b2.client.structures.B2SetBucketNotificationRulesRequest; +import com.backblaze.b2.client.structures.B2SetBucketNotificationRulesResponse; import com.backblaze.b2.client.structures.B2StartLargeFileRequest; import com.backblaze.b2.client.structures.B2UpdateBucketRequest; import com.backblaze.b2.client.structures.B2UpdateFileLegalHoldRequest; @@ -176,6 +180,12 @@ B2UpdateFileLegalHoldResponse updateFileLegalHold(B2AccountAuthorization account B2UpdateFileRetentionResponse updateFileRetention(B2AccountAuthorization accountAuth, B2UpdateFileRetentionRequest request) throws B2Exception; + B2SetBucketNotificationRulesResponse setBucketNotificationRules(B2AccountAuthorization accountAuth, + B2SetBucketNotificationRulesRequest request) throws B2Exception; + + B2GetBucketNotificationRulesResponse getBucketNotificationRules(B2AccountAuthorization accountAuth, + B2GetBucketNotificationRulesRequest request) throws B2Exception; + /** * Closes this object and its underlying resources. * This is overridden from AutoCloseable to declare that it can't throw any exception. diff --git a/core/src/main/java/com/backblaze/b2/client/B2StorageClientWebifierImpl.java b/core/src/main/java/com/backblaze/b2/client/B2StorageClientWebifierImpl.java index 5737918d7..d10d43110 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2StorageClientWebifierImpl.java +++ b/core/src/main/java/com/backblaze/b2/client/B2StorageClientWebifierImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2020, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client; @@ -37,6 +37,8 @@ import com.backblaze.b2.client.structures.B2FileSseForResponse; import com.backblaze.b2.client.structures.B2FileVersion; import com.backblaze.b2.client.structures.B2FinishLargeFileRequest; +import com.backblaze.b2.client.structures.B2GetBucketNotificationRulesRequest; +import com.backblaze.b2.client.structures.B2GetBucketNotificationRulesResponse; import com.backblaze.b2.client.structures.B2GetDownloadAuthorizationRequest; import com.backblaze.b2.client.structures.B2GetFileInfoByNameRequest; import com.backblaze.b2.client.structures.B2GetFileInfoRequest; @@ -57,6 +59,8 @@ import com.backblaze.b2.client.structures.B2ListUnfinishedLargeFilesResponse; import com.backblaze.b2.client.structures.B2OverrideableHeaders; import com.backblaze.b2.client.structures.B2Part; +import com.backblaze.b2.client.structures.B2SetBucketNotificationRulesRequest; +import com.backblaze.b2.client.structures.B2SetBucketNotificationRulesResponse; import com.backblaze.b2.client.structures.B2StartLargeFileRequest; import com.backblaze.b2.client.structures.B2TestMode; import com.backblaze.b2.client.structures.B2UpdateBucketRequest; @@ -93,7 +97,7 @@ public class B2StorageClientWebifierImpl implements B2StorageClientWebifier { // This path specifies which version of the B2 APIs to use. // See: https://www.backblaze.com/b2/docs/versions.html - private static String API_VERSION_PATH = "b2api/v2/"; + private static final String API_VERSION_PATH = "b2api/v2/"; private final B2WebApiClient webApiClient; private final String userAgent; @@ -317,6 +321,12 @@ public B2FileVersion uploadFile(B2UploadUrlResponse uploadUrlResponse, headersBuilder.set(B2Headers.FILE_INFO_PREFIX + entry.getKey(), percentEncode(entry.getValue())); } + // Add custom upload timestamp if necessary + final Long customUploadTimestamp = request.getCustomUploadTimestamp(); + if (customUploadTimestamp != null) { + headersBuilder.set(B2Headers.CUSTOM_UPLOAD_TIMESTAMP, customUploadTimestamp.toString()); + } + final B2ByteProgressListener progressAdapter = new B2UploadProgressAdapter(uploadListener, 0, 1, 0, contentLen); final B2ByteProgressFilteringListener progressListener = new B2ByteProgressFilteringListener(progressAdapter); @@ -665,6 +675,26 @@ public B2UpdateFileRetentionResponse updateFileRetention(B2AccountAuthorization B2UpdateFileRetentionResponse.class); } + @Override + public B2SetBucketNotificationRulesResponse setBucketNotificationRules(B2AccountAuthorization accountAuth, + B2SetBucketNotificationRulesRequest request) throws B2Exception { + return webApiClient.postJsonReturnJson( + makeUrl(accountAuth, "b2_set_bucket_notification_rules"), + makeHeaders(accountAuth), + request, + B2SetBucketNotificationRulesResponse.class); + } + + @Override + public B2GetBucketNotificationRulesResponse getBucketNotificationRules(B2AccountAuthorization accountAuth, + B2GetBucketNotificationRulesRequest request) throws B2Exception { + return webApiClient.postJsonReturnJson( + makeUrl(accountAuth, "b2_get_bucket_notification_rules"), + makeHeaders(accountAuth), + request, + B2GetBucketNotificationRulesResponse.class); + } + private void addAuthHeader(B2HeadersImpl.Builder builder, B2AccountAuthorization accountAuth) { builder.set(B2Headers.AUTHORIZATION, accountAuth.getAuthorizationToken()); diff --git a/core/src/main/java/com/backblaze/b2/client/B2UploadingPartStorer.java b/core/src/main/java/com/backblaze/b2/client/B2UploadingPartStorer.java index 7cb08be07..58dbe7150 100644 --- a/core/src/main/java/com/backblaze/b2/client/B2UploadingPartStorer.java +++ b/core/src/main/java/com/backblaze/b2/client/B2UploadingPartStorer.java @@ -42,12 +42,12 @@ public long getPartSizeOrThrow() throws B2CannotComputeException { @Override public B2Part storePart( - B2LargeFileStorer largeFileCreationManager, + B2LargeFileStorer largeFileStorer, B2UploadListener uploadListener, B2CancellationToken cancellationToken) throws IOException, B2Exception { final B2CancellableContentSource cancellableContentSource = new B2CancellableContentSource(contentSource, cancellationToken); - return largeFileCreationManager.uploadPart(partNumber, cancellableContentSource, uploadListener, cancellationToken); + return largeFileStorer.uploadPart(partNumber, cancellableContentSource, uploadListener); } @Override diff --git a/core/src/main/java/com/backblaze/b2/client/contentSources/B2Headers.java b/core/src/main/java/com/backblaze/b2/client/contentSources/B2Headers.java index 58d58b5e2..242f7e6ff 100644 --- a/core/src/main/java/com/backblaze/b2/client/contentSources/B2Headers.java +++ b/core/src/main/java/com/backblaze/b2/client/contentSources/B2Headers.java @@ -77,6 +77,8 @@ public interface B2Headers { // header for Cloud Replication String REPLICATION_STATUS = "X-Bz-Replication-Status"; + String CUSTOM_UPLOAD_TIMESTAMP = "X-Bz-Custom-Upload-Timestamp"; + /** * @return a collection with the names of all the headers in this object. never null. */ diff --git a/core/src/main/java/com/backblaze/b2/client/exceptions/B2SignatureVerificationException.java b/core/src/main/java/com/backblaze/b2/client/exceptions/B2SignatureVerificationException.java new file mode 100644 index 000000000..ad5160546 --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/client/exceptions/B2SignatureVerificationException.java @@ -0,0 +1,18 @@ +/* + * Copyright 2024, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.exceptions; + +/** + * Instances of this class represents failure to verify content given a signature and secret. + * + */ +public class B2SignatureVerificationException extends B2Exception { + public static final String DEFAULT_CODE = "signature_verification_failed"; + public static final int STATUS = 0; + + public B2SignatureVerificationException(String message, Throwable cause) { + super(DEFAULT_CODE, STATUS, null /* no retries */, message, cause); + } +} diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2Bucket.java b/core/src/main/java/com/backblaze/b2/client/structures/B2Bucket.java index ee53ad405..a9b36a75e 100644 --- a/core/src/main/java/com/backblaze/b2/client/structures/B2Bucket.java +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2Bucket.java @@ -1,5 +1,5 @@ /* - * Copyright 2021, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client.structures; @@ -50,8 +50,7 @@ public class B2Bucket { @B2Json.required private final int revision; - @B2Json.constructor(params = "accountId,bucketId,bucketName,bucketType,bucketInfo,corsRules,lifecycleRules," + - "options,fileLockConfiguration,defaultServerSideEncryption,replicationConfiguration,revision") + @B2Json.constructor public B2Bucket(String accountId, String bucketId, String bucketName, @@ -196,7 +195,7 @@ public String toString() { fileLockConfiguration + "," + defaultServerSideEncryption + "," + replicationConfiguration + "," + - "v" + revision + + "v" + revision + ')'; } @@ -214,7 +213,7 @@ public boolean equals(Object o) { Objects.equals(getCorsRules(), b2Bucket.getCorsRules()) && Objects.equals(getLifecycleRules(), b2Bucket.getLifecycleRules()) && Objects.equals(getOptions(), b2Bucket.getOptions()) && - // don't use getter for these two fields because they can throw B2ForbiddenException + // don't use getter for these fields because they can throw B2ForbiddenException Objects.equals(fileLockConfiguration, b2Bucket.fileLockConfiguration) && Objects.equals(defaultServerSideEncryption, b2Bucket.defaultServerSideEncryption) && Objects.equals(replicationConfiguration, b2Bucket.replicationConfiguration); @@ -232,7 +231,7 @@ public int hashCode() { getLifecycleRules(), getOptions(), getRevision(), - // don't use getter for these two fields because they can throw B2ForbiddenException + // don't use getter for these fields because they can throw B2ForbiddenException fileLockConfiguration, defaultServerSideEncryption, replicationConfiguration diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2Capabilities.java b/core/src/main/java/com/backblaze/b2/client/structures/B2Capabilities.java index f0565d463..ce3575c78 100644 --- a/core/src/main/java/com/backblaze/b2/client/structures/B2Capabilities.java +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2Capabilities.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ @@ -48,4 +48,7 @@ public interface B2Capabilities { String READ_BUCKET_REPLICATIONS = "readBucketReplications"; String WRITE_BUCKET_REPLICATIONS = "writeBucketReplications"; + + String READ_BUCKET_NOTIFICATIONS = "readBucketNotifications"; + String WRITE_BUCKET_NOTIFICATIONS = "writeBucketNotifications"; } diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2CreateBucketRequest.java b/core/src/main/java/com/backblaze/b2/client/structures/B2CreateBucketRequest.java index 6288cad90..38475e228 100644 --- a/core/src/main/java/com/backblaze/b2/client/structures/B2CreateBucketRequest.java +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2CreateBucketRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client.structures; diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2CreateBucketRequestReal.java b/core/src/main/java/com/backblaze/b2/client/structures/B2CreateBucketRequestReal.java index 05a78d7d3..f2325cfa2 100644 --- a/core/src/main/java/com/backblaze/b2/client/structures/B2CreateBucketRequestReal.java +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2CreateBucketRequestReal.java @@ -1,5 +1,5 @@ /* - * Copyright 2021, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client.structures; @@ -46,8 +46,7 @@ public class B2CreateBucketRequestReal { @B2Json.optional private final B2BucketReplicationConfiguration replicationConfiguration; - @B2Json.constructor(params = "accountId,bucketName,bucketType,bucketInfo,corsRules,lifecycleRules,fileLockEnabled,"+ - "defaultServerSideEncryption, replicationConfiguration") + @B2Json.constructor private B2CreateBucketRequestReal(String accountId, String bucketName, String bucketType, diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotification.java b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotification.java new file mode 100644 index 000000000..8666548e2 --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotification.java @@ -0,0 +1,175 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.client.exceptions.B2SignatureVerificationException; +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.json.B2JsonException; +import com.backblaze.b2.util.B2Preconditions; +import com.backblaze.b2.util.B2StringUtil; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * The notification that Backblaze sends when object events occur. + */ +public class B2EventNotification { + + @B2Json.required + private final List events; + + @B2Json.constructor + public B2EventNotification(List events) { + this.events = events; + } + + /** + * A list of object events. + */ + public List getEvents() { + return events; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof B2EventNotification)) return false; + final B2EventNotification that = (B2EventNotification) o; + return Objects.equals(events, that.events); + } + + @Override + public int hashCode() { + return Objects.hash(events); + } + + @Override + public String toString() { + return "B2EventNotification{" + + "events=" + events + + '}'; + } + + /** + * Create a new EventNotification from JSON content with signature check. + * + * @param json - The JSON content to create an B2EventNotification object from. The byte array should be UTF-8 encoded + * @return The B2EventNotification object + * @throws B2JsonException - If the content is not valid JSON + */ + public static B2EventNotification parse(byte[] json) throws IOException, B2JsonException { + B2Preconditions.checkArgumentIsNotNull(json, "json"); + B2Preconditions.checkArgument(json.length > 0); + + return B2Json.get().fromJson(json , B2EventNotification.class); + } + + /** + * Create a new EventNotification from JSON content with signature check. + * + * @param json - The JSON content to create an B2EventNotification object from. + * @return The B2EventNotification object + * @throws B2JsonException - If the content is not valid JSON + */ + public static B2EventNotification parse(String json) throws B2JsonException { + B2Preconditions.checkArgument(!B2StringUtil.isEmpty(json), "json is required"); + + return B2Json.get().fromJson(json, B2EventNotification.class); + } + + /** + * Create a new EventNotification from JSON content with signature check. + * + * @param json - The JSON content to create an B2EventNotification object from. The byte array should be UTF-8 encoded + * @param signatureFromHeader - The value of the X-Bz-Event-Notification-Signature header + * @param signingSecret - The secret for computing the signature. + * @return The B2EventNotification object + * @throws B2JsonException - If the content is not valid JSON + * @throws B2SignatureVerificationException - if the content does not match the signature from header. + */ + public static B2EventNotification parse(byte[] json, + String signatureFromHeader, + String signingSecret) + throws B2JsonException, IOException, B2SignatureVerificationException { + B2Preconditions.checkArgumentIsNotNull(json, "json"); + B2Preconditions.checkArgument(json.length > 0); + + // Only validate the signature if signature and secret are both provided + if (signatureFromHeader != null && signingSecret != null) { + SignatureUtils.verifySignature(json, signatureFromHeader, signingSecret); + } + return B2Json.get().fromJson(json , B2EventNotification.class); + } + + /** + * Create a new EventNotification from JSON content with signature check. + * + * @param json - The JSON content to create an B2EventNotification object from. + * @param signatureFromHeader - The value of the X-Bz-Event-Notification-Signature header + * @param signingSecret - The secret for computing the signature. + * @return The B2EventNotification object + * @throws B2JsonException - If the content is not valid JSON + * @throws B2SignatureVerificationException - if the content does not match the signature from header. + */ + public static B2EventNotification parse(String json, + String signatureFromHeader, + String signingSecret) + throws B2JsonException, B2SignatureVerificationException { + B2Preconditions.checkArgument(!B2StringUtil.isEmpty(json), "json is required"); + + // Only validate the signature if signature and secret are both provided + if (signatureFromHeader != null && signingSecret != null) { + SignatureUtils.verifySignature( + json.getBytes(StandardCharsets.UTF_8), signatureFromHeader, signingSecret); + } + return B2Json.get().fromJson(json , B2EventNotification.class); + } + + /*testing*/ + static class SignatureUtils { + public static void verifySignature(byte[] json, String signatureFromHeader, String signingSecret) throws B2SignatureVerificationException { + final String[] signatures = signatureFromHeader.split(","); + final String signature = computeHmacSha256Signature(signingSecret, json); + + boolean signatureMatch = Arrays.stream(signatures).anyMatch(signatureToVerify -> Objects.equals(signatureToVerify, signature)); + if (!signatureMatch) { + throw new B2SignatureVerificationException("Signature from header does not match calculated signature", null); + } + } + + static String computeHmacSha256Signature(String signingSecret, + byte[] b2JsonSerializableObject) throws B2SignatureVerificationException { + final byte[] hmacSha256Signature = sign(signingSecret.getBytes(StandardCharsets.UTF_8), b2JsonSerializableObject); + return "v1=" + B2StringUtil.toHexString(hmacSha256Signature); + } + + private static byte[] sign(byte[] key, byte[] data) throws B2SignatureVerificationException { + final SecretKeySpec secretKey = new SecretKeySpec(key, "HmacSHA256"); + final Mac mac = getMac(); + try { + mac.init(secretKey); + } catch (InvalidKeyException e) { + throw new B2SignatureVerificationException("Invalid key for HmacSHA256", e); + } + return mac.doFinal(data); + } + + private static Mac getMac() throws B2SignatureVerificationException { + try { + return Mac.getInstance("HmacSHA256"); + } catch (NoSuchAlgorithmException error) { + throw new B2SignatureVerificationException("Cannot get HmacSHA256 algorithm which is required to be available", error); + } + } + } +} diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationEvent.java b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationEvent.java new file mode 100644 index 000000000..f95331956 --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationEvent.java @@ -0,0 +1,190 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.util.B2Preconditions; +import com.backblaze.b2.util.B2StringUtil; + +import java.util.Objects; + +/** + * The individual event notification for an object. + */ +public class B2EventNotificationEvent { + + @B2Json.required + private final String accountId; + @B2Json.required + private final String bucketId; + @B2Json.required + private final String bucketName; + @B2Json.required + private final long eventTimestamp; + @B2Json.required + private final String eventType; + @B2Json.required + private final int eventVersion; + @B2Json.required + private final String matchedRuleName; + @B2Json.optional(omitNull = true) + private final String objectName; + @B2Json.optional(omitNull = true) + private final Long objectSize; + @B2Json.optional(omitNull = true) + private final String objectVersionId; + + @B2Json.constructor + public B2EventNotificationEvent(String accountId, + String bucketId, + String bucketName, + long eventTimestamp, + String eventType, + int eventVersion, + String matchedRuleName, + String objectName, + Long objectSize, + String objectVersionId) { + + if (!"b2:TestEvent".equals(eventType)) { + B2Preconditions.checkArgument(!B2StringUtil.isEmpty(objectName), "objectName is required"); + B2Preconditions.checkArgument(!B2StringUtil.isEmpty(objectVersionId), "objectVersionId is required"); + } else { + B2Preconditions.checkArgument(objectName == null, "objectName must be null for test events"); + B2Preconditions.checkArgument(objectSize == null, "objectSize must be null for test events"); + B2Preconditions.checkArgument(objectVersionId == null, "objectVersionId must be null for test events"); + } + this.accountId = accountId; + this.bucketId = bucketId; + this.bucketName = bucketName; + this.eventTimestamp = eventTimestamp; + this.eventType = eventType; + this.eventVersion = eventVersion; + this.matchedRuleName = matchedRuleName; + this.objectName = objectName; + this.objectSize = objectSize; + this.objectVersionId = objectVersionId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final B2EventNotificationEvent that = (B2EventNotificationEvent) o; + return eventTimestamp == that.eventTimestamp && + eventVersion == that.eventVersion && + Objects.equals(accountId, that.accountId) && + Objects.equals(bucketId, that.bucketId) && + Objects.equals(bucketName, that.bucketName) && + Objects.equals(eventType, that.eventType) && + Objects.equals(matchedRuleName, that.matchedRuleName) && + Objects.equals(objectName, that.objectName) && + Objects.equals(objectSize, that.objectSize) && + Objects.equals(objectVersionId, that.objectVersionId); + } + + @Override + public int hashCode() { + return Objects.hash( + accountId, + bucketId, + bucketName, + eventTimestamp, + eventType, + eventVersion, + matchedRuleName, + objectName, + objectSize, + objectVersionId); + } + + /** + * The version of the event notification payload. + */ + public int getEventVersion() { + return eventVersion; + } + + /** + * The name of the event notification rule the corresponds to the event. + */ + public String getMatchedRuleName() { + return matchedRuleName; + } + + /** + * The name of the bucket where the objects reside that corresponds to the event. + */ + public String getBucketName() { + return bucketName; + } + + /** + * The ID of the bucket where the objects reside that corresponds to the event. + */ + public String getBucketId() { + return bucketId; + } + + /** + * The ID of the account where the objects reside that corresponds to the event. + */ + public String getAccountId() { + return accountId; + } + + /** + * The UTC time when this event was generated. It is a base 10 number of milliseconds since midnight, January 1, 1970 UTC. + */ + public long getEventTimestamp() { + return eventTimestamp; + } + + /** + * The event type of the event notification rule that corresponds to the event. + */ + public String getEventType() { + return eventType; + } + + /** + * The name of the object that corresponds to the event. This will be null for test events. + */ + public String getObjectName() { + return objectName; + } + + /** + * The size of bytes of the object that corresponds to the event. The objectSize would be null for hide marker, + * delete, and test events. + */ + public Long getObjectSize() { + return objectSize; + } + + /** + * The unique identifier for the version of the object that corresponds to the event. This will be null + * for test events. + */ + public String getObjectVersionId() { + return objectVersionId; + } + + @Override + public String toString() { + return "B2EventNotificationEvent{" + + "accountId='" + accountId + '\'' + + ", bucketId='" + bucketId + '\'' + + ", bucketName='" + bucketName + '\'' + + ", eventTimestamp=" + eventTimestamp + + ", eventType='" + eventType + '\'' + + ", eventVersion=" + eventVersion + + ", matchedRuleName='" + matchedRuleName + '\'' + + ", objectName='" + objectName + '\'' + + ", objectSize=" + objectSize + + ", objectVersionId='" + objectVersionId + '\'' + + '}'; + } +} diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationRule.java b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationRule.java new file mode 100644 index 000000000..a46734c3a --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationRule.java @@ -0,0 +1,175 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; + +import java.util.Comparator; +import java.util.Objects; +import java.util.TreeSet; + +/** + * One rule about under what condition(s) to send notifications for events in a bucket. + */ +public class B2EventNotificationRule implements Comparable { + private static final Comparator COMPARATOR = Comparator.comparing(B2EventNotificationRule::getName) + .thenComparing(rule -> String.join(",", rule.getEventTypes())) + .thenComparing(B2EventNotificationRule::getObjectNamePrefix) + .thenComparing(rule -> rule.getTargetConfiguration().toString()) + .thenComparing(B2EventNotificationRule::isEnabled) + .thenComparing(B2EventNotificationRule::isSuspended) + .thenComparing(B2EventNotificationRule::getSuspensionReason); + + /** + * A name for identifying the rule. Names must be unique within a bucket. + * The length requirements correspond to the bucket minimum and maximum lengths, + * which are 6 and 63 characters respectively at the time of this writing. + */ + @B2Json.required + private final String name; + + /** + * The Set of Strings identifying the applicable event types for this rule. + * Event types support the wildcard character "*" in the last component only. + * However, event types must not overlap. For example, the Set must + * NOT contain "b2:ObjectCreated:Upload" and "b2:ObjectCreated:*". + */ + @B2Json.required + private final TreeSet eventTypes; + + /** + * The prefix that specifies what object(s) this rule applies to. + * Always set. "" means all objects. + */ + @B2Json.required + private final String objectNamePrefix; + + /** + * The target configuration for the event notification. + */ + @B2Json.required + private final B2EventNotificationTargetConfiguration targetConfiguration; + + /** + * Indicates if the rule is enabled. + */ + @B2Json.required + private final boolean isEnabled; + + /** + * Indicates if the rule is suspended. + */ + @B2Json.optional + private final Boolean isSuspended; + + /** + * If isSuspended is true, specifies the reason the rule was + * suspended. + */ + @B2Json.optional + private final String suspensionReason; + + @B2Json.constructor + public B2EventNotificationRule(String name, + TreeSet eventTypes, + String objectNamePrefix, + B2EventNotificationTargetConfiguration targetConfiguration, + boolean isEnabled, + Boolean isSuspended, + String suspensionReason) { + + this.name = name; + this.eventTypes = new TreeSet<>(eventTypes); + this.objectNamePrefix = objectNamePrefix; + this.targetConfiguration = targetConfiguration; + this.isEnabled = isEnabled; + this.isSuspended = isSuspended; + this.suspensionReason = suspensionReason; + } + + public B2EventNotificationRule(String name, + TreeSet eventTypes, + String objectNamePrefix, + B2EventNotificationTargetConfiguration targetConfiguration, + boolean isEnabled) { + this(name, eventTypes, objectNamePrefix, targetConfiguration, isEnabled, null, null); + } + + public String getName() { + return name; + } + + public TreeSet getEventTypes() { + return eventTypes; + } + + public String getObjectNamePrefix() { + return objectNamePrefix; + } + + public B2EventNotificationTargetConfiguration getTargetConfiguration() { + return targetConfiguration; + } + + public boolean isEnabled() { + return isEnabled; + } + + public boolean isSuspended() { + return isSuspended; + } + + public String getSuspensionReason() { + return suspensionReason; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final B2EventNotificationRule that = (B2EventNotificationRule) o; + return isEnabled == that.isEnabled && + name.equals(that.name) && + eventTypes.equals(that.eventTypes) && + objectNamePrefix.equals(that.objectNamePrefix) && + targetConfiguration.equals(that.targetConfiguration) && + Objects.equals(isSuspended, that.isSuspended) && + Objects.equals(suspensionReason, that.suspensionReason); + } + + @Override + public int hashCode() { + return Objects.hash( + name, + eventTypes, + objectNamePrefix, + targetConfiguration, + isEnabled, + isSuspended, + suspensionReason + ); + } + + @Override + public String toString() { + return "B2EventNotificationRule{" + + "name='" + name + '\'' + + ", eventTypes=" + eventTypes + + ", objectNamePrefix='" + objectNamePrefix + '\'' + + ", targetConfiguration=" + targetConfiguration + + ", isEnabled=" + isEnabled + + ", isSuspended=" + isSuspended + + ", suspensionReason='" + suspensionReason + '\'' + + '}'; + } + + /** + * Rules are sorted by name first, and then additional attributes if necessary. + */ + @Override + public int compareTo(B2EventNotificationRule r) { + return COMPARATOR.compare(this, r); + } +} diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationTargetConfiguration.java b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationTargetConfiguration.java new file mode 100644 index 000000000..1924d15bf --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2EventNotificationTargetConfiguration.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.json.B2JsonException; +import com.backblaze.b2.json.B2JsonUnionTypeMap; + +/** + * A destination for an event notification. Used in B2EventNotificationRule. + */ +@B2Json.union(typeField = "targetType") +public class B2EventNotificationTargetConfiguration { + @SuppressWarnings("unused") // used by B2Json + public static B2JsonUnionTypeMap getUnionTypeMap() throws B2JsonException { + return B2JsonUnionTypeMap + .builder() + .put("webhook", B2WebhookConfiguration.class) + .build(); + } +} diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesRequest.java b/core/src/main/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesRequest.java new file mode 100644 index 000000000..bf9bcec8e --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesRequest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; + +import java.util.Objects; + +public class B2GetBucketNotificationRulesRequest { + + @B2Json.required + private final String bucketId; + + @B2Json.constructor + private B2GetBucketNotificationRulesRequest(String bucketId) { + this.bucketId = bucketId; + } + + public String getBucketId() { + return bucketId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final B2GetBucketNotificationRulesRequest that = (B2GetBucketNotificationRulesRequest) o; + return bucketId.equals(that.bucketId); + } + + @Override + public int hashCode() { + return Objects.hash(bucketId); + } + + @Override + public String toString() { + return "B2GetBucketNotificationRulesRequest{" + + "bucketId='" + bucketId + '\'' + + '}'; + } + + public static B2GetBucketNotificationRulesRequest.Builder builder(String bucketId) { + return new B2GetBucketNotificationRulesRequest.Builder(bucketId); + } + + public static class Builder { + private final String bucketId; + + public Builder(String bucketId) { + this.bucketId = bucketId; + } + + public B2GetBucketNotificationRulesRequest build() { + return new B2GetBucketNotificationRulesRequest(bucketId); + } + } +} diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesResponse.java b/core/src/main/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesResponse.java new file mode 100644 index 000000000..8b9f20f9d --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesResponse.java @@ -0,0 +1,76 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class B2GetBucketNotificationRulesResponse { + + @B2Json.required + private final String bucketId; + + @B2Json.required + private final List eventNotificationRules; + + @B2Json.constructor + public B2GetBucketNotificationRulesResponse(String bucketId, + List eventNotificationRules) { + this.bucketId = bucketId; + this.eventNotificationRules = eventNotificationRules; + } + + public String getBucketId() { + return bucketId; + } + + public List getEventNotificationRules() { + return new ArrayList<>(eventNotificationRules); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final B2GetBucketNotificationRulesResponse that = (B2GetBucketNotificationRulesResponse) o; + return bucketId.equals(that.bucketId) && eventNotificationRules.equals(that.eventNotificationRules); + } + + @Override + public int hashCode() { + return Objects.hash(bucketId, eventNotificationRules); + } + + @Override + public String toString() { + return "B2GetBucketNotificationRulesResponse{" + + "bucketId='" + bucketId + '\'' + + ", eventNotificationRules=" + eventNotificationRules + + '}'; + } + + public static B2GetBucketNotificationRulesResponse.Builder builder(String bucketId, + List eventNotificationRules) { + return new B2GetBucketNotificationRulesResponse.Builder(bucketId, eventNotificationRules); + } + + public static class Builder { + private final String bucketId; + private final List eventNotificationRules; + + public Builder(String bucketId, + List eventNotificationRules) { + this.bucketId = bucketId; + this.eventNotificationRules = eventNotificationRules; + } + + public B2GetBucketNotificationRulesResponse build() { + return new B2GetBucketNotificationRulesResponse(bucketId, eventNotificationRules); + } + } +} diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2LifecycleRule.java b/core/src/main/java/com/backblaze/b2/client/structures/B2LifecycleRule.java index 755258076..9bb9686d9 100644 --- a/core/src/main/java/com/backblaze/b2/client/structures/B2LifecycleRule.java +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2LifecycleRule.java @@ -36,6 +36,14 @@ public class B2LifecycleRule { @B2Json.optional private final Integer daysFromHidingToDeleting; + /** + * Number of days from the time an unfinished large file is started + * until it is canceled. + * Null means never cancel. + */ + @B2Json.optional + private final Integer daysFromStartingToCancelingUnfinishedLargeFiles; + public static Builder builder(String fileNamePrefix) { return new Builder(fileNamePrefix); } @@ -52,30 +60,41 @@ public Integer getDaysFromHidingToDeleting() { return daysFromHidingToDeleting; } + public Integer getDaysFromStartingToCancelingUnfinishedLargeFiles() { + return daysFromStartingToCancelingUnfinishedLargeFiles; + } + /** * Initializes a new, immutable rule. */ - @B2Json.constructor(params = "fileNamePrefix, daysFromUploadingToHiding, daysFromHidingToDeleting") + @B2Json.constructor private B2LifecycleRule(String fileNamePrefix, Integer daysFromUploadingToHiding, - Integer daysFromHidingToDeleting) { + Integer daysFromHidingToDeleting, + Integer daysFromStartingToCancelingUnfinishedLargeFiles) { B2Preconditions.checkArgument(fileNamePrefix != null, "fileNamePrefix must not be null"); B2Preconditions.checkArgument(isNullOrPositive(daysFromUploadingToHiding), "daysFromUploadingToHiding must be positive"); B2Preconditions.checkArgument(isNullOrPositive(daysFromHidingToDeleting), "daysFromHidingToDeleting must be positive"); + B2Preconditions.checkArgument(isNullOrPositive(daysFromStartingToCancelingUnfinishedLargeFiles), + "daysFromStartingToCancelingUnfinishedLargeFiles must be positive"); this.daysFromUploadingToHiding = daysFromUploadingToHiding; this.daysFromHidingToDeleting = daysFromHidingToDeleting; + this.daysFromStartingToCancelingUnfinishedLargeFiles = daysFromStartingToCancelingUnfinishedLargeFiles; this.fileNamePrefix = fileNamePrefix; } @Override public String toString() { - return String.format( - "%s:%s:%s", - fileNamePrefix, - daysFromUploadingToHiding, - daysFromHidingToDeleting - ); + return new StringBuilder(32) + .append(fileNamePrefix) + .append(":") + .append(daysFromUploadingToHiding) + .append(":") + .append(daysFromHidingToDeleting) + .append(":") + .append(daysFromStartingToCancelingUnfinishedLargeFiles) + .toString(); } @Override @@ -85,12 +104,18 @@ public boolean equals(Object o) { B2LifecycleRule that = (B2LifecycleRule) o; return Objects.equals(fileNamePrefix, that.fileNamePrefix) && Objects.equals(daysFromUploadingToHiding, that.daysFromUploadingToHiding) && - Objects.equals(daysFromHidingToDeleting, that.daysFromHidingToDeleting); + Objects.equals(daysFromHidingToDeleting, that.daysFromHidingToDeleting) && + Objects.equals(daysFromStartingToCancelingUnfinishedLargeFiles, that.daysFromStartingToCancelingUnfinishedLargeFiles); } @Override public int hashCode() { - return Objects.hash(fileNamePrefix, daysFromUploadingToHiding, daysFromHidingToDeleting); + return Objects.hash( + fileNamePrefix, + daysFromUploadingToHiding, + daysFromHidingToDeleting, + daysFromStartingToCancelingUnfinishedLargeFiles + ); } /** @@ -104,6 +129,7 @@ public static class Builder { private final String fileNamePrefix; private Integer daysFromUploadingToHiding; private Integer daysFromHidingToDeleting; + private Integer daysFromStartingToCancelingUnfinishedLargeFiles; public Builder(String fileNamePrefix) { this.fileNamePrefix = fileNamePrefix; @@ -119,8 +145,16 @@ public Builder setDaysFromHidingToDeleting(Integer daysFromHidingToDeleting) { return this; } + public Builder setDaysFromStartingToCancelingUnfinishedLargeFiles(Integer daysFromStartingToCancelingUnfinishedLargeFiles) { + this.daysFromStartingToCancelingUnfinishedLargeFiles = daysFromStartingToCancelingUnfinishedLargeFiles; + return this; + } + public B2LifecycleRule build() { - return new B2LifecycleRule(fileNamePrefix, daysFromUploadingToHiding, daysFromHidingToDeleting); + return new B2LifecycleRule(fileNamePrefix, + daysFromUploadingToHiding, + daysFromHidingToDeleting, + daysFromStartingToCancelingUnfinishedLargeFiles); } } } diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesRequest.java b/core/src/main/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesRequest.java new file mode 100644 index 000000000..8b70cad79 --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesRequest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class B2SetBucketNotificationRulesRequest { + + @B2Json.required + private final String bucketId; + + @B2Json.required + private final List eventNotificationRules; + + @B2Json.constructor + private B2SetBucketNotificationRulesRequest(String bucketId, + List eventNotificationRules) { + this.bucketId = bucketId; + this.eventNotificationRules = eventNotificationRules; + } + + public String getBucketId() { + return bucketId; + } + + public List getEventNotificationRules() { + return new ArrayList<>(eventNotificationRules); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final B2SetBucketNotificationRulesRequest that = (B2SetBucketNotificationRulesRequest) o; + return bucketId.equals(that.bucketId) && eventNotificationRules.equals(that.eventNotificationRules); + } + + @Override + public int hashCode() { + return Objects.hash(bucketId, eventNotificationRules); + } + + @Override + public String toString() { + return "B2SetBucketNotificationRulesRequest{" + + "bucketId='" + bucketId + '\'' + + ", eventNotificationRules=" + eventNotificationRules + + '}'; + } + + public static B2SetBucketNotificationRulesRequest.Builder builder(String bucketId, + List eventNotificationRules) { + return new B2SetBucketNotificationRulesRequest.Builder(bucketId, eventNotificationRules); + } + + public static class Builder { + private final String bucketId; + + private final List eventNotificationRules; + + public Builder(String bucketId, + List eventNotificationRules) { + this.bucketId = bucketId; + this.eventNotificationRules = eventNotificationRules; + } + + public B2SetBucketNotificationRulesRequest build() { + return new B2SetBucketNotificationRulesRequest(bucketId, eventNotificationRules); + } + } +} diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesResponse.java b/core/src/main/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesResponse.java new file mode 100644 index 000000000..41a2faa2f --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesResponse.java @@ -0,0 +1,76 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class B2SetBucketNotificationRulesResponse { + + @B2Json.required + private final String bucketId; + + @B2Json.required + private final List eventNotificationRules; + + @B2Json.constructor + public B2SetBucketNotificationRulesResponse(String bucketId, + List eventNotificationRules) { + this.bucketId = bucketId; + this.eventNotificationRules = eventNotificationRules; + } + + public String getBucketId() { + return bucketId; + } + + public List getEventNotificationRules() { + return new ArrayList<>(eventNotificationRules); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final B2SetBucketNotificationRulesResponse that = (B2SetBucketNotificationRulesResponse) o; + return bucketId.equals(that.bucketId) && eventNotificationRules.equals(that.eventNotificationRules); + } + + @Override + public int hashCode() { + return Objects.hash(bucketId, eventNotificationRules); + } + + @Override + public String toString() { + return "B2SetBucketNotificationRulesResponse{" + + "bucketId='" + bucketId + '\'' + + ", eventNotificationRules=" + eventNotificationRules + + '}'; + } + + public static B2SetBucketNotificationRulesResponse.Builder builder(String bucketId, + List eventNotificationRules) { + return new B2SetBucketNotificationRulesResponse.Builder(bucketId, eventNotificationRules); + } + + public static class Builder { + private final String bucketId; + private final List eventNotificationRules; + + public Builder(String bucketId, + List eventNotificationRules) { + this.bucketId = bucketId; + this.eventNotificationRules = eventNotificationRules; + } + + public B2SetBucketNotificationRulesResponse build() { + return new B2SetBucketNotificationRulesResponse(bucketId, eventNotificationRules); + } + } +} diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2StartLargeFileRequest.java b/core/src/main/java/com/backblaze/b2/client/structures/B2StartLargeFileRequest.java index b07248949..8a09e355b 100644 --- a/core/src/main/java/com/backblaze/b2/client/structures/B2StartLargeFileRequest.java +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2StartLargeFileRequest.java @@ -35,15 +35,18 @@ public class B2StartLargeFileRequest { @B2Json.optional(omitNull = true) private final String legalHold; - @B2Json.constructor(params = "bucketId,fileName,contentType,serverSideEncryption,fileInfo," + - "fileRetention,legalHold") + @B2Json.optional(omitNull = true) + private final Long customUploadTimestamp; + + @B2Json.constructor private B2StartLargeFileRequest(String bucketId, String fileName, String contentType, B2FileSseForRequest serverSideEncryption, Map fileInfo, B2FileRetention fileRetention, - String legalHold) { + String legalHold, + Long customUploadTimestamp) { this.bucketId = bucketId; this.fileName = fileName; this.contentType = contentType; @@ -51,6 +54,7 @@ private B2StartLargeFileRequest(String bucketId, this.fileInfo = B2Collections.unmodifiableMap(fileInfo); this.fileRetention = fileRetention; this.legalHold = legalHold; + this.customUploadTimestamp = customUploadTimestamp; } public String getBucketId() { @@ -81,6 +85,10 @@ public String getLegalHold() { return legalHold; } + public Long getCustomUploadTimestamp() { + return customUploadTimestamp; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -92,7 +100,8 @@ public boolean equals(Object o) { Objects.equals(getServerSideEncryption(), that.getServerSideEncryption()) && Objects.equals(getFileInfo(), that.getFileInfo()) && Objects.equals(getFileRetention(), that.getFileRetention()) && - Objects.equals(getLegalHold(), that.getLegalHold()); + Objects.equals(getLegalHold(), that.getLegalHold()) && + Objects.equals(getCustomUploadTimestamp(), that.getCustomUploadTimestamp()); } @Override @@ -104,7 +113,8 @@ public int hashCode() { getServerSideEncryption(), getFileInfo(), getFileRetention(), - getLegalHold() + getLegalHold(), + getCustomUploadTimestamp() ); } @@ -128,6 +138,9 @@ public static B2StartLargeFileRequest buildFrom(B2UploadFileRequest orig) throws // we always start with the original fileInfo. builder.setCustomFields(orig.getFileInfo()); + // copy custom upload timestamp (if any) from original + builder.setCustomUploadTimestamp(orig.getCustomUploadTimestamp()); + final String largeFileSha1 = orig.getContentSource().getSha1OrNull(); if (largeFileSha1 != null) { // there's a largeFileSha1 in the contentSource, so use it. @@ -164,6 +177,8 @@ public static class Builder { private B2FileRetention fileRetention; private String legalHold; + private Long customUploadTimestamp; + Builder(String bucketId, String fileName, String contentType) { @@ -213,6 +228,11 @@ public Builder setLegalHold(String legalHold) { return this; } + public Builder setCustomUploadTimestamp(Long customUploadTimestamp) { + this.customUploadTimestamp = customUploadTimestamp; + return this; + } + public B2StartLargeFileRequest build() { return new B2StartLargeFileRequest(bucketId, fileName, @@ -220,7 +240,9 @@ public B2StartLargeFileRequest build() { serverSideEncryption, fileInfo, fileRetention, - legalHold); + legalHold, + customUploadTimestamp + ); } } } diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2StoreLargeFileRequest.java b/core/src/main/java/com/backblaze/b2/client/structures/B2StoreLargeFileRequest.java index 31c368a2b..484d71898 100644 --- a/core/src/main/java/com/backblaze/b2/client/structures/B2StoreLargeFileRequest.java +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2StoreLargeFileRequest.java @@ -12,24 +12,33 @@ import static com.backblaze.b2.client.structures.B2ServerSideEncryptionMode.SSE_C; public class B2StoreLargeFileRequest { + @B2Json.optional + private final String fileId; @B2Json.required - private final B2FileVersion fileVersion; + private final B2FileVersion b2FileVersion; @B2Json.optional private final B2FileSseForRequest serverSideEncryption; - @B2Json.constructor(params = "fileVersion,serverSideEncryption") - private B2StoreLargeFileRequest(B2FileVersion fileVersion, + @B2Json.constructor(params = "fileId,fileVersion,serverSideEncryption") + private B2StoreLargeFileRequest(String fileId, + B2FileVersion b2FileVersion, B2FileSseForRequest serverSideEncryption) { - B2Preconditions.checkArgumentIsNotNull(fileVersion, "fileVersion"); + B2Preconditions.checkArgumentIsNotNull(b2FileVersion, "b2FileVersion"); // SSE parameters must be null for all but SSE-C part uploads B2Preconditions.checkArgument(serverSideEncryption == null || SSE_C.equals(serverSideEncryption.getMode())); - this.fileVersion = fileVersion; + this.fileId = (fileId != null) ? fileId : b2FileVersion.getFileId(); + this.b2FileVersion = b2FileVersion; this.serverSideEncryption = serverSideEncryption; } + public String getFileId() { + return fileId; + } + + @Deprecated public B2FileVersion getFileVersion() { - return fileVersion; + return b2FileVersion; } public B2FileSseForRequest getServerSideEncryption() { @@ -41,34 +50,64 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; B2StoreLargeFileRequest that = (B2StoreLargeFileRequest) o; - return Objects.equals(getFileVersion(), that.getFileVersion()) && + return Objects.equals(getFileId(), that.getFileId()) && Objects.equals(getServerSideEncryption(), that.getServerSideEncryption()); } @Override public int hashCode() { - return Objects.hash(getFileVersion(), getServerSideEncryption()); + return Objects.hash(getFileId(), getServerSideEncryption()); } + @Deprecated public static Builder builder(B2FileVersion fileVersion) { return new Builder(fileVersion); } + public static Builder builder(String fileId) { + return new Builder(fileId); + } + public static class Builder { - private B2FileVersion fileVersion; + private final String fileId; + private final B2FileVersion b2FileVersion; private B2FileSseForRequest serverSideEncryption; - Builder(B2FileVersion fileVersion) { - this.fileVersion = fileVersion; + Builder(String fileId) { + this.fileId = fileId; + // old B2LargeFileStorer code only uses the file ID, so we're deprecating the b2FileVersion in favor + // of just using the file ID directly; in the meantime, we create a b2FileVersion that only has the + // fileId field and everything else set to null/zero + this.b2FileVersion = new B2FileVersion( + fileId, + null, + 0, + null, + null, + null, + null, + null, + 0, + null, + null, + null, + null + ); + } + + Builder(B2FileVersion b2FileVersion) { + this.b2FileVersion = b2FileVersion; + this.fileId = b2FileVersion.getFileId(); } + public Builder setServerSideEncryption(B2FileSseForRequest serverSideEncryption) { this.serverSideEncryption = serverSideEncryption; return this; } public B2StoreLargeFileRequest build() { - return new B2StoreLargeFileRequest(fileVersion, serverSideEncryption); + return new B2StoreLargeFileRequest(fileId, b2FileVersion, serverSideEncryption); } } } diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2UpdateBucketRequest.java b/core/src/main/java/com/backblaze/b2/client/structures/B2UpdateBucketRequest.java index 3513eae5e..8f1b6baa4 100644 --- a/core/src/main/java/com/backblaze/b2/client/structures/B2UpdateBucketRequest.java +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2UpdateBucketRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2022, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client.structures; @@ -42,10 +42,10 @@ public class B2UpdateBucketRequest { private final Boolean fileLockEnabled; @B2Json.optional + private final Integer ifRevisionIs; - @B2Json.constructor(params = "accountId,bucketId,bucketType,bucketInfo,corsRules,lifecycleRules," + - "defaultRetention,defaultServerSideEncryption,replicationConfiguration,fileLockEnabled,ifRevisionIs") + @B2Json.constructor private B2UpdateBucketRequest(String accountId, String bucketId, String bucketType, diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2UploadFileRequest.java b/core/src/main/java/com/backblaze/b2/client/structures/B2UploadFileRequest.java index 86e029278..4b72675de 100644 --- a/core/src/main/java/com/backblaze/b2/client/structures/B2UploadFileRequest.java +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2UploadFileRequest.java @@ -21,6 +21,8 @@ public class B2UploadFileRequest { private final Map fileInfo; private final B2UploadListener listener; + private final Long customUploadTimestamp; + private B2UploadFileRequest(String bucketId, String fileName, @@ -30,7 +32,8 @@ private B2UploadFileRequest(String bucketId, String legalHold, Map fileInfo, B2ContentSource contentSource, - B2UploadListener listener) { + B2UploadListener listener, + Long customUploadTimestamp) { this.bucketId = bucketId; this.fileName = fileName; this.contentType = contentType; @@ -38,6 +41,7 @@ private B2UploadFileRequest(String bucketId, this.fileRetention = fileRetention; this.legalHold = legalHold; + this.customUploadTimestamp = customUploadTimestamp; validateLegalHold(legalHold); this.fileInfo = fileInfo; // make sorted, immutable copyOf?! @@ -89,6 +93,10 @@ public B2UploadListener getListener() { return listener; } + public Long getCustomUploadTimestamp() { + return customUploadTimestamp; + } + public static Builder builder(String bucketId, String fileName, String contentType, @@ -107,6 +115,8 @@ public static class Builder { private Map info; private B2UploadListener listener; + private Long customUploadTimestamp; + Builder(String bucketId, String fileName, String contentType, @@ -150,6 +160,11 @@ public Builder setListener(B2UploadListener listener) { return this; } + public Builder setCustomUploadTimestamp(Long customUploadTimestamp) { + this.customUploadTimestamp = customUploadTimestamp; + return this; + } + public B2UploadFileRequest build() { return new B2UploadFileRequest(bucketId, fileName, @@ -159,7 +174,9 @@ public B2UploadFileRequest build() { legalHold, info, source, - listener); + listener, + customUploadTimestamp + ); } } } diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2WebhookConfiguration.java b/core/src/main/java/com/backblaze/b2/client/structures/B2WebhookConfiguration.java new file mode 100644 index 000000000..e837e78aa --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2WebhookConfiguration.java @@ -0,0 +1,94 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.util.B2Preconditions; + +import java.util.Objects; +import java.util.TreeSet; + +/** + * Webhook destination for B2EventNotificationRule + */ +public class B2WebhookConfiguration extends B2EventNotificationTargetConfiguration { + /** + * The URL endpoint for the webhook, including the protocol, which + * must be "https://". + */ + @B2Json.required + private final String url; + + @B2Json.optional + private final TreeSet customHeaders; + + @B2Json.optional + private final String hmacSha256SigningSecret; + + @B2Json.constructor + public B2WebhookConfiguration(String url, + TreeSet customHeaders, + String hmacSha256SigningSecret) { + B2Preconditions.checkArgument( + url != null && url.startsWith("https://"), + "The protocol for the url must be https://" + ); + + this.url = url; + this.customHeaders = customHeaders; + this.hmacSha256SigningSecret = hmacSha256SigningSecret; + } + + public B2WebhookConfiguration(String url) { + this(url, null, null); + } + + public B2WebhookConfiguration(String url, TreeSet customHeaders) { + this(url, customHeaders, null); + } + + public B2WebhookConfiguration(String url, String hmacSha256SigningSecret) { + this(url, null, hmacSha256SigningSecret); + } + + public String getUrl() { + return url; + } + + public TreeSet getCustomHeaders() { + if (customHeaders == null) { + return null; + } + return new TreeSet<>(customHeaders); + } + + public String getHmacSha256SigningSecret() { + return hmacSha256SigningSecret; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final B2WebhookConfiguration that = (B2WebhookConfiguration) o; + return url.equals(that.url) && + Objects.equals(customHeaders, that.customHeaders) && + Objects.equals(hmacSha256SigningSecret, that.hmacSha256SigningSecret); + } + + @Override + public int hashCode() { + return Objects.hash(url, customHeaders, hmacSha256SigningSecret); + } + + @Override + public String toString() { + return "B2WebhookConfiguration{" + + "url='" + url + '\'' + + ", customHeaders=" + customHeaders + + ", hmacSha256SigningSecret='" + hmacSha256SigningSecret + '\'' + + '}'; + } +} diff --git a/core/src/main/java/com/backblaze/b2/client/structures/B2WebhookCustomHeader.java b/core/src/main/java/com/backblaze/b2/client/structures/B2WebhookCustomHeader.java new file mode 100644 index 000000000..2fec63259 --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/client/structures/B2WebhookCustomHeader.java @@ -0,0 +1,85 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.util.B2Preconditions; +import com.backblaze.b2.util.B2StringUtil; + +import java.util.Comparator; +import java.util.Objects; + +/** + * Custom headers for B2WebhookConfiguration + */ +public class B2WebhookCustomHeader implements Comparable { + + private static final Comparator COMPARATOR = + Comparator.comparing(B2WebhookCustomHeader::getName, String.CASE_INSENSITIVE_ORDER) + .thenComparing(B2WebhookCustomHeader::getValue); + + /** + * The name of the custom header. Must never be "". + */ + @B2Json.required + private final String name; + + /** + * The value of the custom header + */ + @B2Json.required + private final String value; + + @B2Json.constructor + public B2WebhookCustomHeader(String name, + String value) { + + B2Preconditions.checkArgument(!B2StringUtil.isEmpty(name), "the name must not be empty"); + + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public String getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final B2WebhookCustomHeader that = (B2WebhookCustomHeader) o; + return name.equals(that.name) && value.equals(that.value); + } + + @Override + public int hashCode() { + return Objects.hash(name, value); + } + + @Override + public String toString() { + return "B2CustomWebhookHeader{" + + "name='" + name + '\'' + + ", value='" + value + '\'' + + '}'; + } + + /** + * B2CustomHeaders are sorted (without case sensitivity) by name first, and then value. + */ + @Override + public int compareTo(B2WebhookCustomHeader c) { + return COMPARATOR.compare(this, c); + } +} diff --git a/core/src/main/java/com/backblaze/b2/json/B2Json.java b/core/src/main/java/com/backblaze/b2/json/B2Json.java index c47a48170..97bb95dd7 100644 --- a/core/src/main/java/com/backblaze/b2/json/B2Json.java +++ b/core/src/main/java/com/backblaze/b2/json/B2Json.java @@ -7,18 +7,22 @@ import com.backblaze.b2.util.B2StringUtil; +import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; +import java.io.Reader; import java.io.StringReader; + import java.lang.annotation.Annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -71,7 +75,6 @@ public class B2Json { * relying on the shape of a class for which you don't own the * source code? */ - private static String UTF8 = "UTF-8"; /** * A simple instance that can be shared. @@ -80,7 +83,7 @@ public class B2Json { /** * Bit map values for the options parameter to the constructor. - * + *

* Deprecated in favor of using B2JsonOptions. */ @Deprecated @@ -115,11 +118,7 @@ public byte[] toJsonUtf8Bytes(Object obj) throws B2JsonException { } public byte[] toJsonUtf8Bytes(Object obj, B2JsonOptions options) throws B2JsonException { - try { - return toJson(obj, options).getBytes(UTF8); - } catch (IOException e) { - throw new RuntimeException("error writing to byte array: " + e.getMessage()); - } + return toJson(obj, options).getBytes(StandardCharsets.UTF_8); } /** @@ -159,17 +158,17 @@ public void toJson(Object obj, B2JsonOptions options, OutputStream out) throws I /** * Turn an object into JSON, writing the results to given * output stream. - * + *

* objTypeOrNull can be set to null if obj is not a parameterized class. However, * if obj contains type parameters (like if obj is a {@literal List}, then * you will need to pass in its type information via objTypeOrNull. This will instruct * B2Json to derive the B2JsonTypeHandler from the type information instead of the * object's class. - * + *

* Getting the Type of obj can be done in at least two ways: * 1. If it is a member of an enclosing class, EnclosingClass.getDeclaredField(...).getGenericType() * 2. By constructing a class that implements Type. - * + *

* Note that the output stream is NOT closed as a side-effect of calling this. * It was a bug that it was being closed in version 1.1.1 and earlier. */ @@ -333,7 +332,7 @@ public T fromJsonUntilEof(InputStream in, Class clazz, int optionFlags) t } public T fromJsonUntilEof(InputStream in, Class clazz, B2JsonOptions options) throws IOException, B2JsonException { - B2JsonReader reader = new B2JsonReader(new InputStreamReader(in, "UTF-8")); + final B2JsonReader reader = new B2JsonReader(new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))); final B2JsonTypeHandler handler = handlerMap.getHandler(clazz); //noinspection unchecked T result = (T) handler.deserialize(reader, options); @@ -360,25 +359,48 @@ public T fromJson(InputStream in, Class clazz, int optionFlags) throws IO /** * Parse the bytes from an InputStream as JSON using the supplied options, returning the parsed object. - * + *

* The Type parameter will usually be a class, which is straightforward to supply. However, * if you are trying to deserialize a parameterized type (like if obj is a * {@literal List}, then you will need to supply a proper Type instance. - * + *

* Getting the Type can be done in at least two ways: * 1. If it is a member of an enclosing class, EnclosingClass.getDeclaredField(...).getGenericType() * 2. By constructing a class that implements Type. */ public T fromJson(InputStream in, Type type, B2JsonOptions options) throws IOException, B2JsonException { - B2JsonReader reader = new B2JsonReader(new InputStreamReader(in, "UTF-8")); - final B2JsonTypeHandler handler = handlerMap.getHandler(type); + final BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + return fromJson(reader, type, options); + } + + /** + * Parse JSON as an object of the given class with the given options. + */ + public T fromJson(Reader reader, Class clazz) throws IOException, B2JsonException { + return fromJson(reader, clazz, B2JsonOptions.DEFAULT); + } + + /** + * Parse the bytes from a Reader as JSON using the supplied options, returning the parsed object. + *

+ * The Type parameter will usually be a class, which is straightforward to supply. However, + * if you are trying to deserialize a parameterized type (for instance, if the object is a + * {@literal List}), then you will need to supply a proper Type instance. + *

+ * Getting the Type can be done in at least two ways: + * 1. If it is a member of an enclosing class, EnclosingClass.getDeclaredField(...).getGenericType() + * 2. By constructing a class that implements Type. + */ + public T fromJson(Reader reader, Type type, B2JsonOptions options) throws IOException, B2JsonException { + final B2JsonReader b2JsonReader = new B2JsonReader(reader); + final B2JsonTypeHandler handler = handlerMap.getHandler(type); if (handler == null) { throw new B2JsonException("B2Json.fromJson called with handler not in handlerMap"); } //noinspection unchecked - return (T) handler.deserialize(reader, options); + return (T) handler.deserialize(b2JsonReader, options); } /** @@ -435,7 +457,19 @@ public T fromJson(byte[] jsonUtf8Bytes, Class clazz, int optionFlags) thr } public T fromJson(byte[] jsonUtf8Bytes, Class clazz, B2JsonOptions options) throws IOException, B2JsonException { - B2JsonReader reader = new B2JsonReader(new InputStreamReader(new ByteArrayInputStream(jsonUtf8Bytes), "UTF-8")); + // Use a StringReader instead of BufferedReader and InputerStreamReader for small input, + // in order to optimize memory allocations + final B2JsonReader reader; + if (jsonUtf8Bytes.length <= 8192) { + reader = new B2JsonReader( + new StringReader(new String(jsonUtf8Bytes, StandardCharsets.UTF_8)) + ); + } else { + reader = new B2JsonReader( + new BufferedReader( + new InputStreamReader( + new ByteArrayInputStream(jsonUtf8Bytes), StandardCharsets.UTF_8))); + } final B2JsonTypeHandler handler = handlerMap.getHandler(clazz); //noinspection unchecked return (T) handler.deserialize(reader, options); @@ -443,7 +477,7 @@ public T fromJson(byte[] jsonUtf8Bytes, Class clazz, B2JsonOptions option /** * Parse a URL parameter map as an object of the given class. - * + *

* The values in the map are the values that will be used in the * object. The caller is responsible for URL-decoding them * before passing them to this method. @@ -491,6 +525,23 @@ public T fromUrlParameterMap(Map parameterMap, Class claz String typeField(); } + + /** + *

Class annotation that applies to an interface that is a @union.

+ * + *

Provides a mapping from type name to class

+ */ + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + public @interface unionSubtypes { + type[] value(); + + @interface type { + Class clazz(); + String name(); + } + } + /** *

Class annotation that applies to a class that is a @union.

* @@ -510,12 +561,21 @@ public T fromUrlParameterMap(Map parameterMap, Class claz public @interface required {} /** - * Field annotation that says a field is optional. The value will - * always be included, even if it is null, when omitNull is false - * (default); when omitNull is true and the field value is null, - * the value will not be included. A B2JsonException is thrown - * when omitNull is set to true on a primitive field; primitives - * are not nullable objects so omitNull does not apply. + * Field annotation that says a field is optional. + * + * The value will almost always be included when serializing, with + * two exceptions. If omitNull is true and the value is null, it will + * be left out of the serialized representation. Similarly, if omitZero + * is true and the value is zero, it will be left out of the serialized + * representation. Note that both omitNull and omitZero default to false. + * + * A B2JsonException is thrown when omitNull is set to true on a primitive field; + * primitives are not nullable objects so omitNull does not apply. + * Similarly, a B2JsonException is thrown when omitZero is set to true on a + * field that's either non-numeric, non-primitive, or not built into B2Json. + * Note that they can't both be used on the same field because one only works + * on non-primitive fields and the other only works on primitive fields. + * * When deserializing, null/false/0 will be passed to * the constructor if the value is not present in the JSON. */ @@ -523,19 +583,33 @@ public T fromUrlParameterMap(Map parameterMap, Class claz @Target(ElementType.FIELD) public @interface optional { boolean omitNull() default false; + boolean omitZero() default false; } /** - * Field annotation that says a field is optional. The value will - * always be included when serializing, even if it is null. When - * deserializing, the provided default value will be used. The default + * Field annotation that says a field is optional. + * When deserializing, the provided default value will be used. The default * provided should be the JSON form of the value, as a string. + * + * The value will almost always be included when serializing, with + * two exceptions. If omitNull is true and the value is null, it will + * be left out of the serialized representation. Similarly, if omitZero + * is true and the value is zero, it will be left out of the serialized + * representation. Note that both omitNull and omitZero default to false. + * + * A B2JsonException is thrown when omitNull is set to true on a primitive field; + * primitives are not nullable objects so omitNull does not apply. + * Similarly, a B2JsonException is thrown when omitZero is set to true on a + * field that's either non-numeric, non-primitive, or not built into B2Json. + * Note that they can't both be used on the same field because one only works + * on non-primitive fields and the other only works on primitive fields. */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface optionalWithDefault { String defaultValue(); boolean omitNull() default false; + boolean omitZero() default false; } /** @@ -572,22 +646,56 @@ public T fromUrlParameterMap(Map parameterMap, Class claz @Target(ElementType.FIELD) public @interface sensitive {} + /** + * Annotation to declare that this member will be serialized to JSON + * with the specified name, instead of the field name in the Java class. + *

+ * The Java class's field name is used for the params list in the + * B2Json.constructor annotation + *

+ * For example: + *

+     * class Example {
+     *     {@literal @}B2Json.serializedName(value = "@field")
+     *      private String field;
+     *
+     *     {@literal @}B2Json.constructor(params = "field")
+     *      public Example(String field) {
+     *          this.field = field;
+     *      }
+     * }
+     * 
+ * will serialize to the following JSON: + *
+     *     {
+     *         "@field": "value"
+     *     }
+     * 
+ * + */ + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.FIELD) + public @interface serializedName { + String value(); + } + + /** * Constructor annotation saying that this is the constructor B2Json * should use. This constructor must take ALL of the serializable * fields as parameters. - * + *

* You must either compile classes with the '-parameters' javac option * or else provide a "params" parameter that lists the order of * the parameters to the constructor. - * + *

* If present, the "discards" parameter is a comma-separated list of * field names which are allowed to be present in the parsed json, * but whose values will be discarded. The names may be for fields * that don't exist or for fields marked @ignored. This is useful * for accepting deprecated fields without having to use * ALLOW_EXTRA_FIELDS, which would accept ALL unknown fields. - * + *

* When versionParam is non-empty, it is the name of a parameter that * is not a field name, and will take the version number being constructed. * This should be included for objects that have multiple versions, @@ -604,6 +712,63 @@ public T fromUrlParameterMap(Map parameterMap, Class claz String versionParam() default ""; } + /** + * Type annotation used to configure B2Json serialization/deserialization. + + * When versionParam is non-empty, it is the name of a parameter that + * is not a field name, and will take the version number being constructed. + * This should be included for objects that have multiple versions, + * and the code in the constructor should validate the data based on it. + */ + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + public @interface type { + + /** + * When discards is non-empty, is a comma-separated list of field names which are + * allowed to be present in the parsed json, + * but whose values will be discarded. The names may be for fields + * that don't exist or for fields marked @ignored. This is useful + * for accepting deprecated fields without having to use + * ALLOW_EXTRA_FIELDS, which would accept ALL unknown fields. + * + * @return A comma separated list of fields to discard, or an empty string + */ + String discards() default ""; + + /** + * When versionParam is non-empty, it is the name of a parameter that + * is not a field name, and will take the version number being constructed. + * This should be included for objects that have multiple versions, + * and the code in the constructor should validate the data based on it. + * + * @return the version parameter if present, or an empty string + */ + String versionParam() default ""; + } + + /** + * B2Json common configuration object for types (both classes and interfaces) + */ + static class B2JsonTypeConfig { + + public final String discards; + public final String versionParam; + public final String params; + + public B2JsonTypeConfig(B2Json.type type) { + this.discards = type.discards(); + this.versionParam = type.versionParam(); + this.params = ""; + } + + public B2JsonTypeConfig(B2Json.constructor constructor) { + this.discards = constructor.discards(); + this.versionParam = constructor.versionParam(); + this.params = constructor.params(); + } + } + /** * Field annotation that designates the enum value to use when the * value in a field isn't one of the known values. Use this at most @@ -621,6 +786,8 @@ public T fromUrlParameterMap(Map parameterMap, Class claz /*package*/ static final Class[] ALL_ANNOTATIONS = new Class[] { union.class, + unionSubtypes.class, + type.class, required.class, optional.class, optionalWithDefault.class, @@ -633,7 +800,7 @@ public T fromUrlParameterMap(Map parameterMap, Class claz /** * Convert from deprecated options flags to options object. - * + *

* Called a lot, so optimized to always return the same objects. */ private static B2JsonOptions optionsFromFlags(int optionFlags) { diff --git a/core/src/main/java/com/backblaze/b2/json/B2JsonAtomicLongArrayHandler.java b/core/src/main/java/com/backblaze/b2/json/B2JsonAtomicLongArrayHandler.java new file mode 100644 index 000000000..dffaf471d --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/json/B2JsonAtomicLongArrayHandler.java @@ -0,0 +1,69 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ + +package com.backblaze.b2.json; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLongArray; + +public class B2JsonAtomicLongArrayHandler extends B2JsonNonUrlTypeHandler { + + private final B2JsonTypeHandler itemHandler; + + public B2JsonAtomicLongArrayHandler(B2JsonTypeHandler itemHandler) { + this.itemHandler = itemHandler; + } + + public Type getHandledType() { + return AtomicLongArray.class; + } + + public void serialize(AtomicLongArray array, + B2JsonOptions options, + B2JsonWriter out) throws IOException, B2JsonException { + out.setAllowNewlines(false); + out.startArray(); + for (int i = 0; i result = new ArrayList<>(); + if (in.startArrayAndCheckForContents()) { + do { + result.add(B2JsonUtil.deserializeMaybeNull(itemHandler, in, options)); + } while (in.arrayHasMoreValues()); + } + in.finishArray(); + + final int nElts = result.size(); + final AtomicLongArray array = new AtomicLongArray(nElts); + int i = 0; + for (Long elt : result) { + if (elt == null) { + throw new B2JsonBadValueException("can't put null in an AtomicLongArray."); + } + array.set(i, elt); + i++; + } + return array; + } + + public AtomicLongArray defaultValueForOptional() { + return null; + } + + public boolean isStringInJson() { + return false; + } +} diff --git a/core/src/main/java/com/backblaze/b2/json/B2JsonDeserializationUtil.java b/core/src/main/java/com/backblaze/b2/json/B2JsonDeserializationUtil.java new file mode 100644 index 000000000..5be8185ab --- /dev/null +++ b/core/src/main/java/com/backblaze/b2/json/B2JsonDeserializationUtil.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ + +package com.backblaze.b2.json; + +import com.backblaze.b2.util.B2Collections; + +import java.lang.reflect.Constructor; +import java.util.Collections; +import java.util.Set; + +class B2JsonDeserializationUtil { + + /** + * Returns the object constructor that is annotated with the {@code B2Json} annotation. + * @throws B2JsonException if there is not a {@code B2Json} annotated constructor found, or + * multiple constructors found with the {@code B2Json} annotation. + */ + static Constructor findConstructor(Class clazz) throws B2JsonException { + // Find the constructor to use. + final Constructor[] declaredConstructors = clazz.getDeclaredConstructors(); + if (declaredConstructors.length == 0) { + throw new B2JsonException(clazz.getName() + " has no constructor"); + } + + Constructor chosenConstructor = null; + for (Constructor candidate : declaredConstructors) { + if (candidate.getAnnotation(B2Json.constructor.class) != null) { + if (chosenConstructor != null) { + throw new B2JsonException(clazz.getName() + " has two constructors selected"); + } + //noinspection unchecked + chosenConstructor = (Constructor) candidate; + } + } + + B2Json.type b2JsonTypeAnnotation = clazz.getAnnotation(B2Json.type.class); + if (chosenConstructor == null) { + // ensure there is only one constructor. If the user has multiple constructors, then one should have + // a @B2Json.constructor annotation. + if (declaredConstructors.length > 1) { + throw new B2JsonException(clazz.getName() + " has multiple constructors without @B2Json.constructor"); + } + // verify that class has the @B2Json.type annotation + if (b2JsonTypeAnnotation != null) { + //noinspection unchecked + chosenConstructor = (Constructor) declaredConstructors[0]; + } + } else { + // verify the class doesn't have both @B2Json.constructor and @B2Json.type annotations + if (b2JsonTypeAnnotation != null) { + throw new B2JsonException(clazz.getName() + " has both @B2Json.type and @B2Json.constructor annotations"); + } + } + + if (chosenConstructor == null) { + throw new B2JsonException(clazz.getName() + " has no constructor annotated with B2Json.constructor"); + } + chosenConstructor.setAccessible(true); + return chosenConstructor; + } + + /** + * Returns the set of discarded fields that are configured on the {@code B2Json} annotated constructor or class. + * If there are no discards set, an empty set is returned. + */ + static Set getDiscards(B2Json.B2JsonTypeConfig params) { + final String discardsWithCommas = params.discards.replace(" ", ""); + if (discardsWithCommas.isEmpty()) { + return Collections.emptySet(); + } + + String[] discardNames = discardsWithCommas.split(","); + return B2Collections.unmodifiableSet(discardNames); + } +} diff --git a/core/src/main/java/com/backblaze/b2/json/B2JsonHandlerMap.java b/core/src/main/java/com/backblaze/b2/json/B2JsonHandlerMap.java index f9c422899..1090ebc86 100644 --- a/core/src/main/java/com/backblaze/b2/json/B2JsonHandlerMap.java +++ b/core/src/main/java/com/backblaze/b2/json/B2JsonHandlerMap.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ @@ -29,16 +29,18 @@ import java.util.SortedMap; import java.util.TreeMap; import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicLongArray; /** * Holds a mapping from Class to B2JsonTypeHandler. - * + *

* The mapping starts out with initial contents, which must be ALL * of the non-default mappings that will be used. If any other * handlers are needed, the default B2JsonObjectHandler will be used * for that class. - * + *

* This class is THREAD SAFE. */ public class B2JsonHandlerMap { @@ -49,10 +51,21 @@ public class B2JsonHandlerMap { // a handler for a class are equivalent. private final Map> map = new HashMap<>(); + /** + * All Handlers that are ready to use without any further work needed. + * For example, B2JsonInitializedTypeHandler handlers have three phases according to the Javadoc: construction, + * initialization and validation of default values. These types of handlers could be included in the + * map variable before all three phases are complete, which would be problematic for the lock + * contention optimization that does not synchronize if the handler exists in the map. Instead, + * a separate mapWithHandlersReadyToUse variable is created where the entries that exist are sure to need + * no further initialization work performed. + */ + private final Map> mapWithHandlersReadyToUse = new ConcurrentHashMap<>(200); + /** * The getHandler() method is not supposed to be re-entrant. This flag * is used to check that. - * + *

* Guarded by: this */ private boolean inGetHandler = false; @@ -104,10 +117,13 @@ private B2JsonHandlerMap(Map> initialMapOrNull) { map.put(long[].class, new B2JsonLongArrayHandler(map.get(long.class))); map.put(float[].class, new B2JsonFloatArrayHandler(map.get(float.class))); map.put(double[].class, new B2JsonDoubleArrayHandler(map.get(double.class))); + map.put(AtomicLongArray.class, new B2JsonAtomicLongArrayHandler(new B2JsonLongHandler(false))); if (initialMapOrNull != null) { initialMapOrNull.forEach(this::putHandler); } + + mapWithHandlersReadyToUse.putAll(map); } /** @@ -120,112 +136,122 @@ private B2JsonHandlerMap(Map> initialMapOrNull) { *

* So, this method does NOT need to be re-entrant, and in fact we assume that it's not. */ - public synchronized B2JsonTypeHandler getHandler(Type type) throws B2JsonException { - // This method is NOT re-entrant. The code that creates and initializes new handlers - // should not call this method. - // - // The reason this code cannot be re-entrant is that it would try to create more - // new handlers, which could make it impossible for this method to un-do what it - // has done by removing the handlers it had added. A re-entrant call could - // wind up creating dependencies on the classes we temporarily added but wound - // up removing. - B2Preconditions.checkState(!inGetHandler); - B2Preconditions.checkState(handlersAddedToMap.isEmpty()); - - // Fast path (without try/catch/finally) for the case where the handler is already in the map. + public B2JsonTypeHandler getHandler(Type type) throws B2JsonException { + // Fast path (without try/catch/finally) for the case where the handler is already + // in the mapWithHandlersReadyToUse. Don't synchronize on the fast path { - final B2JsonTypeHandler existingHandlerOrNull = lookupHandler(type); - if (existingHandlerOrNull != null) { - return existingHandlerOrNull; + final B2JsonTypeHandler existingHandlerOrNullReadyToUse = + (B2JsonTypeHandler) mapWithHandlersReadyToUse.get(type); + if (existingHandlerOrNullReadyToUse != null) { + return existingHandlerOrNullReadyToUse; } } - inGetHandler = true; - final B2JsonTypeHandler handler; - final List handlersToCheckDefaults; - try { - // Create any handlers that need to be created. - handler = getUninitializedHandler(type); - - // Initialize handlers that were created. Note that initializing a new handler - // may result in new handlers being added to handlersAddedToMap, so this loop - // needs to be able to handle the list changing as the loop progresses, so we - // use an explicit index variable. + synchronized (this) { + // This method is NOT re-entrant. The code that creates and initializes new handlers + // should not call this method. // - //noinspection ForLoopReplaceableByForEach - for (int i = 0; i < handlersAddedToMap.size(); i++) { - final B2JsonTypeHandler handlerAdded = handlersAddedToMap.get(i); - if (handlerAdded instanceof B2JsonInitializedTypeHandler) { - ((B2JsonInitializedTypeHandler) handlerAdded).initialize(this); + // The reason this code cannot be re-entrant is that it would try to create more + // new handlers, which could make it impossible for this method to un-do what it + // has done by removing the handlers it had added. A re-entrant call could + // wind up creating dependencies on the classes we temporarily added but wound + // up removing. + B2Preconditions.checkState(!inGetHandler); + B2Preconditions.checkState(handlersAddedToMap.isEmpty()); + + { + final B2JsonTypeHandler existingHandlerOrNull = lookupHandler(type); + if (existingHandlerOrNull != null) { + return existingHandlerOrNull; } } - // NOTE: It is not possible to run the default value checks at this point. - // Up until this point we have not had to initialize any of the classes - // whose handlers were created and initialized. (Reflection to see fields - // and annotations does not require initializing the class.) - // - // If we were to check default values, that would create instances, which - // would first require initializing the classes. And classes can have - // arbitrary code in their static initializers, which could call B2Json. - // Those calls to B2Json could wind up calling getHandler() on more - // classes, thus violating the no-re-entry precondition, and they could - // wind up trying to use the handlers we're in the process of setting up. - - // Remember the handlers we added so we can check their defaults. - handlersToCheckDefaults = new ArrayList<>(handlersAddedToMap); - } - catch (Throwable t) { - // Something went wrong, and the handlers are not ready to use, so we'll take them - // out of the map. - for (B2JsonTypeHandler handlerAdded : handlersAddedToMap) { - map.remove(handlerAdded.getHandledType()); - } + inGetHandler = true; + final B2JsonTypeHandler handler; + final List handlersToCheckDefaults; + try { + // Create any handlers that need to be created. + handler = getUninitializedHandler(type); + + // Initialize handlers that were created. Note that initializing a new handler + // may result in new handlers being added to handlersAddedToMap, so this loop + // needs to be able to handle the list changing as the loop progresses, so we + // use an explicit index variable. + // + //noinspection ForLoopReplaceableByForEach + for (int i = 0; i < handlersAddedToMap.size(); i++) { + final B2JsonTypeHandler handlerAdded = handlersAddedToMap.get(i); + if (handlerAdded instanceof B2JsonInitializedTypeHandler) { + ((B2JsonInitializedTypeHandler) handlerAdded).initialize(this); + } + } - // Let the caller know that something went wrong. - throw new B2JsonException(t.getMessage()); - } - finally { - // Always clear the list of handlers that were added. - handlersAddedToMap.clear(); + // NOTE: It is not possible to run the default value checks at this point. + // Up until this point we have not had to initialize any of the classes + // whose handlers were created and initialized. (Reflection to see fields + // and annotations does not require initializing the class.) + // + // If we were to check default values, that would create instances, which + // would first require initializing the classes. And classes can have + // arbitrary code in their static initializers, which could call B2Json. + // Those calls to B2Json could wind up calling getHandler() on more + // classes, thus violating the no-re-entry precondition, and they could + // wind up trying to use the handlers we're in the process of setting up. + + // Remember the handlers we added so we can check their defaults. + handlersToCheckDefaults = new ArrayList<>(handlersAddedToMap); + } catch (Throwable t) { + // Something went wrong, and the handlers are not ready to use, so we'll take them + // out of the map. + for (B2JsonTypeHandler handlerAdded : handlersAddedToMap) { + map.remove(handlerAdded.getHandledType()); + } - // And we're no longer in this method. - B2Preconditions.checkState(inGetHandler); - inGetHandler = false; - } + // Let the caller know that something went wrong. + throw new B2JsonException(t.getMessage()); + } finally { + // Always clear the list of handlers that were added. + handlersAddedToMap.clear(); - // Now we can check default values. - // - // Note that we have already committed to keeping the handlers we created, - // but we can still mark them as having bad defaults. - // - // This leaves an interval now where other threads could use the handlers, but - // might not get told that they are unusable because of bad default values. - // What we do guarantee is that the first caller to need the handler (this thread) - // will get an error, and that any thread that calls after this method returns - // will get an error. - try { - for (B2JsonTypeHandler handlerToCheck : handlersToCheckDefaults) { - if (handlerToCheck instanceof B2JsonTypeHandlerWithDefaults) { - final B2JsonTypeHandlerWithDefaults handlerWithDefaults = - (B2JsonTypeHandlerWithDefaults) handlerToCheck; - handlerWithDefaults.checkDefaultValuesAndRememberResult(); - } - } - } finally { - handlersAddedToMap.clear(); - } + // And we're no longer in this method. + B2Preconditions.checkState(inGetHandler); + inGetHandler = false; + } - // All done. - return handler; + // Now we can check default values. + // + // Note that we have already committed to keeping the handlers we created, + // but we can still mark them as having bad defaults. + // + // This leaves an interval now where other threads could use the handlers, but + // might not get told that they are unusable because of bad default values. + // What we do guarantee is that the first caller to need the handler (this thread) + // will get an error, and that any thread that calls after this method returns + // will get an error. + try { + for (B2JsonTypeHandler handlerToCheck : handlersToCheckDefaults) { + if (handlerToCheck instanceof B2JsonTypeHandlerWithDefaults) { + final B2JsonTypeHandlerWithDefaults handlerWithDefaults = + (B2JsonTypeHandlerWithDefaults) handlerToCheck; + handlerWithDefaults.checkDefaultValuesAndRememberResult(); + } + } + } finally { + handlersAddedToMap.clear(); + } + + // All done. + mapWithHandlersReadyToUse.put(handler.getHandledType(), handler); + return handler; + } } /** * Gets the handler for a given type at the top level. - * + *

* The type must be resolved. If the type represents a generic class, the type must also * have concrete type arguments. Otherwise this will fail. - * + *

* The handler MAY NOT BE INITIALIZED. This method is for use by handlers that need to get * a reference to another handler in their initialize() methods. You cannot assume that any * fields set by initialize() have been set. @@ -290,7 +316,7 @@ private synchronized B2JsonTypeHandler getUninitializedHandlerForClass(Cl return (B2JsonTypeHandler) new B2JsonObjectArrayHandler(clazz, eltClazz, eltClazzHandler); } - if (isUnionBase(clazz)) { + if (hasUnionAnnotation(clazz)) { //noinspection unchecked return (B2JsonTypeHandler) new B2JsonUnionBaseHandler(clazz); } @@ -412,11 +438,9 @@ private synchronized B2JsonTypeHandler getUninitializedHandlerForGenericArrayTyp } /** - * Is this class the base class for a union type? - * - * Union bases have the @union annotation. + * Does this class have the @B2Json.union annotation? */ - /*package*/ static boolean isUnionBase(Class clazz) { + /*package*/ static boolean hasUnionAnnotation(Class clazz) { return clazz.getAnnotation(B2Json.union.class) != null; } @@ -460,7 +484,7 @@ private synchronized B2JsonTypeHandler lookupHandler(Type type) { /** * Saves a handler in the map, remembering to use it for the given class. - * + *

* This method is not private because it is needed by B2JsonObjectHandler, * so that it can store itself in the map before trying to make handlers * for its fields, which may be recursive and be of the same type. When diff --git a/core/src/main/java/com/backblaze/b2/json/B2JsonInitializedTypeHandler.java b/core/src/main/java/com/backblaze/b2/json/B2JsonInitializedTypeHandler.java index 12d9fbbae..a9af00606 100644 --- a/core/src/main/java/com/backblaze/b2/json/B2JsonInitializedTypeHandler.java +++ b/core/src/main/java/com/backblaze/b2/json/B2JsonInitializedTypeHandler.java @@ -9,31 +9,31 @@ /** * Base class for all implementations of B2JsonTypeHandler that have an initialize() method. - * + *

* (De)serialization is expected to be fast, so each implementation class gathers * information it needs when it's set up, so when it's time to run it has all of * the necessary information at hand. This gets tricky because dependencies between * handlers may have loops. - * + *

* The plan is to initialize in three phases: - * + *

* First, the constructor, which must not depend on any other handlers, and which * must gather all of the information that other handlers will need. - * + *

* Second, the initialize() method does any work that needs information from other * type handlers. - * + *

* Third, check the validity of default values, now that all type handlers have * gone through at least the second phase. - * + *

* All phases are protected by the lock on B2JsonHandlerMap, so they don't need to * lock, and the data they store in the object is guaranteed to be visible without * further locking. - * + *

* Methods that return data set during initialize() should include this check: - * + *

* Preconditions.checkState(isInitialized()); - * + *

* NOTE: adding the initialize() method to BzJsonTypeHandler would change the interface * and break any clients who have written their own handlers. */ diff --git a/core/src/main/java/com/backblaze/b2/json/B2JsonObjectHandler.java b/core/src/main/java/com/backblaze/b2/json/B2JsonObjectHandler.java index de1cdb2d8..4ca6cda47 100644 --- a/core/src/main/java/com/backblaze/b2/json/B2JsonObjectHandler.java +++ b/core/src/main/java/com/backblaze/b2/json/B2JsonObjectHandler.java @@ -6,7 +6,6 @@ package com.backblaze.b2.json; import com.backblaze.b2.json.FieldInfo.FieldRequirement; -import com.backblaze.b2.util.B2Collections; import com.backblaze.b2.util.B2Preconditions; import java.io.IOException; @@ -21,7 +20,7 @@ /** * (De)serializes Java objects based on field annotations. - * + *

* See doc comment on B2Json for annotation requirements. */ public class B2JsonObjectHandler extends B2JsonTypeHandlerWithDefaults { @@ -53,9 +52,14 @@ public class B2JsonObjectHandler extends B2JsonTypeHandlerWithDefaults { private FieldInfo [] fields; /** - * Map from field name to field. + * Map from json member name to FieldInfo. */ - private final Map fieldMap = new HashMap<>(); + private final Map jsonMemberNameFieldInfoMap = new HashMap<>(); + + /** + * Map from Java object field name to FieldInfo + */ + private final Map javaFieldNameFieldInfoMap = new HashMap<>(); /** * The constructor to use. @@ -73,7 +77,7 @@ public class B2JsonObjectHandler extends B2JsonTypeHandlerWithDefaults { private Integer versionParamIndexOrNull; /** - * null or a set containing the names of fields to discard during parsing. + * Set containing the names of fields to discard during parsing. */ private Set fieldsToDiscard; @@ -95,16 +99,34 @@ public class B2JsonObjectHandler extends B2JsonTypeHandlerWithDefaults { String fieldName = null; String fieldValue = null; for (Class parent = clazz.getSuperclass(); parent != null; parent = parent.getSuperclass()) { - if (B2JsonHandlerMap.isUnionBase(parent)) { + if (B2JsonHandlerMap.hasUnionAnnotation(parent)) { unionClass = parent; fieldName = parent.getAnnotation(B2Json.union.class).typeField(); - fieldValue = B2JsonUnionBaseHandler.getUnionTypeMap(parent).getTypeNameOrNullForClass(clazz); + fieldValue = B2JsonUnionBaseHandler.getUnionTypeMapFromMethod(parent).getTypeNameOrNullForClass(clazz); if (fieldValue == null) { throw new B2JsonException("class " + clazz + " inherits from " + parent + ", but is not in the type map"); } break; } } + + if (unionClass == null) { + for (Class interfaze : clazz.getInterfaces()) { + if (B2JsonHandlerMap.hasUnionAnnotation(interfaze)) { + fieldName = interfaze.getAnnotation(B2Json.union.class).typeField(); + final B2JsonUnionTypeMap unionTypeMapOrNull = B2JsonUnionBaseHandler.getUnionTypeMapFromAnnotationOrNull(interfaze); + if (unionTypeMapOrNull == null) { + throw new B2JsonException(interfaze + " has B2Json.union annotation, but does not have @B2Json.unionSubtypes annotation"); + } + fieldValue = unionTypeMapOrNull.getTypeNameOrNullForClass(clazz); + if (fieldValue == null) { + throw new B2JsonException(interfaze + " does not contain mapping for " + clazz + " in the @B2Json.unionSubtypes annotation"); + } + break; + } + } + } + this.unionClass = unionClass; this.unionTypeFieldName = fieldName; this.unionTypeFieldValue = fieldValue; @@ -129,40 +151,42 @@ protected void initializeImplementation(B2JsonHandlerMap handlerMap) throws B2Js final VersionRange versionRange = getVersionRange(field); final boolean isSensitive = field.getAnnotation(B2Json.sensitive.class) != null; final boolean omitNull = omitNull(field); - final FieldInfo fieldInfo = new FieldInfo(field, handler, requirement, defaultValueJsonOrNull, versionRange, isSensitive, omitNull); - fieldMap.put(field.getName(), fieldInfo); + final boolean omitZero = omitZero(field); + final B2Json.serializedName serializedNameAnnotation = field.getAnnotation(B2Json.serializedName.class); + final String jsonMemberName = serializedNameAnnotation != null ? serializedNameAnnotation.value() : field.getName(); + final FieldInfo fieldInfo = + new FieldInfo(jsonMemberName, field, handler, requirement, defaultValueJsonOrNull, versionRange, isSensitive, omitNull, omitZero); + + if (jsonMemberNameFieldInfoMap.containsKey(jsonMemberName)) { + throw new B2JsonException(clazz.getName() + " contains multiple class fields for the json member " + jsonMemberName); + } + jsonMemberNameFieldInfoMap.put(jsonMemberName, fieldInfo); + javaFieldNameFieldInfoMap.put(field.getName(), fieldInfo); } - fields = fieldMap.values().toArray(new FieldInfo [fieldMap.size()]); + fields = jsonMemberNameFieldInfoMap.values().toArray(new FieldInfo[0]); Arrays.sort(fields); + this.constructor = B2JsonDeserializationUtil.findConstructor(clazz); - // Find the constructor to use. - Constructor chosenConstructor = null; - for (Constructor candidate : clazz.getDeclaredConstructors()) { - if (candidate.getAnnotation(B2Json.constructor.class) != null) { - if (chosenConstructor != null) { - throw new B2JsonException(clazz.getName() + " has two constructors selected"); - } - //noinspection unchecked - chosenConstructor = (Constructor) candidate; - chosenConstructor.setAccessible(true); - } - } - if (chosenConstructor == null) { - throw new B2JsonException(clazz.getName() + " has no constructor annotated with B2Json.constructor"); - } - this.constructor = chosenConstructor; + final B2Json.constructor annotation = this.constructor.getAnnotation(B2Json.constructor.class); + B2Json.B2JsonTypeConfig b2JsonTypeConfig; + if (annotation == null) { + // use @B2Json.type to get type information + B2Json.type typeAnnotation = clazz.getAnnotation(B2Json.type.class); + b2JsonTypeConfig = new B2Json.B2JsonTypeConfig(typeAnnotation); + } else { + b2JsonTypeConfig = new B2Json.B2JsonTypeConfig(annotation); + } // Does the constructor take the version number as a parameter? - final B2Json.constructor annotation = chosenConstructor.getAnnotation(B2Json.constructor.class); - final String versionParamOrEmpty = annotation.versionParam(); + final String versionParamOrEmpty = b2JsonTypeConfig.versionParam; final int numberOfVersionParams = versionParamOrEmpty.isEmpty() ? 0 : 1; // Figure out the argument positions for the constructor. { // Parse @B2Json.constructor#params into an array - String paramsWithCommas = annotation.params().replace(" ", ""); + String paramsWithCommas = b2JsonTypeConfig.params.replace(" ", ""); String[] annotationParamNames = paramsWithCommas.split(","); - if (annotationParamNames.length == 1 && annotationParamNames[0].length() == 0) { + if (annotationParamNames.length == 1 && annotationParamNames[0].isEmpty()) { annotationParamNames = null; } @@ -200,9 +224,8 @@ protected void initializeImplementation(B2JsonHandlerMap handlerMap) throws B2Js } if (paramName.equals(versionParamOrEmpty)) { versionParamIndex = i; - } - else { - final FieldInfo fieldInfo = fieldMap.get(paramName); + } else { + final FieldInfo fieldInfo = javaFieldNameFieldInfoMap.get(paramName); if (fieldInfo == null) { throw new B2JsonException(clazz.getName() + " param name is not a field: " + paramName); } @@ -215,17 +238,11 @@ protected void initializeImplementation(B2JsonHandlerMap handlerMap) throws B2Js // figure out which names to discard, if any { - String discardsWithCommas = annotation.discards().replace(" ", ""); - if (discardsWithCommas.isEmpty()) { - fieldsToDiscard = null; - } else { - String[] discardNames = discardsWithCommas.split(","); - fieldsToDiscard = B2Collections.unmodifiableSet(discardNames); - for (String name : fieldsToDiscard) { - final FieldInfo fieldInfo = fieldMap.get(name); - if (fieldInfo != null && fieldInfo.requirement != FieldRequirement.IGNORED) { - throw new B2JsonException(clazz.getSimpleName() + "'s field '" + name + "' cannot be discarded: it's " + fieldInfo.requirement + ". only non-existent or IGNORED fields can be discarded."); - } + this.fieldsToDiscard = B2JsonDeserializationUtil.getDiscards(b2JsonTypeConfig); + for (String name : fieldsToDiscard) { + final FieldInfo fieldInfo = javaFieldNameFieldInfoMap.get(name); + if (fieldInfo != null && fieldInfo.requirement != FieldRequirement.IGNORED) { + throw new B2JsonException(clazz.getSimpleName() + "'s field '" + name + "' cannot be discarded: it's " + fieldInfo.requirement + ". only non-existent or IGNORED fields can be discarded."); } } } @@ -238,6 +255,7 @@ protected void initializeImplementation(B2JsonHandlerMap handlerMap) throws B2Js * for all others omitNull will return false. * @param field field definition * @return whether the field has the omitNull property + * @throws B2JsonException if omitNull is applied on to a field it shouldn't be. */ private boolean omitNull(Field field) throws B2JsonException { final B2Json.optional optionalAnnotation = field.getAnnotation(B2Json.optional.class); @@ -262,6 +280,39 @@ private boolean omitNull(Field field) throws B2JsonException { return omitNull; } + /** + * Determines whether this field has the omitZero property. + * This property can only be set from the 'optional' or + * 'optionalWithDefault' annotations, + * for all others omitZero will return false. + * @param field field definition + * @return whether the field has the omitZero property + * @throws B2JsonException if omitZero is applied on to a field it shouldn't be. + */ + private boolean omitZero(Field field) throws B2JsonException { + final B2Json.optional optionalAnnotation = field.getAnnotation(B2Json.optional.class); + final B2Json.optionalWithDefault optionalWithDefaultAnnotation = field.getAnnotation(B2Json.optionalWithDefault.class); + + final boolean omitZero; + if (optionalAnnotation != null) { + omitZero = optionalAnnotation.omitZero(); + } else if (optionalWithDefaultAnnotation != null) { + omitZero = optionalWithDefaultAnnotation.omitZero(); + } else { + omitZero = false; + } + // omitZero can only be set on zeroable classes that B2Json innately knows about. + if (omitZero && !isBuiltInZeroableType(field.getType())) { + final String message = String.format( + "Field %s.%s declared with 'omitZero = true' but is not a primitive, numeric type", + this.clazz.getSimpleName(), + field.getName() + ); + throw new B2JsonException(message); + } + return omitZero; + } + /** * Checks the validity of all of the default values for fields with optionalWithDefault. * @@ -269,7 +320,7 @@ private boolean omitNull(Field field) throws B2JsonException { */ @Override protected void checkDefaultValues() throws B2JsonException { - for (FieldInfo field : fieldMap.values()) { + for (FieldInfo field : jsonMemberNameFieldInfoMap.values()) { if (field.defaultValueJsonOrNull != null) { try { field.handler.deserialize( @@ -278,7 +329,7 @@ protected void checkDefaultValues() throws B2JsonException { ); } catch (B2JsonException | IOException e) { throw new B2JsonException("error in default value for " + - clazz.getSimpleName() + "." + field.getName() + ": " + + clazz.getSimpleName() + "." + field.getJsonMemberName() + ": " + e.getMessage()); } } @@ -334,9 +385,9 @@ public Type getHandledType() { /** * Serializes the object, adding all fields to the JSON. - * + *

* Optional fields are always present, and set to null/0 when not present. - * + *

* The type name field for a member of a union type is added alphabetically in sequence, if needed. */ public void serialize(T obj, B2JsonOptions options, B2JsonWriter out) throws IOException, B2JsonException { @@ -350,7 +401,7 @@ public void serialize(T obj, B2JsonOptions options, B2JsonWriter out) throws IOE out.startObject(); if (fields != null) { for (FieldInfo fieldInfo : fields) { - if (unionTypeFieldName != null && !typeFieldDone && unionTypeFieldName.compareTo(fieldInfo.getName()) < 0) { + if (unionTypeFieldName != null && !typeFieldDone && unionTypeFieldName.compareTo(fieldInfo.getJsonMemberName()) < 0) { out.writeObjectFieldNameAndColon(unionTypeFieldName); out.writeString(unionTypeFieldValue); typeFieldDone = true; @@ -359,13 +410,16 @@ public void serialize(T obj, B2JsonOptions options, B2JsonWriter out) throws IOE final Object value = fieldInfo.field.get(obj); // Only write the field if the value is not null OR omitNull is not set - if (!fieldInfo.omitNull || value != null) { - out.writeObjectFieldNameAndColon(fieldInfo.getName()); + final boolean omitValue = + (fieldInfo.omitNull && value == null) || + (fieldInfo.omitZero && isZero(value)); + if (!omitValue) { + out.writeObjectFieldNameAndColon(fieldInfo.getJsonMemberName()); if (fieldInfo.getIsSensitive() && options.getRedactSensitive()) { out.writeString("***REDACTED***"); } else { if (fieldInfo.isRequiredAndInVersion(version) && value == null) { - throw new B2JsonException("required field " + fieldInfo.getName() + " cannot be null"); + throw new B2JsonException("required field " + fieldInfo.getJsonMemberName() + " cannot be null"); } //noinspection unchecked B2JsonUtil.serializeMaybeNull(fieldInfo.handler, value, out, options); @@ -410,7 +464,7 @@ public T deserialize(B2JsonReader in, B2JsonOptions options) throws B2JsonExcept if (in.startObjectAndCheckForContents()) { do { String fieldName = in.readObjectFieldNameAndColon(); - FieldInfo fieldInfo = fieldMap.get(fieldName); + FieldInfo fieldInfo = jsonMemberNameFieldInfoMap.get(fieldName); if (fieldInfo == null) { if ((options.getExtraFieldOption() == B2JsonOptions.ExtraFieldOption.ERROR) && (fieldsToDiscard == null || !fieldsToDiscard.contains(fieldName))) { @@ -420,12 +474,12 @@ public T deserialize(B2JsonReader in, B2JsonOptions options) throws B2JsonExcept } else { if (foundFieldBits.get(fieldInfo.constructorArgIndex)) { - throw new B2JsonException("duplicate field: " + fieldInfo.getName()); + throw new B2JsonException("duplicate field: " + fieldInfo.getJsonMemberName()); } @SuppressWarnings("unchecked") final Object value = B2JsonUtil.deserializeMaybeNull(fieldInfo.handler, in, options); if (fieldInfo.isRequiredAndInVersion(version) && value == null) { - throw new B2JsonException("required field " + fieldInfo.getName() + " cannot be null"); + throw new B2JsonException("required field " + fieldInfo.getJsonMemberName() + " cannot be null"); } constructorArgs[fieldInfo.constructorArgIndex] = value; foundFieldBits.set(fieldInfo.constructorArgIndex); @@ -456,7 +510,7 @@ public T deserializeFromFieldNameToValueMap(Map fieldNameToValue } for (Map.Entry entry : fieldNameToValue.entrySet()) { String fieldName = entry.getKey(); - FieldInfo fieldInfo = fieldMap.get(fieldName); + FieldInfo fieldInfo = jsonMemberNameFieldInfoMap.get(fieldName); if (fieldInfo == null) { if ((options.getExtraFieldOption() == B2JsonOptions.ExtraFieldOption.ERROR) && (fieldsToDiscard == null || !fieldsToDiscard.contains(fieldName))) { @@ -466,7 +520,7 @@ public T deserializeFromFieldNameToValueMap(Map fieldNameToValue else { Object value = entry.getValue(); if (fieldInfo.isRequiredAndInVersion(version) && value == null) { - throw new B2JsonException("required field " + fieldInfo.getName() + " cannot be null"); + throw new B2JsonException("required field " + fieldInfo.getJsonMemberName() + " cannot be null"); } constructorArgs[fieldInfo.constructorArgIndex] = value; } @@ -490,7 +544,7 @@ public T deserializeFromUrlParameterMap(Map parameterMap, B2Json String fieldName = entry.getKey(); String strOfValue = entry.getValue(); - FieldInfo fieldInfo = fieldMap.get(fieldName); + FieldInfo fieldInfo = jsonMemberNameFieldInfoMap.get(fieldName); if (fieldInfo == null) { if ((options.getExtraFieldOption() == B2JsonOptions.ExtraFieldOption.ERROR) && (fieldsToDiscard == null || !fieldsToDiscard.contains(fieldName))) { @@ -500,7 +554,7 @@ public T deserializeFromUrlParameterMap(Map parameterMap, B2Json else { final Object value = fieldInfo.handler.deserializeUrlParam(strOfValue); if (fieldInfo.isRequiredAndInVersion(version) && value == null) { - throw new B2JsonException("required field " + fieldInfo.getName() + " cannot be null"); + throw new B2JsonException("required field " + fieldInfo.getJsonMemberName() + " cannot be null"); } constructorArgs[fieldInfo.constructorArgIndex] = value; } @@ -522,7 +576,7 @@ private T deserializeFromConstructorArgs(Object[] constructorArgs, int version) int index = fieldInfo.constructorArgIndex; if (constructorArgs[index] == null) { if (fieldInfo.isRequiredAndInVersion(version)) { - throw new B2JsonException("required field " + fieldInfo.getName() + " is missing"); + throw new B2JsonException("required field " + fieldInfo.getJsonMemberName() + " is missing"); } if (fieldInfo.defaultValueJsonOrNull != null) { // We do a fresh deserialization of the default value each time, in case it's @@ -548,7 +602,7 @@ private T deserializeFromConstructorArgs(Object[] constructorArgs, int version) } else { if (!fieldInfo.isInVersion(version)) { - throw new B2JsonException("field " + fieldInfo.getName() + " is not in version " + version); + throw new B2JsonException("field " + fieldInfo.getJsonMemberName() + " is not in version " + version); } } } @@ -577,4 +631,31 @@ public T defaultValueForOptional() { public boolean isStringInJson() { return false; } + public static boolean isBuiltInZeroableType(Class type) { + return (type == byte.class) || + (type == int.class) || + (type == long.class) || + (type == float.class) || + (type == double.class); + } + + public static boolean isZero(Object value) { + B2Preconditions.checkArgumentIsNotNull(value, value); // because we should only be called for primitives! + if (value instanceof Byte) { + return ((Byte) value) == 0; + } + if (value instanceof Integer) { + return ((Integer) value) == 0; + } + if (value instanceof Long) { + return ((Long) value) == 0; + } + if (value instanceof Float) { + return ((Float) value) == 0; + } + if (value instanceof Double) { + return ((Double) value) == 0; + } + throw new RuntimeException("bug: isZero called on " + value.getClass()); + } } diff --git a/core/src/main/java/com/backblaze/b2/json/B2JsonReader.java b/core/src/main/java/com/backblaze/b2/json/B2JsonReader.java index d6795f6f7..c5b181328 100644 --- a/core/src/main/java/com/backblaze/b2/json/B2JsonReader.java +++ b/core/src/main/java/com/backblaze/b2/json/B2JsonReader.java @@ -10,7 +10,7 @@ /** * Reads a stream of characters and converts them to JSON tokens. - * + *

* This class is NOT thread safe. */ public class B2JsonReader { diff --git a/core/src/main/java/com/backblaze/b2/json/B2JsonTypeHandlerWithDefaults.java b/core/src/main/java/com/backblaze/b2/json/B2JsonTypeHandlerWithDefaults.java index cc48130b3..c6c955c9d 100644 --- a/core/src/main/java/com/backblaze/b2/json/B2JsonTypeHandlerWithDefaults.java +++ b/core/src/main/java/com/backblaze/b2/json/B2JsonTypeHandlerWithDefaults.java @@ -1,10 +1,12 @@ /* - * Copyright 2019, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.json; +import java.util.concurrent.atomic.AtomicReference; + /** * Base class for all handlers that deal with default values. */ @@ -14,10 +16,9 @@ private enum DefaultValueState { NOT_CHECKED, GOOD, BAD } /** * Have we checked the default values, and are the good? - * - * Guarded by: this */ - private DefaultValueState defaultValueState = DefaultValueState.NOT_CHECKED; + private final AtomicReference defaultValueStateAtomicReference = + new AtomicReference<>(DefaultValueState.NOT_CHECKED); /** * If the state is BAD, the error message saying what's wrong. Null otherwise. @@ -29,8 +30,8 @@ private enum DefaultValueState { NOT_CHECKED, GOOD, BAD } /** * Before (de)serializing, make sure that the defaults are OK. */ - protected synchronized void throwIfBadDefaultValue() throws B2JsonException { - if (defaultValueState == DefaultValueState.BAD) { + protected void throwIfBadDefaultValue() throws B2JsonException { + if (defaultValueStateAtomicReference.get() == DefaultValueState.BAD) { throw new B2JsonException(errorMessage); } } @@ -41,7 +42,7 @@ protected synchronized void throwIfBadDefaultValue() throws B2JsonException { synchronized void checkDefaultValuesAndRememberResult() { try { checkDefaultValues(); - defaultValueState = DefaultValueState.GOOD; + defaultValueStateAtomicReference.set(DefaultValueState.GOOD); } catch (B2JsonException e) { setDefaultValueBad(e.getMessage()); } @@ -54,7 +55,7 @@ synchronized void checkDefaultValuesAndRememberResult() { * the message to members of the union. */ synchronized void setDefaultValueBad(String errorMessage) { - this.defaultValueState = DefaultValueState.BAD; + defaultValueStateAtomicReference.set(DefaultValueState.BAD); this.errorMessage = errorMessage; } diff --git a/core/src/main/java/com/backblaze/b2/json/B2JsonUnionBaseHandler.java b/core/src/main/java/com/backblaze/b2/json/B2JsonUnionBaseHandler.java index 9974f6ad8..d9599afe0 100644 --- a/core/src/main/java/com/backblaze/b2/json/B2JsonUnionBaseHandler.java +++ b/core/src/main/java/com/backblaze/b2/json/B2JsonUnionBaseHandler.java @@ -18,10 +18,11 @@ import java.lang.reflect.Type; import java.util.HashMap; import java.util.Map; +import java.util.Set; /** * Handler for the class that is the base class for a union type. - * + *

* This handler is used only for deserialization, where it finds the * type name in the JSON object, and this dispatches to the subclass * for that type. @@ -62,6 +63,11 @@ public class B2JsonUnionBaseHandler extends B2JsonTypeHandlerWithDefaults */ private Map> fieldNameToHandler; + /** + * Set containing the names of fields to discard during parsing. + */ + private Map, Set> handlerToFieldsToDiscard; + /*package*/ B2JsonUnionBaseHandler(Class clazz) throws B2JsonException { @@ -102,23 +108,43 @@ public class B2JsonUnionBaseHandler extends B2JsonTypeHandlerWithDefaults @Override protected void initializeImplementation(B2JsonHandlerMap b2JsonHandlerMap) throws B2JsonException { - + boolean unionTypeFromMethod = false; // Get the map of type name to class of all the members of the union. - final Map> typeNameToClass = getUnionTypeMap(clazz).getTypeNameToClass(); + B2JsonUnionTypeMap unionTypeMapFromAnnotationOrNull = getUnionTypeMapFromAnnotationOrNull(clazz); + if (unionTypeMapFromAnnotationOrNull == null) { + unionTypeMapFromAnnotationOrNull = getUnionTypeMapFromMethod(clazz); + unionTypeFromMethod = true; + } + final Map> typeNameToClass = unionTypeMapFromAnnotationOrNull.getTypeNameToClass(); // Build the maps from type name and class to handler. typeNameToHandler = new HashMap<>(); classToHandler = new HashMap<>(); + handlerToFieldsToDiscard = new HashMap<>(); for (Map.Entry> entry : typeNameToClass.entrySet()) { final String typeName = entry.getKey(); final Class typeClass = entry.getValue(); - if (!hasSuperclass(typeClass, clazz)) { // use clazz.isAssignableFrom(typeClass)? + if (unionTypeFromMethod && !hasSuperclass(typeClass, clazz)) { // use clazz.isAssignableFrom(typeClass)? throw new B2JsonException(typeClass + " is not a subclass of " + clazz); } final B2JsonTypeHandler handler = b2JsonHandlerMap.getUninitializedHandler(typeClass); if (handler instanceof B2JsonObjectHandler) { typeNameToHandler.put(typeName, (B2JsonObjectHandler) handler); classToHandler.put(typeClass, (B2JsonObjectHandler) handler); + // Add discarded fields to handlerToFieldsToDiscard + Constructor b2JsonConstructor = B2JsonDeserializationUtil.findConstructor(typeClass); + + final B2Json.B2JsonTypeConfig jsonTypeParams; + final B2Json.constructor annotation = b2JsonConstructor.getAnnotation(B2Json.constructor.class); + if (annotation == null) { + // must be a record, use @B2Json.type + B2Json.type typeAnnotation = typeClass.getAnnotation(B2Json.type.class); + jsonTypeParams = new B2Json.B2JsonTypeConfig(typeAnnotation); + } else{ + jsonTypeParams = new B2Json.B2JsonTypeConfig(annotation); + } + Set fieldsToDiscard = B2JsonDeserializationUtil.getDiscards(jsonTypeParams); + handlerToFieldsToDiscard.put((B2JsonObjectHandler) handler, fieldsToDiscard); } else { throw new B2JsonException("BUG: handler for subclass of union is not B2JsonObjectHandler"); @@ -141,7 +167,7 @@ protected void initializeImplementation(B2JsonHandlerMap b2JsonHandlerMap) throw "In union type " + clazz + ", field " + fieldName + " has two different types. " + fieldNameToSourceClassName.get(fieldName) + " has " + fieldNameToHandler.get(fieldName).getHandledType() + " and " + - subclass.toString() + " has " + handler.getHandledType() + subclass + " has " + handler.getHandledType() ); } } @@ -211,10 +237,10 @@ private static boolean hasSuperclass(Class classA, Class classB) { /** * Returns the mapping from type name to class for all members of the union. - * + *

* Gets the map by calling the static method getUnionTypeMap on the base class. */ - /*package*/ static B2JsonUnionTypeMap getUnionTypeMap(Class clazz) throws B2JsonException { + /*package*/ static B2JsonUnionTypeMap getUnionTypeMapFromMethod(Class clazz) throws B2JsonException { // This uses getDeclaredMethod instead of just getMethod so that classes // can't inherit the type handler from their superclass. that seems like // a safer starting point. @@ -239,6 +265,27 @@ private static boolean hasSuperclass(Class classA, Class classB) { } } + /** + * Gets the map by the declared B2Json.unionSubtypes annotation. If the {@code B2Json.unionSubTypes} is not present, + * then {@code null} is returned + */ + /*package*/ static B2JsonUnionTypeMap getUnionTypeMapFromAnnotationOrNull(Class clazz) throws B2JsonException { + + B2Json.unionSubtypes annotation = clazz.getAnnotation(B2Json.unionSubtypes.class); + if (annotation == null) { + return null; + } + B2Json.unionSubtypes.type[] subTypes = annotation.value(); + if (subTypes.length == 0) { + throw new B2JsonException(clazz.getSimpleName() + " - at least one type must be configured set in @B2Json.unionSubtypes"); + } + final B2JsonUnionTypeMap.Builder builder = B2JsonUnionTypeMap.builder(); + for (B2Json.unionSubtypes.type type: subTypes) { + builder.put(type.name(), type.clazz()); + } + return builder.build(); + } + @Override public Type getHandledType() { // TODO not sure what to do here... @@ -253,7 +300,7 @@ public void serialize(T obj, B2JsonOptions options, B2JsonWriter out) throws IOE if (obj.getClass() == clazz) { // the union base class is basically "abstract" and can't be serialized. - throw new B2JsonException("" + clazz + " is a union base class, and cannot be serialized"); + throw new B2JsonException(clazz + " is a union base class, and cannot be serialized"); } // @@ -264,7 +311,7 @@ public void serialize(T obj, B2JsonOptions options, B2JsonWriter out) throws IOE //noinspection unchecked final B2JsonObjectHandler objHandler = (B2JsonObjectHandler) classToHandler.get(obj.getClass()); if (objHandler == null) { - throw new B2JsonException("" + obj.getClass() + " isn't a registered part of union " + clazz); + throw new B2JsonException(obj.getClass() + " isn't a registered part of union " + clazz); } // we have the right handler, so make it do the work. @@ -299,7 +346,9 @@ public T deserialize(B2JsonReader in, B2JsonOptions options) throws B2JsonExcept else { final B2JsonTypeHandler handler = fieldNameToHandler.get(fieldName); if (handler == null) { - unknownFieldNameOrNull = fieldName; + if (options.getExtraFieldOption() == B2JsonOptions.ExtraFieldOption.ERROR) { + unknownFieldNameOrNull = fieldName; + } in.skipValue(); } else { @@ -332,7 +381,8 @@ public T deserialize(B2JsonReader in, B2JsonOptions options) throws B2JsonExcept } // Throw errors for unknown fields. (Actually, we'll just throw for one.) - if (unknownFieldNameOrNull != null) { + if (unknownFieldNameOrNull != null && + !handlerToFieldsToDiscard.get(handler).contains(unknownFieldNameOrNull)) { throw new B2JsonException("unknown field '" + unknownFieldNameOrNull + "' in union type " + clazz.getSimpleName()); } @@ -353,7 +403,7 @@ public boolean isStringInJson() { /** * Remembers that the default value is bad. - * + *

* We propagate this error to all members of the union, because they * are unusable if the union base cannot be deserialized. */ diff --git a/core/src/main/java/com/backblaze/b2/json/FieldInfo.java b/core/src/main/java/com/backblaze/b2/json/FieldInfo.java index ae7c2dde3..1f4b3f0eb 100644 --- a/core/src/main/java/com/backblaze/b2/json/FieldInfo.java +++ b/core/src/main/java/com/backblaze/b2/json/FieldInfo.java @@ -5,19 +5,18 @@ package com.backblaze.b2.json; -import com.backblaze.b2.util.B2Preconditions; - import java.lang.reflect.Field; /** * Information for one field in an object that is (de)serialized. - * + *

* Used by B2ObjectHandler and B2JsonObjectHandler. */ public final class FieldInfo implements Comparable { public enum FieldRequirement { REQUIRED, OPTIONAL, IGNORED } + private final String jsonMemberName; public final Field field; public final B2JsonTypeHandler handler; public final FieldRequirement requirement; @@ -26,15 +25,18 @@ public enum FieldRequirement { REQUIRED, OPTIONAL, IGNORED } public int constructorArgIndex; public final boolean isSensitive; public final boolean omitNull; + public final boolean omitZero; /*package*/ FieldInfo( + String jsonMemberName, Field field, B2JsonTypeHandler handler, FieldRequirement requirement, String defaultValueJsonOrNull, VersionRange versionRange, boolean isSensitive, - boolean omitNull - ) { + boolean omitNull, + boolean omitZero) { + this.jsonMemberName = jsonMemberName; this.field = field; this.handler = handler; this.requirement = requirement; @@ -42,12 +44,25 @@ public enum FieldRequirement { REQUIRED, OPTIONAL, IGNORED } this.versionRange = versionRange; this.isSensitive = isSensitive; this.omitNull = omitNull; + this.omitZero = omitZero; this.field.setAccessible(true); } + /** + * Returns the member name that this field is serialized to in Json. + * @deprecated use {@link #getJsonMemberName()} instead which is clearer. + */ + @Deprecated public String getName() { - return field.getName(); + return jsonMemberName; + } + + /** + * Returns the member name that this field is serialized to in Json. + */ + public String getJsonMemberName() { + return jsonMemberName; } public B2JsonTypeHandler getHandler() { @@ -59,7 +74,7 @@ public boolean getIsSensitive() { } public int compareTo(@SuppressWarnings("NullableProblems") FieldInfo o) { - return field.getName().compareTo(o.field.getName()); + return jsonMemberName.compareTo(o.jsonMemberName); } public void setConstructorArgIndex(int index) { diff --git a/core/src/main/java/com/backblaze/b2/util/B2Clock.java b/core/src/main/java/com/backblaze/b2/util/B2Clock.java index e6a524bb0..355830bf9 100644 --- a/core/src/main/java/com/backblaze/b2/util/B2Clock.java +++ b/core/src/main/java/com/backblaze/b2/util/B2Clock.java @@ -24,7 +24,7 @@ */ public abstract class B2Clock { // only access with while synchronized(B2Clock.class). - private static B2Clock theClock; + private static volatile B2Clock theClock; /** * If theClock is null, this will create a B2ClockSim with the desiredNow. @@ -48,9 +48,13 @@ public static B2ClockSim useSimulator(LocalDateTime desiredNow) { /** * @return theClock to use. */ - public static synchronized B2Clock get() { + public static B2Clock get() { if (theClock == null) { - theClock = new B2ClockImpl(); + synchronized (B2Clock.class) { + if (theClock == null) { + theClock = new B2ClockImpl(); + } + } } return theClock; } diff --git a/core/src/main/java/com/backblaze/b2/util/B2DateTimeUtil.java b/core/src/main/java/com/backblaze/b2/util/B2DateTimeUtil.java index 9848a499e..36cb15c38 100644 --- a/core/src/main/java/com/backblaze/b2/util/B2DateTimeUtil.java +++ b/core/src/main/java/com/backblaze/b2/util/B2DateTimeUtil.java @@ -141,15 +141,50 @@ public static LocalDateTime parseDateTime(String str) { * Returns a date-time in FGUID form: "d20150315_m092654" */ public static String formatFguidDateTime(LocalDateTime dateTime) { - return String.format( - "d%04d%02d%02d_m%02d%02d%02d", - dateTime.getYear(), - dateTime.getMonthValue(), - dateTime.getDayOfMonth(), - dateTime.getHour(), - dateTime.getMinute(), - dateTime.getSecond() - ); + final int year = dateTime.getYear(); + final int month = dateTime.getMonthValue(); + final int day = dateTime.getDayOfMonth(); + final int hour = dateTime.getHour(); + final int min = dateTime.getMinute(); + final int sec = dateTime.getSecond(); + + final char[] buf = new char[17]; + + //////////////////////////////////////////////// + // date + //////////////////////////////////////////////// + + buf[0] = 'd'; + + // year + buf[ 1] = (char) getDigit(year, 1000); + buf[ 2] = (char) getDigit(year, 100); + buf[ 3] = (char) getDigit(year, 10); + buf[ 4] = (char) getDigit(year, 1); + // month + buf[ 5] = (char) getDigit(month, 10); + buf[ 6] = (char) getDigit(month, 1); + // day + buf[ 7] = (char) getDigit(day, 10); + buf[ 8] = (char) getDigit(day, 1); + + //////////////////////////////////////////////// + // time + //////////////////////////////////////////////// + + buf[ 9] = '_'; + buf[10] = 'm'; + // hour + buf[11] = (char) getDigit(hour, 10); + buf[12] = (char) getDigit(hour, 1); + // minute + buf[13] = (char) getDigit(min, 10); + buf[14] = (char) getDigit(min, 1); + // second + buf[15] = (char) getDigit(sec, 10); + buf[16] = (char) getDigit(sec, 1); + + return new String(buf); } private static class DurationParser { @@ -273,4 +308,12 @@ static long getMillisecondsSinceEpoch(LocalDateTime dateTime) { // it is package-private so that no one else can call it. B2DateTimeUtil() { } + + /** + * @return the specified column of the given integer, as a character. + * for instance, getDigit(2019, 1000) returns '2' because that's in the 1000s column of 2019. + */ + private static int getDigit(int value, int whichColumn) { + return '0' + ((value / whichColumn) % 10); + } } diff --git a/core/src/main/java/com/backblaze/b2/util/B2StringUtil.java b/core/src/main/java/com/backblaze/b2/util/B2StringUtil.java index 8dc532139..170e91a3d 100644 --- a/core/src/main/java/com/backblaze/b2/util/B2StringUtil.java +++ b/core/src/main/java/com/backblaze/b2/util/B2StringUtil.java @@ -23,7 +23,7 @@ public class B2StringUtil { * @param str the string to check * @return true if str is null or zero-length. */ - static boolean isEmpty(String str) { + public static boolean isEmpty(String str) { if (str == null) { return true; } diff --git a/core/src/test/java/com/backblaze/b2/client/B2CopyingPartStorerTest.java b/core/src/test/java/com/backblaze/b2/client/B2CopyingPartStorerTest.java index 55881af86..7c1b741d6 100644 --- a/core/src/test/java/com/backblaze/b2/client/B2CopyingPartStorerTest.java +++ b/core/src/test/java/com/backblaze/b2/client/B2CopyingPartStorerTest.java @@ -36,7 +36,7 @@ public class B2CopyingPartStorerTest extends B2BaseTest { private final B2UploadListener uploadListener = mock(B2UploadListener.class); public B2CopyingPartStorerTest() throws B2Exception { - when(largeFileStorer.copyPart(anyInt(), anyString(), anyObject(), anyObject(), anyObject())).thenReturn(part); + when(largeFileStorer.copyPart(anyInt(), anyString(), anyObject(), anyObject())).thenReturn(part); } @Test @@ -45,7 +45,7 @@ public void testStorePart_noByteRange() throws B2Exception { final B2CancellationToken cancellationToken = new B2CancellationToken(); assertEquals(part, partStorer.storePart(largeFileStorer, uploadListener, cancellationToken)); - verify(largeFileStorer).copyPart(2, SOURCE_FILE_ID, null, uploadListener, cancellationToken); + verify(largeFileStorer).copyPart(2, SOURCE_FILE_ID, null, uploadListener); } @Test @@ -55,6 +55,6 @@ public void testStorePart_byteRange() throws B2Exception { final B2CancellationToken cancellationToken = new B2CancellationToken(); assertEquals(part, partStorer.storePart(largeFileStorer, uploadListener, cancellationToken)); - verify(largeFileStorer).copyPart(2, SOURCE_FILE_ID, byteRange, uploadListener, cancellationToken); + verify(largeFileStorer).copyPart(2, SOURCE_FILE_ID, byteRange, uploadListener); } } diff --git a/core/src/test/java/com/backblaze/b2/client/B2LargeFileStorerTest.java b/core/src/test/java/com/backblaze/b2/client/B2LargeFileStorerTest.java index 0fd311a79..5e12988db 100644 --- a/core/src/test/java/com/backblaze/b2/client/B2LargeFileStorerTest.java +++ b/core/src/test/java/com/backblaze/b2/client/B2LargeFileStorerTest.java @@ -18,10 +18,7 @@ import com.backblaze.b2.client.structures.B2UploadProgress; import com.backblaze.b2.client.structures.B2UploadState; import com.backblaze.b2.util.B2BaseTest; -import org.junit.After; -import org.junit.Assert; -import org.junit.Rule; -import org.junit.Test; +import org.junit.*; import org.junit.rules.ExpectedException; import org.mockito.Matchers; @@ -30,13 +27,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.concurrent.AbstractExecutorService; -import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -108,14 +99,20 @@ public void tearDown() { } public List createB2LargeFileStorerAndGetSortedPartStorers(List outOfOrderPartStorers) { + return createB2LargeFileStorerAndGetSortedPartStorers(outOfOrderPartStorers, false); + } + + public List createB2LargeFileStorerAndGetSortedPartStorers(List outOfOrderPartStorers, + boolean partNumberGapsAllowed) { return new B2LargeFileStorer( - B2StoreLargeFileRequest.builder(largeFileVersion).build(), + B2StoreLargeFileRequest.builder(largeFileVersion.getFileId()).build(), outOfOrderPartStorers, authCache, webifier, retryer, retryPolicySupplier, - executor).getPartStorers(); + singleThreadedExecutor, + partNumberGapsAllowed).getPartStorers(); } @Test @@ -159,6 +156,47 @@ public void testPartStorers_missingPartNumber() throws IOException { createB2LargeFileStorerAndGetSortedPartStorers(partStorers); } + @Test + public void testPartStorers_missingPartNumber_gapsAllowed() throws IOException { + final List partStorers = Arrays.asList( + new B2AlreadyStoredPartStorer(part2), + new B2UploadingPartStorer(1, createContentSourceWithSize(100)), + new B2CopyingPartStorer(4, fileId(4))); + + final List sortedPartStorers = createB2LargeFileStorerAndGetSortedPartStorers(partStorers, true); + assertEquals( + Arrays.asList(1, 2, 4), + sortedPartStorers.stream().map(B2PartStorer::getPartNumber).collect(Collectors.toList()) + ); + } + + @Test + public void testPartStorers_partNumbersStartWithTwo() { + final List partStorers = Arrays.asList( + new B2AlreadyStoredPartStorer(part2), + new B2AlreadyStoredPartStorer(part3), + new B2CopyingPartStorer(4, fileId(4))); + + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("part number 1 has no part storers"); + + createB2LargeFileStorerAndGetSortedPartStorers(partStorers); + } + + @Test + public void testPartStorers_partNumbersStartWithTwo_gapsAllowed() { + final List partStorers = Arrays.asList( + new B2AlreadyStoredPartStorer(part2), + new B2AlreadyStoredPartStorer(part3), + new B2CopyingPartStorer(4, fileId(4))); + + final List sortedPartStorers = createB2LargeFileStorerAndGetSortedPartStorers(partStorers, true); + assertEquals( + Arrays.asList(2, 3, 4), + sortedPartStorers.stream().map(B2PartStorer::getPartNumber).collect(Collectors.toList()) + ); + } + private B2LargeFileStorer createFromLocalContent() throws B2Exception { final B2ContentSource contentSource = new TestContentSource(0, FILE_SIZE); @@ -180,7 +218,7 @@ private B2ContentSource createContentSourceWithSize(long size) throws IOExceptio return contentSource; } - private B2LargeFileStorer createLargeFileStorerForStartByteTests() throws IOException { + private B2LargeFileStorer createLargeFileStorerForStartByteTests(boolean partNumberGapsAllowed) throws IOException { final List partStorers = Arrays.asList( new B2UploadingPartStorer(1, createContentSourceWithSize(100)), new B2AlreadyStoredPartStorer(part2), @@ -188,18 +226,19 @@ private B2LargeFileStorer createLargeFileStorerForStartByteTests() throws IOExce new B2UploadingPartStorer(4, createContentSourceWithSize(900))); return new B2LargeFileStorer( - B2StoreLargeFileRequest.builder(largeFileVersion).build(), + B2StoreLargeFileRequest.builder(largeFileVersion.getFileId()).build(), partStorers, authCache, webifier, retryer, retryPolicySupplier, - executor); + executor, + partNumberGapsAllowed); } @Test public void testStartByte() throws IOException { - final B2LargeFileStorer largeFileStorer = createLargeFileStorerForStartByteTests(); + final B2LargeFileStorer largeFileStorer = createLargeFileStorerForStartByteTests(false); assertEquals(0, largeFileStorer.getStartByteOrUnknown(1)); assertEquals(100, largeFileStorer.getStartByteOrUnknown(2)); @@ -207,14 +246,34 @@ public void testStartByte() throws IOException { assertEquals(B2UploadProgress.UNKNOWN_PART_START_BYTE, largeFileStorer.getStartByteOrUnknown(4)); } - @Test(expected = IndexOutOfBoundsException.class) + @Test + public void testStartByte_partNumberGapsAllowed() throws IOException { + final B2LargeFileStorer largeFileStorer = createLargeFileStorerForStartByteTests(true); + + assertEquals(0, largeFileStorer.getStartByteOrUnknown(1)); + assertEquals(100, largeFileStorer.getStartByteOrUnknown(2)); + assertEquals(100 + PART_SIZE_FOR_FIRST_TWO, largeFileStorer.getStartByteOrUnknown(3)); + assertEquals(B2UploadProgress.UNKNOWN_PART_START_BYTE, largeFileStorer.getStartByteOrUnknown(4)); + } + + @Test(expected = IllegalArgumentException.class) public void testStartByte_partNumberTooLow() throws IOException { - createLargeFileStorerForStartByteTests().getStartByteOrUnknown(0); + createLargeFileStorerForStartByteTests(false).getStartByteOrUnknown(0); } - @Test(expected = IndexOutOfBoundsException.class) + @Test(expected = IllegalArgumentException.class) + public void testStartByte_partNumberTooLow_partNumberGapsAllowed() throws IOException { + createLargeFileStorerForStartByteTests(true).getStartByteOrUnknown(0); + } + + @Test(expected = IllegalArgumentException.class) public void testStartByte_partNumberTooHigh() throws IOException { - createLargeFileStorerForStartByteTests().getStartByteOrUnknown(5); + createLargeFileStorerForStartByteTests(false).getStartByteOrUnknown(5); + } + + @Test(expected = IllegalArgumentException.class) + public void testStartByte_partNumberTooHigh_partNumberGapsAllowed() throws IOException { + createLargeFileStorerForStartByteTests(true).getStartByteOrUnknown(5); } @Test @@ -246,13 +305,14 @@ private void storeFile(B2UploadListener uploadListener) throws IOException, B2Ex partStorers.add(new B2AlreadyStoredPartStorer(part3)); final B2LargeFileStorer largeFileStorer = new B2LargeFileStorer( - B2StoreLargeFileRequest.builder(largeFileVersion).build(), + B2StoreLargeFileRequest.builder(largeFileVersion.getFileId()).build(), partStorers, authCache, webifier, retryer, retryPolicySupplier, - executor); + executor, + false); assertEquals(partStorers, largeFileStorer.getPartStorers()); @@ -271,13 +331,14 @@ private void storeFileAsync(B2UploadListener uploadListener) throws IOException, partStorers.add(new B2AlreadyStoredPartStorer(part3)); final B2LargeFileStorer largeFileStorer = new B2LargeFileStorer( - B2StoreLargeFileRequest.builder(largeFileVersion).build(), + B2StoreLargeFileRequest.builder(largeFileVersion.getFileId()).build(), partStorers, authCache, webifier, retryer, retryPolicySupplier, - executor); + executor, + false); assertEquals(partStorers, largeFileStorer.getPartStorers()); @@ -359,6 +420,7 @@ public void testStoreFile_cannotUpload() throws B2Exception { fail("should have thrown B2InternalErrorException"); } } + @Test public void testStoreFileAsync_cannotUpload() throws B2Exception { when(webifier.uploadPart(anyObject(), anyObject())).thenThrow(new B2InternalErrorException("error")); @@ -429,10 +491,12 @@ public void testStoreFileAsyncCancelled() throws B2Exception, IOException { when(contentSourceForPart1.getContentLength()).thenReturn(PART_SIZE_FOR_FIRST_TWO); when(contentSourceForPart2.getContentLength()).thenReturn(PART_SIZE_FOR_FIRST_TWO); - // when the first call to uploadPart is called, we will cancel the request + // when the first call to uploadPart is called, we will cancel the request when(webifier.uploadPart(any(), any())).thenAnswer(invocation -> { - future.get().cancel(true); - return null; + // this method could be called before value is assigned to future, so we need to use + // waitForReferenceValue() instead of directly calling future.get() + waitForReferenceValue(future).cancel(true); + return mock(B2Part.class); }); partStorers.add(new B2UploadingPartStorer(1, contentSourceForPart1)); @@ -440,38 +504,132 @@ public void testStoreFileAsyncCancelled() throws B2Exception, IOException { partStorers.add(new B2AlreadyStoredPartStorer(part3)); final B2LargeFileStorer largeFileStorer = new B2LargeFileStorer( - B2StoreLargeFileRequest.builder(largeFileVersion).build(), + B2StoreLargeFileRequest.builder(largeFileVersion.getFileId()).build(), partStorers, authCache, webifier, retryer, retryPolicySupplier, - singleThreadedExecutor); + singleThreadedExecutor, + false); assertEquals(partStorers, largeFileStorer.getPartStorers()); future.set(largeFileStorer.storeFileAsync(uploadListenerMock)); try { - future.get().get(); Assert.fail("we should have gotten a CancellationException"); } catch (CancellationException e) { - // upload part should only be called once verify(webifier, times(1)).uploadPart(anyObject(), anyObject()); verify(webifier, times(0)).finishLargeFile(anyObject(), anyObject()); + } catch (Exception e) { + Assert.fail("we should have gotten a CancellationException"); + } + } + + @Test + public void testStorePartsAsyncCancelled() throws B2Exception, IOException { + final List partStorers = new ArrayList<>(); + final B2ContentSource contentSourceForPart1 = mock(B2ContentSource.class); + final B2ContentSource contentSourceForPart2 = mock(B2ContentSource.class); + final AtomicReference>> future = new AtomicReference<>(); + + when(contentSourceForPart1.getContentLength()).thenReturn(PART_SIZE_FOR_FIRST_TWO); + when(contentSourceForPart2.getContentLength()).thenReturn(PART_SIZE_FOR_FIRST_TWO); + + // when the first call to uploadPart is called, we will cancel the request + when(webifier.uploadPart(any(), any())).thenAnswer(invocation -> { + // this method could be called before value is assigned to future, so we need to use + // waitForReferenceValue() instead of directly calling future.get() + waitForReferenceValue(future).cancel(true); + return mock(B2Part.class); + }); + + partStorers.add(new B2UploadingPartStorer(1, contentSourceForPart1)); + partStorers.add(new B2UploadingPartStorer(2, contentSourceForPart2)); + partStorers.add(new B2AlreadyStoredPartStorer(part3)); + + final B2LargeFileStorer largeFileStorer = new B2LargeFileStorer( + B2StoreLargeFileRequest.builder(largeFileVersion.getFileId()).build(), + partStorers, + authCache, + webifier, + retryer, + retryPolicySupplier, + singleThreadedExecutor, + false); + + assertEquals(partStorers, largeFileStorer.getPartStorers()); + + future.set(largeFileStorer.storePartsAsync(uploadListenerMock)); + + try { + future.get().get(); + Assert.fail("we should have gotten a CancellationException"); + } catch (CancellationException e) { + // upload part should only be called once + verify(webifier, times(1)).uploadPart(anyObject(), anyObject()); } catch (Exception e) { Assert.fail("we should have gotten a CancellationException"); } } + @Test + public void testStorePartsAsyncCannotUpload_SingleThreaded() + throws B2Exception, IOException, InterruptedException { + testStorePartsAsyncCannotUpload(singleThreadedExecutor); + } + + @Test + public void testStorePartsAsyncCannotUpload_MultiThreaded() + throws B2Exception, IOException, InterruptedException { + testStorePartsAsyncCannotUpload(Executors.newFixedThreadPool(3)); + } + + private void testStorePartsAsyncCannotUpload(ExecutorService executor) + throws B2Exception, IOException, InterruptedException { + final List partStorers = new ArrayList<>(); + final B2ContentSource contentSourceForPart = mock(B2ContentSource.class); + + when(contentSourceForPart.getContentLength()).thenReturn(FIVE_MEGABYTES); + + // every time uploadPart is called, we throw an exception to simulate a failed upload + when(webifier.uploadPart(any(), any())).thenThrow(new B2InternalErrorException("test")); + + partStorers.add(new B2UploadingPartStorer(1, contentSourceForPart)); + partStorers.add(new B2UploadingPartStorer(2, contentSourceForPart)); + partStorers.add(new B2UploadingPartStorer(3, contentSourceForPart)); + + final B2LargeFileStorer largeFileStorer = new B2LargeFileStorer( + B2StoreLargeFileRequest.builder(largeFileVersion.getFileId()).build(), + partStorers, + authCache, + webifier, + retryer, + retryPolicySupplier, + executor, + false); + + assertEquals(partStorers, largeFileStorer.getPartStorers()); + + try { + largeFileStorer.storePartsAsync(uploadListenerMock).get(); + Assert.fail("should have thrown ExecutionException"); + } catch (ExecutionException e) { + assertEquals(B2InternalErrorException.class, e.getCause().getClass()); + // all three parts should be attempted even though each fails (8x each because of default retry policy) + verify(webifier, times(24)).uploadPart(anyObject(), anyObject()); + } + } + @Test public void testStoreLargeFileRequest_withSseB2_throwsIllegalArgumentException() { thrown.expect(IllegalArgumentException.class); - B2StoreLargeFileRequest.builder(largeFileVersion) + B2StoreLargeFileRequest.builder(largeFileVersion.getFileId()) .setServerSideEncryption(B2FileSseForRequest.createSseB2Aes256()) .build(); } @@ -493,7 +651,7 @@ public void testCreateRangedContentSource() throws IOException { /** * A content source that can be ranged once. */ - class TestContentSource implements B2ContentSource { + static class TestContentSource implements B2ContentSource { private final long start; private final long length; @@ -556,7 +714,7 @@ public boolean equals(Object o) { * thread. This is done so our assertions can assume a specific order of progress events. This works because the * tasks are all independent of each other and the main thread has no work to do while the tasks are running. */ - private class ExecutorThatUsesMainThread extends AbstractExecutorService { + private static class ExecutorThatUsesMainThread extends AbstractExecutorService { @Override public void shutdown() { } @@ -586,4 +744,27 @@ public void execute(Runnable command) { command.run(); } } + + /** + * Repeatedly sleeps and retries until the value of reference.get() is non-null, and then returns that + * value. + */ + private T waitForReferenceValue(AtomicReference reference) { + final long startTime = System.currentTimeMillis(); + T value = reference.get(); + while (value == null) { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + throw new RuntimeException("Interrupted", e); + } + // just in case, let's set a 30s timeout so that we don't spin forever waiting for a reference value + if (System.currentTimeMillis() - startTime > 30_000) { + throw new RuntimeException("timed out while waiting for reference to be set!"); + } + + value = reference.get(); + } + return value; + } } diff --git a/core/src/test/java/com/backblaze/b2/client/B2StorageClientImplTest.java b/core/src/test/java/com/backblaze/b2/client/B2StorageClientImplTest.java index 6bdd148b1..879ab185c 100644 --- a/core/src/test/java/com/backblaze/b2/client/B2StorageClientImplTest.java +++ b/core/src/test/java/com/backblaze/b2/client/B2StorageClientImplTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client; @@ -28,10 +28,13 @@ import com.backblaze.b2.client.structures.B2DownloadAuthorization; import com.backblaze.b2.client.structures.B2DownloadByIdRequest; import com.backblaze.b2.client.structures.B2DownloadByNameRequest; +import com.backblaze.b2.client.structures.B2EventNotificationRule; import com.backblaze.b2.client.structures.B2FileRetention; import com.backblaze.b2.client.structures.B2FileRetentionMode; import com.backblaze.b2.client.structures.B2FileVersion; import com.backblaze.b2.client.structures.B2FinishLargeFileRequest; +import com.backblaze.b2.client.structures.B2GetBucketNotificationRulesRequest; +import com.backblaze.b2.client.structures.B2GetBucketNotificationRulesResponse; import com.backblaze.b2.client.structures.B2GetDownloadAuthorizationRequest; import com.backblaze.b2.client.structures.B2GetFileInfoByNameRequest; import com.backblaze.b2.client.structures.B2GetFileInfoRequest; @@ -52,6 +55,8 @@ import com.backblaze.b2.client.structures.B2ListUnfinishedLargeFilesResponse; import com.backblaze.b2.client.structures.B2Part; import com.backblaze.b2.client.structures.B2ReplicationRule; +import com.backblaze.b2.client.structures.B2SetBucketNotificationRulesRequest; +import com.backblaze.b2.client.structures.B2SetBucketNotificationRulesResponse; import com.backblaze.b2.client.structures.B2StartLargeFileRequest; import com.backblaze.b2.client.structures.B2UpdateBucketRequest; import com.backblaze.b2.client.structures.B2UpdateFileLegalHoldRequest; @@ -64,6 +69,7 @@ import com.backblaze.b2.client.structures.B2UploadPartUrlResponse; import com.backblaze.b2.client.structures.B2UploadProgress; import com.backblaze.b2.client.structures.B2UploadUrlResponse; +import com.backblaze.b2.client.structures.B2WebhookConfiguration; import com.backblaze.b2.util.B2BaseTest; import com.backblaze.b2.util.B2ByteRange; import com.backblaze.b2.util.B2Clock; @@ -82,6 +88,7 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.TreeSet; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -205,7 +212,7 @@ public void testCreateBucket_convenience() throws B2Exception { final B2Bucket response = client.createBucket(BUCKET_NAME, BUCKET_TYPE); assertEquals(bucket, response); - B2CreateBucketRequestReal expectedRequest = new B2CreateBucketRequestReal( + final B2CreateBucketRequestReal expectedRequest = new B2CreateBucketRequestReal( ACCOUNT_ID, new B2CreateBucketRequest( BUCKET_NAME, @@ -270,6 +277,7 @@ public void testCreateBucket() throws B2Exception { .setLifecycleRules(lifecycleRules) .setDefaultServerSideEncryption(defaultServerSideEncryption) .build(); + final B2Bucket bucket = new B2Bucket( ACCOUNT_ID, bucketId(1), @@ -1180,4 +1188,117 @@ public void testClose() { verify(webifier, times(1)).close(); } + @Test + public void testSetBucketNotificationRules() throws B2Exception { + final List eventNotificationRuleList = listOf( + new B2EventNotificationRule( + "myRule", + new TreeSet<>( + listOf( + "b2:ObjectCreated:Replica", + "b2:ObjectCreated:Upload" + ) + ), + "", + new B2WebhookConfiguration("https://www.example.com"), + true + ), + new B2EventNotificationRule( + "myRule2", + new TreeSet<>( + listOf( + "b2:ObjectDeleted:LifecycleRule" + ) + ), + "", + new B2WebhookConfiguration("https://www.example2.com"), + true + ) + ); + + final List expectedEventNotificationRuleList = listOf( + new B2EventNotificationRule( + "myRule", + new TreeSet<>( + listOf( + "b2:ObjectCreated:Replica", + "b2:ObjectCreated:Upload" + ) + ), + "", + new B2WebhookConfiguration("https://www.example.com", "dummySigningSecret"), + true, + false, + ""), + new B2EventNotificationRule( + "myRule2", + new TreeSet<>( + listOf( + "b2:ObjectDeleted:LifecycleRule" + ) + ), + "", + new B2WebhookConfiguration("https://www.example2.com", "dummySigningSecret"), + true, + false, + "") + ); + + + final B2SetBucketNotificationRulesRequest request = B2SetBucketNotificationRulesRequest + .builder(bucketId(1), eventNotificationRuleList) + .build(); + final B2SetBucketNotificationRulesResponse response = + new B2SetBucketNotificationRulesResponse(bucketId(1), expectedEventNotificationRuleList); + when(webifier.setBucketNotificationRules(any(), eq(request))).thenReturn(response); + + assertSame(response, client.setBucketNotificationRules(request)); + + verify(webifier, times(1)).authorizeAccount(any()); + verify(webifier, times(1)).setBucketNotificationRules(any(), eq(request)); + } + + @Test + public void testGetBucketNotificationRules() throws B2Exception { + final List eventNotificationRuleList = listOf( + new B2EventNotificationRule( + "myRule", + new TreeSet<>( + listOf( + "b2:ObjectCreated:Replica", + "b2:ObjectCreated:Upload" + ) + ), + "", + new B2WebhookConfiguration("https://www.example.com", "dummySigningSecret"), + true, + false, + ""), + new B2EventNotificationRule( + "myRule2", + new TreeSet<>( + listOf( + "b2:ObjectDeleted:LifecycleRule" + ) + ), + "", + new B2WebhookConfiguration("https://www.example2.com", "dummySigningSecret"), + true, + false, + "") + ); + + final B2GetBucketNotificationRulesRequest request = B2GetBucketNotificationRulesRequest + .builder(bucketId(1)) + .build(); + final B2GetBucketNotificationRulesResponse response = + new B2GetBucketNotificationRulesResponse(bucketId(1), eventNotificationRuleList); + when(webifier.getBucketNotificationRules(any(), eq(request))).thenReturn(response); + + assertSame(response, client.getBucketNotificationRules(request)); + + verify(webifier, times(1)).authorizeAccount(any()); + verify(webifier, times(1)).getBucketNotificationRules(any(), eq(request)); + } + } diff --git a/core/src/test/java/com/backblaze/b2/client/B2StorageClientWebifierImplTest.java b/core/src/test/java/com/backblaze/b2/client/B2StorageClientWebifierImplTest.java index 7ac8da9eb..66608fafb 100644 --- a/core/src/test/java/com/backblaze/b2/client/B2StorageClientWebifierImplTest.java +++ b/core/src/test/java/com/backblaze/b2/client/B2StorageClientWebifierImplTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2022, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client; @@ -20,15 +20,18 @@ import com.backblaze.b2.client.structures.B2CopyPartRequest; import com.backblaze.b2.client.structures.B2CreateBucketRequest; import com.backblaze.b2.client.structures.B2CreateBucketRequestReal; +import com.backblaze.b2.client.structures.B2WebhookCustomHeader; import com.backblaze.b2.client.structures.B2DeleteBucketRequestReal; import com.backblaze.b2.client.structures.B2DeleteFileVersionRequest; import com.backblaze.b2.client.structures.B2DownloadByIdRequest; import com.backblaze.b2.client.structures.B2DownloadByNameRequest; +import com.backblaze.b2.client.structures.B2EventNotificationRule; import com.backblaze.b2.client.structures.B2FileRetention; import com.backblaze.b2.client.structures.B2FileRetentionMode; import com.backblaze.b2.client.structures.B2FileSseForRequest; import com.backblaze.b2.client.structures.B2FileVersion; import com.backblaze.b2.client.structures.B2FinishLargeFileRequest; +import com.backblaze.b2.client.structures.B2GetBucketNotificationRulesRequest; import com.backblaze.b2.client.structures.B2GetDownloadAuthorizationRequest; import com.backblaze.b2.client.structures.B2GetFileInfoByNameRequest; import com.backblaze.b2.client.structures.B2GetFileInfoRequest; @@ -41,6 +44,7 @@ import com.backblaze.b2.client.structures.B2ListFileVersionsRequest; import com.backblaze.b2.client.structures.B2ListPartsRequest; import com.backblaze.b2.client.structures.B2ListUnfinishedLargeFilesRequest; +import com.backblaze.b2.client.structures.B2SetBucketNotificationRulesRequest; import com.backblaze.b2.client.structures.B2StartLargeFileRequest; import com.backblaze.b2.client.structures.B2TestMode; import com.backblaze.b2.client.structures.B2UpdateBucketRequest; @@ -53,6 +57,7 @@ import com.backblaze.b2.client.structures.B2UploadProgress; import com.backblaze.b2.client.structures.B2UploadState; import com.backblaze.b2.client.structures.B2UploadUrlResponse; +import com.backblaze.b2.client.structures.B2WebhookConfiguration; import com.backblaze.b2.client.webApiClients.B2WebApiClient; import com.backblaze.b2.util.B2BaseTest; import com.backblaze.b2.util.B2ByteRange; @@ -73,7 +78,9 @@ import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.TreeSet; import java.util.concurrent.atomic.AtomicInteger; import static com.backblaze.b2.client.B2TestHelpers.bucketId; @@ -88,6 +95,7 @@ import static com.backblaze.b2.client.exceptions.B2UnauthorizedException.RequestCategory.OTHER; import static com.backblaze.b2.client.exceptions.B2UnauthorizedException.RequestCategory.UPLOADING; import static com.backblaze.b2.json.B2Json.toJsonOrThrowRuntime; +import static com.backblaze.b2.util.B2Collections.listOf; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -1947,6 +1955,135 @@ public void testUpdateFileRetention() throws B2Exception { checkRequestCategory(OTHER, w -> w.updateFileRetention(ACCOUNT_AUTH, requestReal)); } + @Test + public void testSetBucketNotificationRules() throws B2Exception { + final List eventNotificationRuleList = listOf( + new B2EventNotificationRule( + "myRule", + new TreeSet<>( + listOf( + "b2:ObjectCreated:Replica", + "b2:ObjectCreated:Upload" + ) + ), + "", + new B2WebhookConfiguration( + "https://www.example.com", + new TreeSet<>( + listOf( + new B2WebhookCustomHeader("name1", "val1"), + new B2WebhookCustomHeader("name2", "val2") + ) + ), + "3XDfkdQte2OgA78qCtSD17LAzpj6ay9H" + ), + true + ), + new B2EventNotificationRule( + "myRule2", + new TreeSet<>( + listOf( + "b2:ObjectDeleted:LifecycleRule" + ) + ), + "", + new B2WebhookConfiguration("https://www.example2.com"), + true + ) + ); + + final B2SetBucketNotificationRulesRequest requestReal = B2SetBucketNotificationRulesRequest + .builder(bucketId(1), eventNotificationRuleList) + .build(); + webifier.setBucketNotificationRules(ACCOUNT_AUTH, requestReal); + + webApiClient.check("postJsonReturnJson.\n" + + "url:\n" + + " apiUrl1/b2api/v2/b2_set_bucket_notification_rules\n" + + "headers:\n" + + " Authorization: accountToken1\n" + + " User-Agent: SecretAgentMan/3.19.28\n" + + " X-Bz-Test-Mode: force_cap_exceeded\n" + + "request:\n" + + " {\n" + + " \"bucketId\": \"bucket1\",\n" + + " \"eventNotificationRules\": [\n" + + " {\n" + + " \"eventTypes\": [\n" + + " \"b2:ObjectCreated:Replica\",\n" + + " \"b2:ObjectCreated:Upload\"\n" + + " ],\n" + + " \"isEnabled\": true,\n" + + " \"isSuspended\": null,\n" + + " \"name\": \"myRule\",\n" + + " \"objectNamePrefix\": \"\",\n" + + " \"suspensionReason\": null,\n" + + " \"targetConfiguration\": {\n" + + " \"customHeaders\": [\n" + + " {\n" + + " \"name\": \"name1\",\n" + + " \"value\": \"val1\"\n" + + " },\n" + + " {\n" + + " \"name\": \"name2\",\n" + + " \"value\": \"val2\"\n" + + " }\n" + + " ],\n" + + " \"hmacSha256SigningSecret\": \"3XDfkdQte2OgA78qCtSD17LAzpj6ay9H\",\n" + + " \"targetType\": \"webhook\",\n" + + " \"url\": \"https://www.example.com\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"eventTypes\": [\n" + + " \"b2:ObjectDeleted:LifecycleRule\"\n" + + " ],\n" + + " \"isEnabled\": true,\n" + + " \"isSuspended\": null,\n" + + " \"name\": \"myRule2\",\n" + + " \"objectNamePrefix\": \"\",\n" + + " \"suspensionReason\": null,\n" + + " \"targetConfiguration\": {\n" + + " \"customHeaders\": null,\n" + + " \"hmacSha256SigningSecret\": null,\n" + + " \"targetType\": \"webhook\",\n" + + " \"url\": \"https://www.example2.com\"\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + "responseClass:\n" + + " B2SetBucketNotificationRulesResponse\n" + ); + + checkRequestCategory(OTHER, w -> w.setBucketNotificationRules(ACCOUNT_AUTH, requestReal)); + } + + @Test + public void testGetBucketNotificationRules() throws B2Exception { + final B2GetBucketNotificationRulesRequest requestReal = B2GetBucketNotificationRulesRequest + .builder(bucketId(1)) + .build(); + webifier.getBucketNotificationRules(ACCOUNT_AUTH, requestReal); + + webApiClient.check("postJsonReturnJson.\n" + + "url:\n" + + " apiUrl1/b2api/v2/b2_get_bucket_notification_rules\n" + + "headers:\n" + + " Authorization: accountToken1\n" + + " User-Agent: SecretAgentMan/3.19.28\n" + + " X-Bz-Test-Mode: force_cap_exceeded\n" + + "request:\n" + + " {\n" + + " \"bucketId\": \"bucket1\"\n" + + " }\n" + + "responseClass:\n" + + " B2GetBucketNotificationRulesResponse\n" + ); + + checkRequestCategory(OTHER, w -> w.getBucketNotificationRules(ACCOUNT_AUTH, requestReal)); + } + @Test public void testTestModes() throws B2Exception { // test each possible testMode, including "none". diff --git a/core/src/test/java/com/backblaze/b2/client/B2TestHelpers.java b/core/src/test/java/com/backblaze/b2/client/B2TestHelpers.java index b38553423..229ef0b39 100644 --- a/core/src/test/java/com/backblaze/b2/client/B2TestHelpers.java +++ b/core/src/test/java/com/backblaze/b2/client/B2TestHelpers.java @@ -1,5 +1,5 @@ /* - * Copyright 2021, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client; @@ -184,6 +184,7 @@ public static B2LifecycleRule makeLifecycleRule(int i) { .builder("/prefix" + i + "/") .setDaysFromUploadingToHiding(i) .setDaysFromHidingToDeleting(2 * i) + .setDaysFromStartingToCancelingUnfinishedLargeFiles(3 * i) .build(); } diff --git a/core/src/test/java/com/backblaze/b2/client/B2UploadingPartStorerTest.java b/core/src/test/java/com/backblaze/b2/client/B2UploadingPartStorerTest.java index 1e286374b..57486c237 100644 --- a/core/src/test/java/com/backblaze/b2/client/B2UploadingPartStorerTest.java +++ b/core/src/test/java/com/backblaze/b2/client/B2UploadingPartStorerTest.java @@ -42,9 +42,9 @@ public void testStorePart() throws IOException, B2Exception { final B2LargeFileStorer largeFileStorer = mock(B2LargeFileStorer.class); final B2CancellationToken cancellationToken = new B2CancellationToken(); - when(largeFileStorer.uploadPart(anyInt(), anyObject(), anyObject(), eq(cancellationToken))).thenReturn(part); + when(largeFileStorer.uploadPart(anyInt(), anyObject(), anyObject())).thenReturn(part); assertEquals(part, partStorer.storePart(largeFileStorer, uploadListener, cancellationToken)); - verify(largeFileStorer).uploadPart(eq(2), anyObject(), eq(uploadListener), eq(cancellationToken)); + verify(largeFileStorer).uploadPart(eq(2), anyObject(), eq(uploadListener)); } } diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2ApplicationKeyTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2ApplicationKeyTest.java index f547ec409..a074b81a9 100644 --- a/core/src/test/java/com/backblaze/b2/client/structures/B2ApplicationKeyTest.java +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2ApplicationKeyTest.java @@ -32,6 +32,8 @@ public void testEquals() { capabilities.add(B2Capabilities.WRITE_FILE_LEGAL_HOLDS); capabilities.add(B2Capabilities.READ_BUCKET_REPLICATIONS); capabilities.add(B2Capabilities.WRITE_BUCKET_REPLICATIONS); + capabilities.add(B2Capabilities.READ_BUCKET_NOTIFICATIONS); + capabilities.add(B2Capabilities.WRITE_BUCKET_NOTIFICATIONS); final B2ApplicationKey applicationKey = new B2ApplicationKey( "accountId", diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2BucketTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2BucketTest.java index 4846308e4..592efc622 100644 --- a/core/src/test/java/com/backblaze/b2/client/structures/B2BucketTest.java +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2BucketTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ @@ -127,6 +127,7 @@ public void testJsonRoundTrip() { sourceToDestinationKeyMapping ) ); + final B2Bucket bucket = new B2Bucket( ACCOUNT_ID, bucketId(1), @@ -213,6 +214,7 @@ public void testFromJson() { sourceToDestinationKeyMapping ) ); + final B2Bucket bucket = new B2Bucket( ACCOUNT_ID, bucketId(1), @@ -276,6 +278,7 @@ public void testFromJson() { " \"lifecycleRules\": [\n" + " {\n" + " \"daysFromHidingToDeleting\": null,\n" + + " \"daysFromStartingToCancelingUnfinishedLargeFiles\": null,\n" + " \"daysFromUploadingToHiding\": null,\n" + " \"fileNamePrefix\": \"files/\"\n" + " }\n" + diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2CreateBucketRequestRealTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2CreateBucketRequestRealTest.java index 0ddded33a..9396a306b 100644 --- a/core/src/test/java/com/backblaze/b2/client/structures/B2CreateBucketRequestRealTest.java +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2CreateBucketRequestRealTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2021, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client.structures; @@ -137,6 +137,7 @@ public void testFullCreateBucketRequestReal() { " \"lifecycleRules\": [\n" + " {\n" + " \"daysFromHidingToDeleting\": null,\n" + + " \"daysFromStartingToCancelingUnfinishedLargeFiles\": null,\n" + " \"daysFromUploadingToHiding\": null,\n" + " \"fileNamePrefix\": \"files/\"\n" + " }\n" + diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2CustomWebhookHeaderTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2CustomWebhookHeaderTest.java new file mode 100644 index 000000000..4b31ff97b --- /dev/null +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2CustomWebhookHeaderTest.java @@ -0,0 +1,34 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.json.B2JsonOptions; +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class B2CustomWebhookHeaderTest extends B2BaseTest { + + @Test + public void testToJsonAndBack() { + final String jsonString = "{\n" + + " \"name\": \"name1\",\n" + + " \"value\": \"val1\"\n" + + "}"; + final B2WebhookCustomHeader converted = + B2Json.fromJsonOrThrowRuntime( + jsonString, + B2WebhookCustomHeader.class, + B2JsonOptions.DEFAULT_AND_ALLOW_EXTRA_FIELDS // for targetType + ); + final B2WebhookCustomHeader defaultConfig = + new B2WebhookCustomHeader("name1", "val1"); + final String convertedJson = B2Json.toJsonOrThrowRuntime(defaultConfig); + assertEquals(defaultConfig, converted); + assertEquals(jsonString, convertedJson); + } +} diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationEventTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationEventTest.java new file mode 100644 index 000000000..08b480698 --- /dev/null +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationEventTest.java @@ -0,0 +1,175 @@ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class B2EventNotificationEventTest extends B2BaseTest { + + @Test + public void testToJsonAndBack() { + final String jsonString = "{\n" + + " \"accountId\": \"e85c6a500333\",\n" + + " \"bucketId\": \"aea8c5bc362ae55070130333\",\n" + + " \"bucketName\": \"mySampleBucket\",\n" + + " \"eventTimestamp\": 1684793309123,\n" + + " \"eventType\": \"b2:ObjectCreated:Upload\",\n" + + " \"eventVersion\": 1,\n" + + " \"matchedRuleName\": \"mySampleRule1\",\n" + + " \"objectName\": \"objectName.txt\",\n" + + " \"objectSize\": 10495842,\n" + + " \"objectVersionId\": \"4_zaea8c5bc362ae55070130333_f117c7bd5d6c6597c_d20230521_m235957_c001_v0001044_t0052_u01684713597235\"\n" + + "}"; + final B2EventNotificationEvent event = + B2Json.fromJsonOrThrowRuntime( + jsonString, + B2EventNotificationEvent.class + ); + + final B2EventNotificationEvent expectedEvent = new B2EventNotificationEvent("e85c6a500333", "aea8c5bc362ae55070130333", "mySampleBucket", 1684793309123L, "b2:ObjectCreated:Upload", 1, + "mySampleRule1", + "objectName.txt", + 10495842L, + "4_zaea8c5bc362ae55070130333_f117c7bd5d6c6597c_d20230521_m235957_c001_v0001044_t0052_u01684713597235"); + final String convertedJson = B2Json.toJsonOrThrowRuntime(expectedEvent); + assertEquals(expectedEvent, event); + assertEquals(jsonString, convertedJson); + } + + @Test + public void testToJsonAndBack_testEvent() { + final String jsonString = "{\n" + + " \"accountId\": \"e85c6a500333\",\n" + + " \"bucketId\": \"aea8c5bc362ae55070130333\",\n" + + " \"bucketName\": \"mySampleBucket\",\n" + + " \"eventTimestamp\": 1684793309123,\n" + + " \"eventType\": \"b2:TestEvent\",\n" + + " \"eventVersion\": 1,\n" + + " \"matchedRuleName\": \"mySampleRule1\"\n" + + "}"; + final B2EventNotificationEvent event = + B2Json.fromJsonOrThrowRuntime( + jsonString, + B2EventNotificationEvent.class + ); + + final B2EventNotificationEvent expectedEvent = new B2EventNotificationEvent( + "e85c6a500333", + "aea8c5bc362ae55070130333", + "mySampleBucket", + 1684793309123L, + "b2:TestEvent", + 1, + "mySampleRule1", + null, + null, + null + ); + final String convertedJson = B2Json.toJsonOrThrowRuntime(expectedEvent); + assertEquals(expectedEvent, event); + assertEquals(jsonString, convertedJson); + } + + @Test + public void testTestEventsMustNotHaveObjectName() { + final IllegalArgumentException illegalArgumentException = assertThrows( + IllegalArgumentException.class, + () -> new B2EventNotificationEvent( + "e85c6a500333", + "aea8c5bc362ae55070130333", + "mySampleBucket", + 1684793309123L, + "b2:TestEvent", + 1, + "mySampleRule1", + "objectName.txt", + null, + null + ) + ); + assertEquals(illegalArgumentException.getMessage(), "objectName must be null for test events"); + } + + @Test + public void testTestEventsMustNotHaveObjectSize() { + final IllegalArgumentException illegalArgumentException = assertThrows( + IllegalArgumentException.class, + () -> new B2EventNotificationEvent( + "e85c6a500333", + "aea8c5bc362ae55070130333", + "mySampleBucket", + 1684793309123L, + "b2:TestEvent", + 1, + "mySampleRule1", + null, + 10495842L, + null + ) + ); + assertEquals(illegalArgumentException.getMessage(), "objectSize must be null for test events"); + } + + @Test + public void testTestEventsMustNotHaveObjectVersionId() { + final IllegalArgumentException illegalArgumentException = assertThrows( + IllegalArgumentException.class, + () -> new B2EventNotificationEvent( + "e85c6a500333", + "aea8c5bc362ae55070130333", + "mySampleBucket", + 1684793309123L, + "b2:TestEvent", + 1, + "mySampleRule1", + null, + null, + "4_zaea8c5bc362ae55070130333_f117c7bd5d6c6597c_d20230521_m235957_c001_v0001044_t0052_u01684713597235" + ) + ); + assertEquals(illegalArgumentException.getMessage(), "objectVersionId must be null for test events"); + } + + @Test + public void testNonTestEventsMustHaveObjectName() { + final IllegalArgumentException illegalArgumentException = assertThrows( + IllegalArgumentException.class, + () -> new B2EventNotificationEvent( + "e85c6a500333", + "aea8c5bc362ae55070130333", + "mySampleBucket", + 1684793309123L, + "b2:ObjectCreated:Upload", + 1, + "mySampleRule1", + null, + null, + "4_zaea8c5bc362ae55070130333_f117c7bd5d6c6597c_d20230521_m235957_c001_v0001044_t0052_u01684713597235" + ) + ); + assertEquals(illegalArgumentException.getMessage(), "objectName is required"); + } + + @Test + public void testNonTestEventsMustHaveObjectVersionId() { + final IllegalArgumentException illegalArgumentException = assertThrows( + IllegalArgumentException.class, + () -> new B2EventNotificationEvent( + "e85c6a500333", + "aea8c5bc362ae55070130333", + "mySampleBucket", + 1684793309123L, + "b2:ObjectCreated:Upload", + 1, + "mySampleRule1", + "objectName.txt", + null, + null + ) + ); + assertEquals(illegalArgumentException.getMessage(), "objectVersionId is required"); + } +} \ No newline at end of file diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationRuleTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationRuleTest.java new file mode 100644 index 000000000..3642c54ca --- /dev/null +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationRuleTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import java.util.TreeSet; + +import static com.backblaze.b2.util.B2Collections.listOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class B2EventNotificationRuleTest extends B2BaseTest { + + @Test + public void testToJsonAndBack() { + final String jsonString = "{\n" + + " \"eventTypes\": [\n" + + " \"b2:ObjectCreated:Replica\",\n" + + " \"b2:ObjectCreated:Upload\"\n" + + " ],\n" + + " \"isEnabled\": true,\n" + + " \"isSuspended\": false,\n" + + " \"name\": \"myRule\",\n" + + " \"objectNamePrefix\": \"\",\n" + + " \"suspensionReason\": \"\",\n" + + " \"targetConfiguration\": {\n" + + " \"customHeaders\": [\n" + + " {\n" + + " \"name\": \"name1\",\n" + + " \"value\": \"val1\"\n" + + " },\n" + + " {\n" + + " \"name\": \"name2\",\n" + + " \"value\": \"val2\"\n" + + " }\n" + + " ],\n" + + " \"hmacSha256SigningSecret\": \"3XDfkdQte2OgA78qCtSD17LAzpj6ay9H\",\n" + + " \"targetType\": \"webhook\",\n" + + " \"url\": \"https://www.example.com\"\n" + + " }\n" + + "}"; + final B2EventNotificationRule converted = + B2Json.fromJsonOrThrowRuntime( + jsonString, + B2EventNotificationRule.class + ); + + final TreeSet eventTypes = new TreeSet<>(); + eventTypes.add("b2:ObjectCreated:Replica"); + eventTypes.add("b2:ObjectCreated:Upload"); + final B2EventNotificationRule defaultConfig = + new B2EventNotificationRule( + "myRule", + eventTypes, + "", + new B2WebhookConfiguration("" + + "https://www.example.com", + new TreeSet<>( + listOf( + new B2WebhookCustomHeader("name1", "val1"), + new B2WebhookCustomHeader("name2", "val2") + ) + ), + "3XDfkdQte2OgA78qCtSD17LAzpj6ay9H" + ), + true, + false, + ""); + final String convertedJson = B2Json.toJsonOrThrowRuntime(defaultConfig); + assertEquals(defaultConfig, converted); + assertEquals(jsonString, convertedJson); + } +} diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationTest.java new file mode 100644 index 000000000..ae358ee73 --- /dev/null +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2EventNotificationTest.java @@ -0,0 +1,232 @@ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.client.exceptions.B2SignatureVerificationException; +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.json.B2JsonException; +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + + +public class B2EventNotificationTest extends B2BaseTest { + + private static final String HMAC_SHA256_SIGNING_SECRET = "3XDfkdQte2OgA78qCtSD17LAzpj6ay9H"; + private static final String HMAC_SHA256_SIGNING_SECRET2 = "rrzaVL6BqYt83s2Q5R2I79AilaxVBJUS"; + + private static final String DEFAULT_EVENT_PAYLOAD = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"accountId\": \"e85c6a500333\",\n" + + " \"bucketId\": \"aea8c5bc362ae55070130333\",\n" + + " \"bucketName\": \"mySampleBucket\",\n" + + " \"eventTimestamp\": 1684793309123,\n" + + " \"eventType\": \"b2:ObjectCreated:Upload\",\n" + + " \"eventVersion\": 1,\n" + + " \"matchedRuleName\": \"mySampleRule1\",\n" + + " \"objectName\": \"objectName.txt\",\n" + + " \"objectSize\": 10495842,\n" + + " \"objectVersionId\": \"4_zaea8c5bc362ae55070130333_f117c7bd5d6c6597c_d20230521_m235957_c001_v0001044_t0052_u01684713597235\"\n" + + " }\n" + + " ]\n" + + "}"; + public static final byte[] DEFAULT_EVENT_BYTES = + DEFAULT_EVENT_PAYLOAD.getBytes(StandardCharsets.UTF_8); + + @Test + public void testToJsonAndBack() { + final B2EventNotification eventNotification = + B2Json.fromJsonOrThrowRuntime( + DEFAULT_EVENT_PAYLOAD, + B2EventNotification.class + ); + + final List expectedEvents = new ArrayList<>(); + expectedEvents.add(new B2EventNotificationEvent( + "e85c6a500333", + "aea8c5bc362ae55070130333", + "mySampleBucket", + 1684793309123L, + "b2:ObjectCreated:Upload", + 1, + "mySampleRule1", + "objectName.txt", + 10495842L, + "4_zaea8c5bc362ae55070130333_f117c7bd5d6c6597c_d20230521_m235957_c001_v0001044_t0052_u01684713597235")); + + final B2EventNotification expectedNotification = new B2EventNotification(expectedEvents); + + final String convertedJson = B2Json.toJsonOrThrowRuntime(expectedNotification); + + assertEquals(DEFAULT_EVENT_PAYLOAD, convertedJson); + assertEquals(expectedNotification, eventNotification); + } + + @Test + public void testToJsonAndBackWithNullSize() { + final String jsonString = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"accountId\": \"e85c6a500333\",\n" + + " \"bucketId\": \"aea8c5bc362ae55070130333\",\n" + + " \"bucketName\": \"mySampleBucket\",\n" + + " \"eventTimestamp\": 1684793309123,\n" + + " \"eventType\": \"b2:ObjectCreated:Upload\",\n" + + " \"eventVersion\": 1,\n" + + " \"matchedRuleName\": \"mySampleRule1\",\n" + + " \"objectName\": \"objectName.txt\",\n" + + " \"objectVersionId\": \"4_zaea8c5bc362ae55070130333_f117c7bd5d6c6597c_d20230521_m235957_c001_v0001044_t0052_u01684713597235\"\n" + + " }\n" + + " ]\n" + + "}"; + final B2EventNotification eventNotification = + B2Json.fromJsonOrThrowRuntime( + jsonString, + B2EventNotification.class + ); + + final List expectedEvents = new ArrayList<>(); + expectedEvents.add(new B2EventNotificationEvent( + "e85c6a500333", + "aea8c5bc362ae55070130333", + "mySampleBucket", + 1684793309123L, + "b2:ObjectCreated:Upload", + 1, + "mySampleRule1", + "objectName.txt", + null, + "4_zaea8c5bc362ae55070130333_f117c7bd5d6c6597c_d20230521_m235957_c001_v0001044_t0052_u01684713597235")); + + final B2EventNotification expectedNotification = new B2EventNotification(expectedEvents); + + final String convertedJson = B2Json.toJsonOrThrowRuntime(expectedNotification); + + assertEquals(jsonString, convertedJson); + assertEquals(expectedNotification, eventNotification); + } + + @Test + public void testParseSuccess() throws B2JsonException, B2SignatureVerificationException { + final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, DEFAULT_EVENT_PAYLOAD.getBytes(StandardCharsets.UTF_8)); + final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, signature, HMAC_SHA256_SIGNING_SECRET); + final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + assertEquals(parsedEventNotification, b2EventNotification); + } + + @Test + public void testParseSuccessWithNoSignature() throws B2JsonException, B2SignatureVerificationException { + final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, null, HMAC_SHA256_SIGNING_SECRET); + final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + assertEquals(parsedEventNotification, b2EventNotification); + } + + @Test + public void testParseSuccessWithNoSecret() throws B2JsonException, B2SignatureVerificationException { + final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, DEFAULT_EVENT_PAYLOAD.getBytes(StandardCharsets.UTF_8)); + final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, signature, null); + final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + assertEquals(parsedEventNotification, b2EventNotification); + } + + @Test + public void testParseSuccessWithNoSignatureOrSecret() throws B2JsonException, B2SignatureVerificationException { + final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, null, null); + final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + assertEquals(parsedEventNotification, b2EventNotification); + } + + @Test + public void testParseSuccessWithNoSignatureValidation() throws B2JsonException { + final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD); + final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + assertEquals(parsedEventNotification, b2EventNotification); + } + + @Test + public void testParseSignatureFailure() throws B2SignatureVerificationException { + final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET2, DEFAULT_EVENT_PAYLOAD.getBytes(StandardCharsets.UTF_8)); + assertThrows(B2SignatureVerificationException.class,() -> B2EventNotification.parse(DEFAULT_EVENT_PAYLOAD, signature, HMAC_SHA256_SIGNING_SECRET)); + } + + @Test + public void testParseBytesSuccess() throws B2JsonException, IOException, B2SignatureVerificationException { + final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, DEFAULT_EVENT_BYTES); + final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_BYTES, signature, HMAC_SHA256_SIGNING_SECRET); + final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + assertEquals(parsedEventNotification, b2EventNotification); + } + + @Test + public void testParseBytesSuccessWithNoSignature() throws B2JsonException, IOException, B2SignatureVerificationException { + final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_BYTES, null, HMAC_SHA256_SIGNING_SECRET); + final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + assertEquals(parsedEventNotification, b2EventNotification); + } + + @Test + public void testParseBytesSuccessWithNoSecret() throws B2JsonException, IOException, B2SignatureVerificationException { + final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, DEFAULT_EVENT_PAYLOAD.getBytes(StandardCharsets.UTF_8)); + final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_BYTES, signature, null); + final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + assertEquals(parsedEventNotification, b2EventNotification); + } + + @Test + public void testParseBytesSuccessWithNoSignatureOrSecret() throws B2JsonException, IOException, B2SignatureVerificationException { + final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_BYTES, null, null); + final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + assertEquals(parsedEventNotification, b2EventNotification); + } + + @Test + public void testParseBytesSuccessWithNoSignatureValidation() throws B2JsonException, IOException { + final B2EventNotification b2EventNotification = B2EventNotification.parse(DEFAULT_EVENT_BYTES); + final B2EventNotification parsedEventNotification = B2Json.get().fromJson(DEFAULT_EVENT_PAYLOAD, B2EventNotification.class); + assertEquals(parsedEventNotification, b2EventNotification); + } + + @Test + public void testParseWithInvalidSignature() throws B2SignatureVerificationException { + final byte[] jsonBytes = DEFAULT_EVENT_PAYLOAD.getBytes(StandardCharsets.UTF_8); + final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, + "HelloWorld".getBytes(StandardCharsets.UTF_8)); + assertThrows(B2SignatureVerificationException.class, () -> B2EventNotification.parse(jsonBytes, signature, HMAC_SHA256_SIGNING_SECRET)); + } + + @Test + public void testParseWithInvalidSecret() throws B2SignatureVerificationException { + final byte[] jsonBytes = DEFAULT_EVENT_PAYLOAD.getBytes(StandardCharsets.UTF_8); + final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, + jsonBytes); + assertThrows(B2SignatureVerificationException.class, () -> B2EventNotification.parse(jsonBytes, signature, "Hello World")); + } + + @Test + public void testParseWithInvalidParameters() throws B2SignatureVerificationException { + final byte[] jsonBytes = DEFAULT_EVENT_PAYLOAD.getBytes(StandardCharsets.UTF_8); + final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, jsonBytes); + + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse((byte[])null)); + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse((byte[])null, signature, HMAC_SHA256_SIGNING_SECRET)); + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse(new byte[0], signature, HMAC_SHA256_SIGNING_SECRET)); + + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse((String)null)); + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse((String)null, signature, HMAC_SHA256_SIGNING_SECRET)); + assertThrows(IllegalArgumentException.class, () -> B2EventNotification.parse("", signature, HMAC_SHA256_SIGNING_SECRET)); + } + + @Test + public void testParseWithInvalidJson() throws B2SignatureVerificationException { + final byte[] jsonBytes = "{\n\"key\":\"value\"\n}".getBytes(StandardCharsets.UTF_8); + final String signature = B2EventNotification.SignatureUtils.computeHmacSha256Signature(HMAC_SHA256_SIGNING_SECRET, + jsonBytes); + assertThrows(B2JsonException.class, () -> B2EventNotification.parse(jsonBytes, signature, HMAC_SHA256_SIGNING_SECRET)); + } +} diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesRequestTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesRequestTest.java new file mode 100644 index 000000000..a31e3de3c --- /dev/null +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesRequestTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import static com.backblaze.b2.client.B2TestHelpers.bucketId; +import static org.junit.Assert.assertEquals; + +public class B2GetBucketNotificationRulesRequestTest extends B2BaseTest { + + private static final String BUCKET_ID = bucketId(1); + + @Test + public void testFullGetBucketNotificationRulesRequest() { + + final B2GetBucketNotificationRulesRequest b2GetBucketNotificationRulesRequest = + B2GetBucketNotificationRulesRequest.builder(BUCKET_ID) + .build(); + + // Convert from B2GetBucketNotificationRulesRequest -> json + final String requestJson = B2Json.toJsonOrThrowRuntime(b2GetBucketNotificationRulesRequest); + + final String json = "{\n" + + " \"bucketId\": \"" + BUCKET_ID + "\"\n" + + "}"; + + // Convert from json -> B2GetBucketNotificationRulesRequest + final B2GetBucketNotificationRulesRequest convertedRequest = B2Json.fromJsonOrThrowRuntime(json, B2GetBucketNotificationRulesRequest.class); + + // Compare json + assertEquals(json, requestJson); + + // Compare requests + assertEquals(b2GetBucketNotificationRulesRequest, convertedRequest); + + } +} \ No newline at end of file diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesResponseTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesResponseTest.java new file mode 100644 index 000000000..113a4901d --- /dev/null +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2GetBucketNotificationRulesResponseTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import java.util.List; +import java.util.TreeSet; + +import static com.backblaze.b2.client.B2TestHelpers.bucketId; +import static com.backblaze.b2.util.B2Collections.listOf; +import static org.junit.Assert.assertEquals; + +public class B2GetBucketNotificationRulesResponseTest extends B2BaseTest { + + private static final String BUCKET_ID = bucketId(1); + + @Test + public void testFullGetBucketNotificationRulesResponse() { + + final List eventNotificationRuleList = listOf( + new B2EventNotificationRule( + "ruleName", + new TreeSet<>(listOf("b2:ObjectCreated:Copy")), + "", + new B2WebhookConfiguration( + "https://www.example.com", + new TreeSet<>( + listOf( + new B2WebhookCustomHeader("name1", "val1"), + new B2WebhookCustomHeader("name2", "val2") + ) + ), + "dummySigningSecret"), + true, + false, + null + ) + ); + + final B2GetBucketNotificationRulesResponse b2GetBucketNotificationRulesResponse = + B2GetBucketNotificationRulesResponse.builder(BUCKET_ID, eventNotificationRuleList) + .build(); + + // Convert from B2GetBucketNotificationRulesResponse -> json + final String requestJson = B2Json.toJsonOrThrowRuntime(b2GetBucketNotificationRulesResponse); + + final String json = "{\n" + + " \"bucketId\": \"" + BUCKET_ID + "\",\n" + + " \"eventNotificationRules\": [\n" + + " {\n" + + " \"eventTypes\": [\n" + + " \"b2:ObjectCreated:Copy\"\n" + + " ],\n" + + " \"isEnabled\": true,\n" + + " \"isSuspended\": false,\n" + + " \"name\": \"ruleName\",\n" + + " \"objectNamePrefix\": \"\",\n" + + " \"suspensionReason\": null,\n" + + " \"targetConfiguration\": {\n" + + " \"customHeaders\": [\n" + + " {\n" + + " \"name\": \"name1\",\n" + + " \"value\": \"val1\"\n" + + " },\n" + + " {\n" + + " \"name\": \"name2\",\n" + + " \"value\": \"val2\"\n" + + " }\n" + + " ],\n" + + " \"hmacSha256SigningSecret\": \"dummySigningSecret\",\n" + + " \"targetType\": \"webhook\",\n" + + " \"url\": \"https://www.example.com\"\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + // Convert from json -> B2GetBucketNotificationRulesResponse + final B2GetBucketNotificationRulesResponse convertedResponse = B2Json.fromJsonOrThrowRuntime(json, B2GetBucketNotificationRulesResponse.class); + + // Compare json + assertEquals(json, requestJson); + + // Compare requests + assertEquals(b2GetBucketNotificationRulesResponse, convertedResponse); + + } +} diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2LifecycleRuleTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2LifecycleRuleTest.java index 5069a16e7..5fdc81dbc 100644 --- a/core/src/test/java/com/backblaze/b2/client/structures/B2LifecycleRuleTest.java +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2LifecycleRuleTest.java @@ -22,7 +22,7 @@ public void testMinimal() { final B2LifecycleRule a = B2LifecycleRule .builder(FILE_PREFIX) .build(); - assertEquals("files/:null:null", a.toString()); + assertEquals("files/:null:null:null", a.toString()); } @Test @@ -31,14 +31,16 @@ public void testMaximal() { .builder(FILE_PREFIX) .setDaysFromUploadingToHiding(2) .setDaysFromHidingToDeleting(1) + .setDaysFromStartingToCancelingUnfinishedLargeFiles(3) .build(); final B2LifecycleRule b = B2LifecycleRule .builder(FILE_PREFIX) .setDaysFromUploadingToHiding(2) .setDaysFromHidingToDeleting(1) + .setDaysFromStartingToCancelingUnfinishedLargeFiles(3) .build(); assertEquals(a, b); - assertEquals("files/:2:1", a.toString()); + assertEquals("files/:2:1:3", a.toString()); } @Test @@ -73,4 +75,15 @@ public void testNegativeDaysIsBad2() { .build(); } + @Test + public void testNegativeDaysIsBad3() { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("daysFromStartingToCancelingUnfinishedLargeFiles must be positive"); + + B2LifecycleRule + .builder(FILE_PREFIX) + .setDaysFromStartingToCancelingUnfinishedLargeFiles(0) + .build(); + } + } diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesRequestTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesRequestTest.java new file mode 100644 index 000000000..f69e79743 --- /dev/null +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesRequestTest.java @@ -0,0 +1,115 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import java.util.List; +import java.util.TreeSet; + +import static com.backblaze.b2.client.B2TestHelpers.bucketId; +import static com.backblaze.b2.util.B2Collections.listOf; +import static org.junit.Assert.assertEquals; + +public class B2SetBucketNotificationRulesRequestTest extends B2BaseTest { + + private static final String BUCKET_ID = bucketId(1); + + @Test + public void testFullSetBucketNotificationRulesRequest() { + + final List eventNotificationRuleList = listOf( + new B2EventNotificationRule( + "ruleName", + new TreeSet<>(listOf("b2:ObjectCreated:Copy")), + "", + new B2WebhookConfiguration("https://www.example.com"), + true + ), + new B2EventNotificationRule( + "ruleNameWithCustomHeaders", + new TreeSet<>(listOf("b2:ObjectCreated:Replica")), + "", + new B2WebhookConfiguration( + "https://www.example.com", + new TreeSet<>( + listOf( + new B2WebhookCustomHeader("name1", "val1"), + new B2WebhookCustomHeader("name2", "val2") + ) + ), + "rrzaVL6BqYt83s2Q5R2I79AilaxVBJUS" + ), + true + ) + ); + + final B2SetBucketNotificationRulesRequest b2SetBucketNotificationRulesRequest = + B2SetBucketNotificationRulesRequest.builder(BUCKET_ID, eventNotificationRuleList) + .build(); + + // Convert from B2SetBucketNotificationRulesRequest -> json + final String requestJson = B2Json.toJsonOrThrowRuntime(b2SetBucketNotificationRulesRequest); + + final String json = "{\n" + + " \"bucketId\": \"" + BUCKET_ID + "\",\n" + + " \"eventNotificationRules\": [\n" + + " {\n" + + " \"eventTypes\": [\n" + + " \"b2:ObjectCreated:Copy\"\n" + + " ],\n" + + " \"isEnabled\": true,\n" + + " \"isSuspended\": null,\n" + + " \"name\": \"ruleName\",\n" + + " \"objectNamePrefix\": \"\",\n" + + " \"suspensionReason\": null,\n" + + " \"targetConfiguration\": {\n" + + " \"customHeaders\": null,\n" + + " \"hmacSha256SigningSecret\": null,\n" + + " \"targetType\": \"webhook\",\n" + + " \"url\": \"https://www.example.com\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"eventTypes\": [\n" + + " \"b2:ObjectCreated:Replica\"\n" + + " ],\n" + + " \"isEnabled\": true,\n" + + " \"isSuspended\": null,\n" + + " \"name\": \"ruleNameWithCustomHeaders\",\n" + + " \"objectNamePrefix\": \"\",\n" + + " \"suspensionReason\": null,\n" + + " \"targetConfiguration\": {\n" + + " \"customHeaders\": [\n" + + " {\n" + + " \"name\": \"name1\",\n" + + " \"value\": \"val1\"\n" + + " },\n" + + " {\n" + + " \"name\": \"name2\",\n" + + " \"value\": \"val2\"\n" + + " }\n" + + " ],\n" + + " \"hmacSha256SigningSecret\": \"rrzaVL6BqYt83s2Q5R2I79AilaxVBJUS\",\n" + + " \"targetType\": \"webhook\",\n" + + " \"url\": \"https://www.example.com\"\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + // Convert from json -> B2SetBucketNotificationRulesRequest + final B2SetBucketNotificationRulesRequest convertedRequest = B2Json.fromJsonOrThrowRuntime(json, B2SetBucketNotificationRulesRequest.class); + + // Compare json + assertEquals(json, requestJson); + + // Compare requests + assertEquals(b2SetBucketNotificationRulesRequest, convertedRequest); + + } +} diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesResponseTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesResponseTest.java new file mode 100644 index 000000000..c9aede1c3 --- /dev/null +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2SetBucketNotificationRulesResponseTest.java @@ -0,0 +1,118 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import java.util.List; +import java.util.TreeSet; + +import static com.backblaze.b2.client.B2TestHelpers.bucketId; +import static com.backblaze.b2.util.B2Collections.listOf; +import static org.junit.Assert.assertEquals; + +public class B2SetBucketNotificationRulesResponseTest extends B2BaseTest { + + private static final String BUCKET_ID = bucketId(1); + + @Test + public void testFullSetBucketNotificationRulesResponse() { + + final List eventNotificationRuleList = listOf( + new B2EventNotificationRule( + "ruleName", + new TreeSet<>(listOf("b2:ObjectCreated:Copy")), + "", + new B2WebhookConfiguration("https://www.example.com", "dummySigningSecret"), + true, + true, + "reason for suspension" + ), + new B2EventNotificationRule( + "ruleNameWithCustomHeaders", + new TreeSet<>(listOf("b2:ObjectCreated:Replica")), + "prefix", + new B2WebhookConfiguration( + "https://www.example.com", + new TreeSet<>( + listOf( + new B2WebhookCustomHeader("name1", "val1"), + new B2WebhookCustomHeader("name2", "val2") + ) + ), + "dummySigningSecret"), + true, + false, + null + ) + ); + + final B2SetBucketNotificationRulesResponse b2SetBucketNotificationRulesResponse = + B2SetBucketNotificationRulesResponse.builder(BUCKET_ID, eventNotificationRuleList) + .build(); + + // Convert from B2SetBucketNotificationRulesResponse -> json + final String requestJson = B2Json.toJsonOrThrowRuntime(b2SetBucketNotificationRulesResponse); + + final String json = "{\n" + + " \"bucketId\": \"" + BUCKET_ID + "\",\n" + + " \"eventNotificationRules\": [\n" + + " {\n" + + " \"eventTypes\": [\n" + + " \"b2:ObjectCreated:Copy\"\n" + + " ],\n" + + " \"isEnabled\": true,\n" + + " \"isSuspended\": true,\n" + + " \"name\": \"ruleName\",\n" + + " \"objectNamePrefix\": \"\",\n" + + " \"suspensionReason\": \"reason for suspension\",\n" + + " \"targetConfiguration\": {\n" + + " \"customHeaders\": null,\n" + + " \"hmacSha256SigningSecret\": \"dummySigningSecret\",\n" + + " \"targetType\": \"webhook\",\n" + + " \"url\": \"https://www.example.com\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"eventTypes\": [\n" + + " \"b2:ObjectCreated:Replica\"\n" + + " ],\n" + + " \"isEnabled\": true,\n" + + " \"isSuspended\": false,\n" + + " \"name\": \"ruleNameWithCustomHeaders\",\n" + + " \"objectNamePrefix\": \"prefix\",\n" + + " \"suspensionReason\": null,\n" + + " \"targetConfiguration\": {\n" + + " \"customHeaders\": [\n" + + " {\n" + + " \"name\": \"name1\",\n" + + " \"value\": \"val1\"\n" + + " },\n" + + " {\n" + + " \"name\": \"name2\",\n" + + " \"value\": \"val2\"\n" + + " }\n" + + " ],\n" + + " \"hmacSha256SigningSecret\": \"dummySigningSecret\",\n" + + " \"targetType\": \"webhook\",\n" + + " \"url\": \"https://www.example.com\"\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + // Convert from json -> B2SetBucketNotificationRulesResponse + final B2SetBucketNotificationRulesResponse convertedResponse = B2Json.fromJsonOrThrowRuntime(json, B2SetBucketNotificationRulesResponse.class); + + // Compare json + assertEquals(json, requestJson); + + // Compare requests + assertEquals(b2SetBucketNotificationRulesResponse, convertedResponse); + + } +} diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2UpdateBucketRequestTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2UpdateBucketRequestTest.java index 13dc1c12c..0785d955f 100644 --- a/core/src/test/java/com/backblaze/b2/client/structures/B2UpdateBucketRequestTest.java +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2UpdateBucketRequestTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2022, Backblaze Inc. All Rights Reserved. + * Copyright 2023, Backblaze Inc. All Rights Reserved. * License https://www.backblaze.com/using_b2_code.html */ package com.backblaze.b2.client.structures; @@ -177,6 +177,7 @@ public void testFullUpdateBucketRequest() { " \"lifecycleRules\": [\n" + " {\n" + " \"daysFromHidingToDeleting\": null,\n" + + " \"daysFromStartingToCancelingUnfinishedLargeFiles\": null,\n" + " \"daysFromUploadingToHiding\": null,\n" + " \"fileNamePrefix\": \"files/\"\n" + " }\n" + diff --git a/core/src/test/java/com/backblaze/b2/client/structures/B2WebhookConfigurationTest.java b/core/src/test/java/com/backblaze/b2/client/structures/B2WebhookConfigurationTest.java new file mode 100644 index 000000000..e62bfab26 --- /dev/null +++ b/core/src/test/java/com/backblaze/b2/client/structures/B2WebhookConfigurationTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.client.structures; + +import com.backblaze.b2.json.B2Json; +import com.backblaze.b2.json.B2JsonOptions; +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import java.util.TreeSet; + +import static com.backblaze.b2.util.B2Collections.listOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class B2WebhookConfigurationTest extends B2BaseTest { + + @Test + public void testUrlWithIncorrectProtocolThrows() { + // Must be https:// + try { + //noinspection HttpUrlsUsage + new B2WebhookConfiguration( + "http://www.backblaze.com", + new TreeSet<>( + listOf( + new B2WebhookCustomHeader("name1", "val1"), + new B2WebhookCustomHeader("name2", "val2") + ) + ), + null + ); + fail("should have thrown"); + } + catch (IllegalArgumentException e) { + assertEquals("The protocol for the url must be https://", e.getMessage()); + } + } + + @Test + public void testToJsonAndBack() { + final String jsonString = "{\n" + + " \"customHeaders\": [\n" + + " {\n" + + " \"name\": \"name1\",\n" + + " \"value\": \"val1\"\n" + + " },\n" + + " {\n" + + " \"name\": \"name2\",\n" + + " \"value\": \"val2\"\n" + + " }\n" + + " ],\n" + + " \"hmacSha256SigningSecret\": \"rrzaVL6BqYt83s2Q5R2I79AilaxVBJUS\",\n" + + " \"targetType\": \"webhook\",\n" + + " \"url\": \"https://www.example.com\"\n" + + "}"; + final B2WebhookConfiguration converted = + B2Json.fromJsonOrThrowRuntime( + jsonString, + B2WebhookConfiguration.class, + B2JsonOptions.DEFAULT_AND_ALLOW_EXTRA_FIELDS // for targetType + ); + final B2WebhookConfiguration defaultConfig = + new B2WebhookConfiguration( + "https://www.example.com", + new TreeSet<>( + listOf( + new B2WebhookCustomHeader("name1", "val1"), + new B2WebhookCustomHeader("name2", "val2") + ) + ), + "rrzaVL6BqYt83s2Q5R2I79AilaxVBJUS" + ); + final String convertedJson = B2Json.toJsonOrThrowRuntime(defaultConfig); + assertEquals(defaultConfig, converted); + assertEquals(jsonString, convertedJson); + } +} diff --git a/core/src/test/java/com/backblaze/b2/json/B2JsonDeserializationUtilTest.java b/core/src/test/java/com/backblaze/b2/json/B2JsonDeserializationUtilTest.java new file mode 100644 index 000000000..cc73cd4bb --- /dev/null +++ b/core/src/test/java/com/backblaze/b2/json/B2JsonDeserializationUtilTest.java @@ -0,0 +1,151 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.json; + +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.*; + +public class B2JsonDeserializationUtilTest extends B2BaseTest { + + @Test + public void findConstructor_withB2JsonConstructor() throws B2JsonException { + final Constructor constructor = B2JsonDeserializationUtil.findConstructor(B2JsonConstructor.class); + assertNotNull(constructor); + } + + @Test + public void findConstructor_withMultipleConstructorsNoneWithB2Json() { + final B2JsonException b2JsonException = assertThrows(B2JsonException.class, () -> B2JsonDeserializationUtil.findConstructor(MultipleNoB2JsonConstructors.class)); + assertTrue(b2JsonException.getMessage().endsWith("has multiple constructors without @B2Json.constructor")); + } + + @Test + public void findConstructor_withMultipleB2JsonConstructors() { + final B2JsonException b2JsonException = assertThrows(B2JsonException.class, () -> B2JsonDeserializationUtil.findConstructor(MultipleB2JsonConstructor.class)); + assertTrue(b2JsonException.getMessage().endsWith("has two constructors selected")); + } + + @Test + public void findConstructor_withNoB2JsonConstructors() { + final B2JsonException b2JsonException = assertThrows(B2JsonException.class, () -> B2JsonDeserializationUtil.findConstructor(NoB2JsonConstructor.class)); + assertTrue(b2JsonException.getMessage().endsWith("has no constructor annotated with B2Json.constructor")); + } + + @Test + public void findConstructor_withInterface() { + final B2JsonException b2JsonException = assertThrows(B2JsonException.class, () -> B2JsonDeserializationUtil.findConstructor(B2JsonInterface.class)); + assertTrue(b2JsonException.getMessage().endsWith("has no constructor")); + } + + @Test + public void getDiscards_noDiscards() throws B2JsonException { + final Constructor constructor = B2JsonDeserializationUtil.findConstructor(B2JsonConstructor.class); + final B2Json.B2JsonTypeConfig b2JsonTypeConfig = new B2Json.B2JsonTypeConfig(constructor.getAnnotation(B2Json.constructor.class)); + final Set discards = B2JsonDeserializationUtil.getDiscards(b2JsonTypeConfig); + assertTrue(discards.isEmpty()); + } + + @Test + public void getDiscards_hasDiscards() throws B2JsonException { + final Constructor constructor = B2JsonDeserializationUtil.findConstructor(WithDiscards.class); + final B2Json.B2JsonTypeConfig b2JsonTypeConfig = new B2Json.B2JsonTypeConfig(constructor.getAnnotation(B2Json.constructor.class)); + final Set discards = B2JsonDeserializationUtil.getDiscards(b2JsonTypeConfig); + assertEquals(new HashSet<>(Arrays.asList("extraField")), discards); + } + + + @Test + public void getDiscards_multipleDiscards() throws B2JsonException { + final Constructor constructor = B2JsonDeserializationUtil.findConstructor(WithMultipleDiscards.class); + final B2Json.B2JsonTypeConfig b2JsonTypeConfig = new B2Json.B2JsonTypeConfig(constructor.getAnnotation(B2Json.constructor.class)); + final Set discards = B2JsonDeserializationUtil.getDiscards(b2JsonTypeConfig); + assertEquals(new HashSet<>(Arrays.asList("extraField1", "extraField2")), discards); + } + + public interface B2JsonInterface { + + } + + public static class B2JsonConstructor { + + @B2Json.required + public final String name; + + @B2Json.constructor(params = "name") + public B2JsonConstructor(String name) { + this.name = name; + } + } + + public static class NoB2JsonConstructor { + + @B2Json.required + public final String name; + + public NoB2JsonConstructor(String name) { + this.name = name; + } + } + + public static class MultipleB2JsonConstructor { + + @B2Json.required + public final String name; + + @B2Json.constructor + public MultipleB2JsonConstructor() { + this("test"); + } + + @B2Json.constructor(params = "name") + public MultipleB2JsonConstructor(String name) { + this.name = name; + } + } + + public static class MultipleNoB2JsonConstructors { + + @B2Json.required + public final String name; + + + public MultipleNoB2JsonConstructors() { + this("test"); + } + + public MultipleNoB2JsonConstructors(String name) { + this.name = name; + } + } + + public static class WithDiscards { + + @B2Json.required + public final String name; + + @B2Json.constructor(discards = "extraField") + public WithDiscards(String name) { + this.name = name; + } + } + + public static class WithMultipleDiscards { + + @B2Json.required + public final String name; + + @B2Json.constructor(discards = "extraField1, extraField2") + public WithMultipleDiscards(String name) { + this.name = name; + } + } +} \ No newline at end of file diff --git a/core/src/test/java/com/backblaze/b2/json/B2JsonInferredParametersTest.java b/core/src/test/java/com/backblaze/b2/json/B2JsonInferredParametersTest.java index b10bf40eb..725592152 100644 --- a/core/src/test/java/com/backblaze/b2/json/B2JsonInferredParametersTest.java +++ b/core/src/test/java/com/backblaze/b2/json/B2JsonInferredParametersTest.java @@ -69,8 +69,18 @@ public void testDeserializeWithMismatchingParamOrder() throws B2JsonException { " \"c\": 101\n" + "}", MismatchingOrderContainer.class); + assertEquals(41, actual.a); + assertEquals("hello", actual.b); + assertEquals(101, actual.c); } + @Test + public void testSeralizedFieldName() { + String json = "{\"b\": 41}"; + final ContainerWithDifferentserializedName obj = B2Json.fromJsonOrThrowRuntime(json, ContainerWithDifferentserializedName.class); + + assertEquals(41, obj.a); + } private static class Empty { @B2Json.constructor Empty() {} @@ -178,6 +188,17 @@ public boolean equals(Object o) { } } + private static class ContainerWithDifferentserializedName { + @B2Json.required + @B2Json.serializedName(value = "b") + public int a; + + @B2Json.constructor + public ContainerWithDifferentserializedName(int a) { + this.a = a; + } + } + private static final B2Json b2Json = B2Json.get(); } diff --git a/core/src/test/java/com/backblaze/b2/json/B2JsonTest.java b/core/src/test/java/com/backblaze/b2/json/B2JsonTest.java index 234990dfd..340733003 100644 --- a/core/src/test/java/com/backblaze/b2/json/B2JsonTest.java +++ b/core/src/test/java/com/backblaze/b2/json/B2JsonTest.java @@ -14,8 +14,11 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.InputStream; import java.io.IOException; +import java.io.StringReader; import java.io.UnsupportedEncodingException; + import java.lang.reflect.Type; import java.math.BigDecimal; import java.math.BigInteger; @@ -32,14 +35,10 @@ import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicLongArray; import java.util.regex.Pattern; -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; +import static org.junit.Assert.*; /** * Unit tests for B2Json. @@ -50,6 +49,10 @@ }) public class B2JsonTest extends B2BaseTest { + private final B2JsonOptions compactOptions = B2JsonOptions.builder() + .setSerializationOption(B2JsonOptions.SerializationOption.COMPACT) + .build(); + @Rule public ExpectedException thrown = ExpectedException.none(); @@ -64,11 +67,16 @@ private static final class Container { @B2Json.ignored public int c; - @B2Json.constructor(params = "a, b") - public Container(int a, String b) { + @B2Json.optional + @B2Json.serializedName(value = "@d") + public final String d; + + @B2Json.constructor(params = "a, b, d") + public Container(int a, String b, String d) { this.a = a; this.b = b; this.c = 5; + this.d = d; } @Override @@ -153,12 +161,107 @@ public void testDuration() throws IOException, B2JsonException { public void testObject() throws B2JsonException { String json = "{\n" + + " \"@d\": \"goodbye\",\n" + " \"a\": 41,\n" + " \"b\": \"hello\"\n" + "}"; - Container obj = new Container(41, "hello"); + Container obj = new Container(41, "hello", "goodbye"); assertEquals(json, b2Json.toJson(obj)); assertEquals(obj, b2Json.fromJson(json, Container.class)); + + final String alternateJson = "{\n" + + " \"a\": 41,\n" + + " \"b\": \"hello\",\n" + + " \"\\u0040d\": \"goodbye\"\n" + + "}"; + assertEquals(obj, b2Json.fromJson(alternateJson, Container.class)); + } + + @Test + public void testFromJsonWithReader() throws B2JsonException, IOException { + final String json = + "{\n" + + " \"@d\": \"goodbye\",\n" + + " \"a\": 41,\n" + + " \"b\": \"hello\"\n" + + "}"; + final Container obj = new Container(41, "hello", "goodbye"); + assertEquals(json, b2Json.toJson(obj)); + + assertEquals(obj, b2Json.fromJson(new StringReader(json), Container.class, B2JsonOptions.DEFAULT)); + assertEquals(obj, b2Json.fromJson(new StringReader(json), Container.class)); + } + + @Test + public void testFromJsonWithInputStream() throws B2JsonException, IOException { + String json = + "{\n" + + " \"@d\": \"goodbye\",\n" + + " \"a\": 41,\n" + + " \"b\": \"hello\"\n" + + "}"; + Container obj = new Container(41, "hello", "goodbye"); + assertEquals(json, b2Json.toJson(obj)); + InputStream jsonInputStream = new ByteArrayInputStream(json.getBytes()); + + assertEquals(obj, b2Json.fromJson(jsonInputStream, Container.class)); + } + + @Test + public void testFromJsonUntilEof() throws B2JsonException, IOException { + String json = + "{\n" + + " \"@d\": \"goodbye\",\n" + + " \"a\": 41,\n" + + " \"b\": \"hello\"\n" + + "}"; + Container obj = new Container(41, "hello", "goodbye"); + + InputStream jsonInputStream = new ByteArrayInputStream(json.getBytes()); + assertEquals(obj, b2Json.fromJsonUntilEof(jsonInputStream, Container.class)); + } + + @Test + public void testFromJsonUntilEofWithCharacterAfterEof() throws B2JsonException, IOException { + String json = + "{\n" + + " \"@d\": \"goodbye\",\n" + + " \"a\": 41,\n" + + " \"b\": \"hello\"\n" + + "}}"; + Container obj = new Container(41, "hello", "goodbye"); + + InputStream jsonInputStream = new ByteArrayInputStream(json.getBytes()); + thrown.expect(B2JsonException.class); + thrown.expectMessage("non-whitespace characters after JSON value"); + b2Json.fromJsonUntilEof(jsonInputStream, Container.class); + } + + @Test + public void testFromJsonWithByteArray() throws B2JsonException, IOException { + String json = + "{\n" + + " \"@d\": \"goodbye\",\n" + + " \"a\": 41,\n" + + " \"b\": \"hello\"\n" + + "}"; + Container obj = new Container(41, "hello", "goodbye"); + + assertEquals(obj, b2Json.fromJson(json.getBytes(), Container.class)); + } + + @Test + public void testMalformedFieldName() throws B2JsonException { + String json = + "{\n" + + " \"a\": 41,\n" + + " \"b\": \"hello\",\n" + + " \"\\u04d\": \"goodbye\"\n" + + "}"; + + thrown.expect(B2JsonException.class); + thrown.expectMessage("bad hex digit: \""); + b2Json.fromJson(json, Container.class); } @Test @@ -206,14 +309,16 @@ public void testComment() throws B2JsonException { String json = "{ // this is a comment\n" + " \"a\": 41,\n" + - " \"b\": \"hello\"\n" + + " \"b\": \"hello\",\n" + + " \"@d\": \"goodbye\"\n" + "} // comment to eof"; String jsonWithoutComment = "{\n" + + " \"@d\": \"goodbye\",\n" + " \"a\": 41,\n" + " \"b\": \"hello\"\n" + "}"; - Container obj = new Container(41, "hello"); + Container obj = new Container(41, "hello", "goodbye"); assertEquals(jsonWithoutComment, b2Json.toJson(obj)); assertEquals(obj, b2Json.fromJson(json, Container.class)); } @@ -222,10 +327,11 @@ public void testComment() throws B2JsonException { public void testNoCommentInString() throws B2JsonException { String json = "{\n" + + " \"@d\": \"goodbye\",\n" + " \"a\": 41,\n" + " \"b\": \"he//o\"\n" + "}"; - Container obj = new Container(41, "he//o"); + Container obj = new Container(41, "he//o", "goodbye"); assertEquals(json, b2Json.toJson(obj)); assertEquals(obj, b2Json.fromJson(json, Container.class)); } @@ -364,6 +470,7 @@ public void testAllowUnknown() throws B2JsonException { String expectedJson = "{\n" + + " \"@d\": null,\n" + " \"a\": 41,\n" + " \"b\": \"hello\"\n" + "}"; @@ -681,7 +788,7 @@ public void testSerializationOfMapWithNullKeyGeneratesException() { MapWithNullKeyHolder mapWithNullKeyHolder = new MapWithNullKeyHolder(map); try { b2Json.toJson(mapWithNullKeyHolder); - assertTrue("Map with null key should not be allowed to be serialized", false); + fail("Map with null key should not be allowed to be serialized"); } catch (B2JsonException ex) { assertEquals("Map key is null", ex.getMessage()); } @@ -701,17 +808,17 @@ public TreeMapHolder(TreeMap treeMap) { public void testTreeMap() throws IOException, B2JsonException { String json1 = "{\n" + - " \"treeMap\": {\n" + - " \"20150101\": 37,\n" + - " \"20150207\": null\n" + - " }\n" + - "}" ; + " \"treeMap\": {\n" + + " \"20150101\": 37,\n" + + " \"20150207\": null\n" + + " }\n" + + "}" ; checkDeserializeSerialize(json1, TreeMapHolder.class); String json2 = "{\n" + - " \"treeMap\": null\n" + - "}"; + " \"treeMap\": null\n" + + "}"; checkDeserializeSerialize(json2, TreeMapHolder.class); } @@ -729,17 +836,17 @@ public SortedMapHolder(SortedMap sortedMap) { public void testSortedMap() throws IOException, B2JsonException { String json1 = "{\n" + - " \"sortedMap\": {\n" + - " \"20150101\": 37,\n" + - " \"20150207\": null\n" + - " }\n" + - "}" ; + " \"sortedMap\": {\n" + + " \"20150101\": 37,\n" + + " \"20150207\": null\n" + + " }\n" + + "}" ; checkDeserializeSerialize(json1, SortedMapHolder.class); String json2 = "{\n" + - " \"sortedMap\": null\n" + - "}"; + " \"sortedMap\": null\n" + + "}"; checkDeserializeSerialize(json2, SortedMapHolder.class); } @@ -968,8 +1075,8 @@ private FlavorHolder(Flavor flavor) { public void testUnknownEnum_usesDefaultInvalidEnumValue() throws B2JsonException { String json = "{\n" + - " \"flavor\": \"CHARTREUSE\"\n" + - "}"; + " \"flavor\": \"CHARTREUSE\"\n" + + "}"; final FlavorHolder holder = B2Json.get().fromJson(json, FlavorHolder.class); assertEquals(Flavor.STRANGE, holder.flavor); @@ -2071,7 +2178,7 @@ public void testSerializeSkipFieldNotInVersion() throws B2JsonException { public void testSerializeIncludeFieldInVersion() throws B2JsonException { final B2JsonOptions options = B2JsonOptions.builder().setVersion(5).build(); assertEquals( - "{\n" + + "{\n" + " \"x\": 3\n" + "}", b2Json.toJson(new VersionedContainer(3, 5), options) @@ -2254,6 +2361,178 @@ public void testOmitNullOnPrimitive() throws B2JsonException { B2Json.toJsonOrThrowRuntime(bad); } + /** + * + */ + private static class OmitZeroTestClass { + @B2Json.optional(omitZero = true) + private final byte omitZeroByte; + + @B2Json.optional + private final byte regularByte; + + @B2Json.optional(omitZero = true) + private final int omitZeroInt; + + @B2Json.optional + private final int regularInt; + + @B2Json.optional(omitZero = true) + private final long omitZeroLong; + + @B2Json.optional + private final long regularLong; + + @B2Json.optional(omitZero = true) + private final float omitZeroFloat; + + @B2Json.optional + private final float regularFloat; + + @B2Json.optional(omitZero = true) + private final double omitZeroDouble; + + @B2Json.optional + private final double regularDouble; + + @B2Json.constructor + private OmitZeroTestClass(byte omitZeroByte, byte regularByte, + int omitZeroInt, int regularInt, + long omitZeroLong, long regularLong, + float omitZeroFloat, float regularFloat, + double omitZeroDouble, double regularDouble) { + this.omitZeroByte = omitZeroByte; + this.regularByte = regularByte; + this.omitZeroInt = omitZeroInt; + this.regularInt = regularInt; + this.omitZeroLong = omitZeroLong; + this.regularLong = regularLong; + this.omitZeroFloat = omitZeroFloat; + this.regularFloat = regularFloat; + this.omitZeroDouble = omitZeroDouble; + this.regularDouble = regularDouble; + } + } + + @Test + public void testOmitZeroWithZeroInputs() { + final OmitZeroTestClass object = new OmitZeroTestClass( + (byte) 0, (byte) 0, + 0, 0, + 0L, 0L, + 0.0f, 0.0f, + 0.0, 0.0 + ); + final String actual = B2Json.toJsonOrThrowRuntime(object); + + // The omitNullString and omitNullInteger fields should not be present in the output + assertEquals("{\n" + + " \"regularByte\": 0,\n" + + " \"regularDouble\": 0.0,\n" + + " \"regularFloat\": 0.0,\n" + + " \"regularInt\": 0,\n" + + " \"regularLong\": 0\n" + + "}", + actual); + } + + @Test + public void testOmitZeroWithNonZeroInputs() { + final OmitZeroTestClass object = new OmitZeroTestClass( + (byte) 1, (byte) 1, + 1, 1, + 1L, 1L, + 1.1f, 1.1f, + 1.1, 1.1 + ); + final String actual = B2Json.toJsonOrThrowRuntime(object); + + // The omitNullString and omitNullInteger fields should not be present in the output + assertEquals("{\n" + + " \"omitZeroByte\": 1,\n" + + " \"omitZeroDouble\": 1.1,\n" + + " \"omitZeroFloat\": 1.1,\n" + + " \"omitZeroInt\": 1,\n" + + " \"omitZeroLong\": 1,\n" + + " \"regularByte\": 1,\n" + + " \"regularDouble\": 1.1,\n" + + " \"regularFloat\": 1.1,\n" + + " \"regularInt\": 1,\n" + + " \"regularLong\": 1\n" + + "}", + actual); + } + + @Test + public void testOmitZeroCreateFromEmpty() { + final OmitZeroTestClass actual = B2Json.fromJsonOrThrowRuntime("{}", OmitZeroTestClass.class); + + assertEquals(0, actual.omitZeroByte); + assertEquals(0, actual.regularByte); + assertEquals(0, actual.omitZeroInt); + assertEquals(0, actual.regularInt); + assertEquals(0, actual.omitZeroLong); + assertEquals(0, actual.regularLong); + assertEquals(0.0f, actual.omitZeroFloat, 0); + assertEquals(0.0f, actual.regularFloat, 0); + assertEquals(0.0, actual.omitZeroDouble, 0); + assertEquals(0.0, actual.regularDouble, 0); + } + + private static class OmitZeroBadTestCase { + @B2Json.optional(omitZero = true) + private final String string; + + @B2Json.constructor + OmitZeroBadTestCase(String string) { + this.string = string; + } + } + @Test + public void testOmitZeroOnPrimitive() throws B2JsonException { + thrown.expectMessage("Field OmitZeroBadTestCase.string declared with 'omitZero = true' but is not a primitive, numeric type"); + final OmitZeroBadTestCase bad = new OmitZeroBadTestCase("foobar"); + B2Json.toJsonOrThrowRuntime(bad); + } + + private static class OmitZeroWithOptionalWithDefaultTestClass { + @B2Json.optionalWithDefault(omitZero = true, defaultValue = "0") + public final int omitZeroDefaultToZero; + + @B2Json.optionalWithDefault(omitZero = true, defaultValue = "1") + public final int omitZeroDefaultToOne; + + @B2Json.optional + public final byte nonOmitted; + + @B2Json.constructor + private OmitZeroWithOptionalWithDefaultTestClass(int omitZeroDefaultToZero, int omitZeroDefaultToOne, byte nonOmitted) { + this.omitZeroDefaultToZero = omitZeroDefaultToZero; + this.omitZeroDefaultToOne = omitZeroDefaultToOne; + this.nonOmitted = nonOmitted; + } + } + + @Test + public void testOptionalWithDefaultWithOmitZero() { + final OmitZeroWithOptionalWithDefaultTestClass obj = B2Json.fromJsonOrThrowRuntime( + "{}", + OmitZeroWithOptionalWithDefaultTestClass.class + ); + + assertEquals(0, obj.omitZeroDefaultToZero); + assertEquals(1, obj.omitZeroDefaultToOne); + assertEquals(0, obj.nonOmitted); + + // The omitNullString and omitNullInteger fields should not be present in the output + assertEquals("{\n" + + " \"nonOmitted\": 0,\n" + + " \"omitZeroDefaultToOne\": 1\n" + + "}", + B2Json.toJsonOrThrowRuntime(obj)); + } + + /** * Because of serialization, the object returned from B2Json will never be the same object as an * instantiated one. @@ -2794,13 +3073,13 @@ public void testClassWithLotsOfFields() throws IOException, B2JsonException { /* A convenience Json object for testing IOException("Requested array size exceeds maximum limit") */ private static class ObjectWithSomeName { - @B2Json.required - private final String name; + @B2Json.required + private final String name; - @B2Json.constructor(params = "name") - public ObjectWithSomeName(String name) { - this.name = name; - } + @B2Json.constructor(params = "name") + public ObjectWithSomeName(String name) { + this.name = name; + } } @Test @@ -2897,4 +3176,155 @@ public ItemArray(T[] values) { this.values = values; } } + + private static class MapWithAtomicLongArrayHolder { + @B2Json.optional + Map map; + + @B2Json.constructor + MapWithAtomicLongArrayHolder(Map map) { + this.map = map; + } + } + + @Test + public void testMapWithAtomicLongArray() throws IOException, B2JsonException { + final String json1 = + "{\n" + + " \"map\": {\n" + + " \"20150207\": null,\n" + + " \"20230209\": [ 4, 23, 5, 3147483647 ]\n" + // max int size is 2147483647 + " }\n" + + "}"; + checkDeserializeSerialize(json1, MapWithAtomicLongArrayHolder.class); + + final String json2 = + "{\n" + + " \"map\": null\n" + + "}"; + checkDeserializeSerialize(json2, MapWithAtomicLongArrayHolder.class); + } + + /** + * the JSON serialized fieldnames "a" and "b" map to Java object fields + * "b" and "a" respectively. + */ + private static class ContainerWithCrossedFieldNames { + @B2Json.required + @B2Json.serializedName(value = "b") + private final int a; + + @B2Json.required + @B2Json.serializedName(value = "a") + private final int b; + + @B2Json.constructor + public ContainerWithCrossedFieldNames(int a, int b) { + this.a = a; + this.b = b; + } + } + + @Test + public void testCrossedFieldNamesDeserialization() { + final String json = "{\"a\": 1, \"b\": 2}"; + + final ContainerWithCrossedFieldNames obj = B2Json.fromJsonOrThrowRuntime(json, ContainerWithCrossedFieldNames.class); + + assertEquals(2, obj.a); + assertEquals(1, obj.b); + } + + @Test + public void testCrossedFieldNamesSerialization() { + + final ContainerWithCrossedFieldNames obj = new ContainerWithCrossedFieldNames(2, 1); + + final String json = B2Json.toJsonOrThrowRuntime(obj, compactOptions); + final String expectedJson = "{\"a\":1,\"b\":2}"; + + assertEquals(expectedJson, json); + } + + private static class ContainerWithHiddenIgnoredField { + @B2Json.required + @B2Json.serializedName(value = "b") + private final int a; + + @B2Json.ignored + private final int b; + + public ContainerWithHiddenIgnoredField(int a, int b) { + this.a = a; + this.b = b; + } + + @B2Json.constructor + public ContainerWithHiddenIgnoredField(int a) { + this.a = a; + this.b = 0; + } + } + + @Test + public void testHiddenIgnoredFieldDeserialization() { + final String json = "{\"b\": 1}"; + + final ContainerWithHiddenIgnoredField obj = B2Json.fromJsonOrThrowRuntime(json, ContainerWithHiddenIgnoredField.class); + + assertEquals(1, obj.a); + assertEquals(0, obj.b); + } + + @Test + public void testHiddenIgnoredFieldSerialization() { + final ContainerWithHiddenIgnoredField obj = new ContainerWithHiddenIgnoredField(1, 2); + + final String json = B2Json.toJsonOrThrowRuntime(obj, compactOptions); + final String expectedJson = "{\"b\":1}"; + + assertEquals(expectedJson, json); + } + + private static class ContainerWithDuplicateFieldNames { + @B2Json.required + final int a; + + @B2Json.required + @B2Json.serializedName(value = "a") + final int b; + + @B2Json.constructor + public ContainerWithDuplicateFieldNames(int a, int b) { + this.a = a; + this.b = b; + } + } + + @Test + public void testClassWithDuplicateFieldNames() { + final ContainerWithDuplicateFieldNames obj = new ContainerWithDuplicateFieldNames(1, 2); + + final B2JsonException thrown = assertThrows(B2JsonException.class, () -> B2Json.get().toJson(obj, compactOptions)); + assertEquals("com.backblaze.b2.json.B2JsonTest$ContainerWithDuplicateFieldNames contains multiple class fields for the json member a", thrown.getMessage()); + } + + @B2Json.type + private static class ClassWithBothB2JsonConstructorAndB2JsonTypeAnnotations { + @B2Json.required + final int a; + + @B2Json.constructor + public ClassWithBothB2JsonConstructorAndB2JsonTypeAnnotations(int a) { + this.a = a; + } + } + + @Test + public void testClassWithBothB2JsonConstructorAndB2JsonTypeAnnotations() { + final ClassWithBothB2JsonConstructorAndB2JsonTypeAnnotations obj = new ClassWithBothB2JsonConstructorAndB2JsonTypeAnnotations(1); + + final B2JsonException thrown = assertThrows(B2JsonException.class, () -> B2Json.get().toJson(obj, compactOptions)); + assertEquals("com.backblaze.b2.json.B2JsonTest$ClassWithBothB2JsonConstructorAndB2JsonTypeAnnotations has both @B2Json.type and @B2Json.constructor annotations", thrown.getMessage()); + } } diff --git a/core/src/test/java/com/backblaze/b2/json/B2JsonUnionBaseHandlerTest.java b/core/src/test/java/com/backblaze/b2/json/B2JsonUnionBaseHandlerTest.java new file mode 100644 index 000000000..2f2d21432 --- /dev/null +++ b/core/src/test/java/com/backblaze/b2/json/B2JsonUnionBaseHandlerTest.java @@ -0,0 +1,128 @@ +/* + * Copyright 2023, Backblaze Inc. All Rights Reserved. + * License https://www.backblaze.com/using_b2_code.html + */ +package com.backblaze.b2.json; + +import com.backblaze.b2.util.B2BaseTest; +import org.junit.Test; + +import java.util.Objects; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class B2JsonUnionBaseHandlerTest extends B2BaseTest { + + @Test + public void testDeserialization() throws B2JsonException { + final String json = "{\n" + + " \"type\": \"dog\",\n" + + " \"name\": \"Charlie\"\n" + + "}"; + final Dog dog = (Dog) B2Json.get().fromJson(json, Pet.class, B2JsonOptions.DEFAULT); + assertEquals(new Dog("Charlie"), dog); + } + + @Test + public void testWithExtraFieldAndAllowExtraFields() throws B2JsonException { + final String json = "{\n" + + " \"type\": \"dog\",\n" + + " \"extraField\": \"extraValue\",\n" + + " \"name\": \"Charlie\"\n" + + "}"; + final Dog dog = (Dog) B2Json.get().fromJson(json, Pet.class, B2JsonOptions.DEFAULT_AND_ALLOW_EXTRA_FIELDS); + assertEquals(new Dog("Charlie"), dog); + } + + @Test + public void testWithExtraFieldAndErrorOnExtraFields() { + final String json = "{\n" + + " \"type\": \"dog\",\n" + + " \"extraField\": \"extraValue\",\n" + + " \"name\": \"Charlie\"\n" + + "}"; + final B2JsonException b2JsonException = assertThrows(B2JsonException.class, () -> B2Json.get().fromJson(json, Pet.class, B2JsonOptions.DEFAULT)); + assertEquals("unknown field 'extraField' in union type Pet", b2JsonException.getMessage()); + } + + @Test + public void testWithDiscardedField() throws B2JsonException { + final String json = "{\n" + + " \"type\": \"cat\",\n" + + " \"breed\": \"siamese\",\n" + + " \"name\": \"Charlie\"\n" + + "}"; + final Cat cat = (Cat) B2Json.get().fromJson(json, Pet.class, B2JsonOptions.DEFAULT); + assertEquals(new Cat("Charlie"), cat); + } + + @B2Json.union(typeField = "type") + public static abstract class Pet { + + @SuppressWarnings("unused") + public static B2JsonUnionTypeMap getUnionTypeMap() throws B2JsonException { + return B2JsonUnionTypeMap + .builder() + .put("dog", Dog.class) + .put("cat", Cat.class) + .build(); + } + } + + public static class Dog extends Pet { + + @B2Json.required + public final String name; + + @B2Json.constructor(params = "name") + public Dog(String name) { + this.name = name; + } + + @Override + public String toString() { + return "Dog{" + + "name='" + name + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Dog)) return false; + Dog dog = (Dog) o; + return Objects.equals(name, dog.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + } + + public static class Cat extends Pet { + + @B2Json.required + public final String name; + + @B2Json.constructor(params = "name", discards = "breed") + public Cat(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Cat)) return false; + Cat cat = (Cat) o; + return Objects.equals(name, cat.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + } + +} diff --git a/gradle.properties b/gradle.properties index d4a1493db..cde877735 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Copyright 2022, Backblaze Inc. All Rights Reserved. # License https://www.backblaze.com/using_b2_code.html -version=6.2.0-SNAPSHOT +version=6.2.0-PRIVATE group=com.backblaze.b2 diff --git a/settings.gradle.kts b/settings.gradle.kts index 09d873394..217410dc4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,7 +3,7 @@ rootProject.name = "b2-sdk-java" -val projects = listOf("core", "httpclient", "samples") +val projects = listOf("core", "httpclient", "samples", "core-test-jdk17") for (proj in projects) { include(proj) findProject(":$proj")?.name = "b2-sdk-$proj"