feature/split-up-postalAddress (#118)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #118
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig 2024-11-06 12:24:37 +01:00
parent 63af33d003
commit 6191bf16e0
19 changed files with 207 additions and 89 deletions

View File

@ -54,8 +54,14 @@ public class HsOfficeContact implements Stringifyable, BaseEntity<HsOfficeContac
@Column(name = "caption")
private String caption;
@Builder.Default
@Setter(AccessLevel.NONE)
@Type(JsonType.class)
@Column(name = "postaladdress")
private String postalAddress; // multiline free-format text
private Map<String, String> postalAddress = new HashMap<>();
@Transient
private PatchableMapWrapper<String> postalAddressWrapper;
@Builder.Default
@Setter(AccessLevel.NONE)
@ -75,6 +81,17 @@ public class HsOfficeContact implements Stringifyable, BaseEntity<HsOfficeContac
@Transient
private PatchableMapWrapper<String> phoneNumbersWrapper;
public PatchableMapWrapper<String> getPostalAddress() {
return PatchableMapWrapper.of(
postalAddressWrapper,
(newWrapper) -> {postalAddressWrapper = newWrapper;},
postalAddress);
}
public void putPostalAddress(Map<String, String> newPostalAddress) {
getPostalAddress().assign(newPostalAddress);
}
public PatchableMapWrapper<String> getEmailAddresses() {
return PatchableMapWrapper.of(
emailAddressesWrapper,

View File

@ -18,7 +18,8 @@ class HsOfficeContactEntityPatcher implements EntityPatcher<HsOfficeContactPatch
@Override
public void apply(final HsOfficeContactPatchResource resource) {
OptionalFromJson.of(resource.getCaption()).ifPresent(entity::setCaption);
OptionalFromJson.of(resource.getPostalAddress()).ifPresent(entity::setPostalAddress);
Optional.ofNullable(resource.getPostalAddress())
.ifPresent(r -> entity.getPostalAddress().patch(KeyValueMap.from(resource.getPostalAddress())));
Optional.ofNullable(resource.getEmailAddresses())
.ifPresent(r -> entity.getEmailAddresses().patch(KeyValueMap.from(resource.getEmailAddresses())));
Optional.ofNullable(resource.getPhoneNumbers())

View File

@ -47,7 +47,7 @@ public interface HsOfficeRelationRbacRepository extends Repository<HsOfficeRelat
OR lower(rel.anchor.givenName) LIKE :personData OR lower(rel.holder.givenName) LIKE :personData )
AND ( :contactData IS NULL
OR lower(rel.contact.caption) LIKE :contactData
OR lower(rel.contact.postalAddress) LIKE :contactData
OR lower(CAST(rel.contact.postalAddress AS String)) LIKE :contactData
OR lower(CAST(rel.contact.emailAddresses AS String)) LIKE :contactData
OR lower(CAST(rel.contact.phoneNumbers AS String)) LIKE :contactData )
""")

View File

@ -12,7 +12,7 @@ components:
caption:
type: string
postalAddress:
type: string
$ref: '#/components/schemas/HsOfficeContactPostalAddress'
emailAddresses:
$ref: '#/components/schemas/HsOfficeContactEmailAddresses'
phoneNumbers:
@ -24,7 +24,7 @@ components:
caption:
type: string
postalAddress:
type: string
$ref: '#/components/schemas/HsOfficeContactPostalAddress'
emailAddresses:
$ref: '#/components/schemas/HsOfficeContactEmailAddresses'
phoneNumbers:
@ -39,21 +39,48 @@ components:
type: string
nullable: true
postalAddress:
type: string
nullable: true
$ref: '#/components/schemas/HsOfficeContactPostalAddress'
emailAddresses:
$ref: '#/components/schemas/HsOfficeContactEmailAddresses'
phoneNumbers:
$ref: '#/components/schemas/HsOfficeContactPhoneNumbers'
HsOfficeContactPostalAddress:
# forces generating a java.lang.Object containing a Map, instead of a class with fixed properties
anyOf:
- type: object
properties:
firm:
type: string
nullable: true
name:
type: string
nullable: true
co:
type: string
nullable: true
street:
type: string
nullable: true
zipcode:
type: string
nullable: true
city:
type: string
nullable: true
country:
type: string
nullable: true
additionalProperties: true
HsOfficeContactEmailAddresses:
# forces generating a java.lang.Object containing a Map, instead of class HsOfficeContactEmailAddresses
# forces generating a java.lang.Object containing a Map, instead of a class with fixed properties
anyOf:
- type: object
additionalProperties: true
HsOfficeContactPhoneNumbers:
# forces generating a java.lang.Object containing a Map, instead of class HsOfficeContactEmailAddresses
# forces generating a java.lang.Object containing a Map, instead of a class with fixed properties
anyOf:
- type: object
properties:

View File

@ -9,7 +9,7 @@ create table if not exists hs_office.contact
uuid uuid unique references rbac.object (uuid) initially deferred,
version int not null default 0,
caption varchar(128) not null,
postalAddress text,
postalAddress jsonb not null,
emailAddresses jsonb not null,
phoneNumbers jsonb not null
);

View File

@ -11,7 +11,6 @@
create or replace procedure hs_office.contact_create_test_data(contCaption varchar)
language plpgsql as $$
declare
postalAddr varchar;
emailAddr varchar;
begin
emailAddr = 'contact-admin@' || base.cleanIdentifier(contCaption) || '.example.com';
@ -19,14 +18,18 @@ begin
perform rbac.create_subject(emailAddr);
call base.defineContext('creating contact test-data', null, emailAddr);
postalAddr := E'Vorname Nachname\nStraße Hnr\nPLZ Stadt';
raise notice 'creating test contact: %', contCaption;
insert
into hs_office.contact (caption, postaladdress, emailaddresses, phonenumbers)
values (
contCaption,
postalAddr,
( '{ ' ||
-- '"name": "' || contCaption || '",' ||
-- '"street": "Somewhere 1",' ||
-- '"zipcode": "12345",' ||
-- '"city": "Where-Ever",' ||
'"country": "Germany"' ||
'}')::jsonb,
('{ "main": "' || emailAddr || '" }')::jsonb,
('{ "phone_office": "+49 123 1234567" }')::jsonb
);

View File

@ -110,7 +110,6 @@ public class HsHostingAssetControllerRestTest {
"caption": "some fake cloud-server",
"alarmContact": {
"caption": "some contact",
"postalAddress": "address of some contact",
"emailAddresses": {
"main": "some-contact@example.com"
}
@ -141,7 +140,6 @@ public class HsHostingAssetControllerRestTest {
"caption": "some fake managed-server",
"alarmContact": {
"caption": "some contact",
"postalAddress": "address of some contact",
"emailAddresses": {
"main": "some-contact@example.com"
}

View File

@ -1111,7 +1111,7 @@ public abstract class BaseOfficeDataImport extends CsvDataImport {
contactRecord.getString("last_name"),
contactRecord.getString("firma")));
contact.putEmailAddresses(Map.of("main", contactRecord.getString("email")));
contact.setPostalAddress(toAddress(contactRecord));
contact.putPostalAddress(toAddress(contactRecord));
contact.putPhoneNumbers(toPhoneNumbers(contactRecord));
contacts.put(contactRecord.getInteger("contact_id"), contact);
@ -1131,36 +1131,23 @@ public abstract class BaseOfficeDataImport extends CsvDataImport {
return phoneNumbers;
}
private String toAddress(final Record rec) {
final var result = new StringBuilder();
private Map<String, String> toAddress(final Record rec) {
final var result = new LinkedHashMap<String, String>();
final var name = toName(
rec.getString("salut"),
rec.getString("title"),
rec.getString("first_name"),
rec.getString("last_name"));
if (isNotBlank(name))
result.append(name + "\n");
result.put("name", name);
if (isNotBlank(rec.getString("firma")))
result.append(rec.getString("firma") + "\n");
if (isNotBlank(rec.getString("co")))
result.append("c/o " + rec.getString("co") + "\n");
if (isNotBlank(rec.getString("street")))
result.append(rec.getString("street") + "\n");
final var zipcodeAndCity = toZipcodeAndCity(rec);
if (isNotBlank(zipcodeAndCity))
result.append(zipcodeAndCity + "\n");
return result.toString();
}
result.put("firm", name);
private String toZipcodeAndCity(final Record rec) {
final var result = new StringBuilder();
if (isNotBlank(rec.getString("country")))
result.append(rec.getString("country") + " ");
if (isNotBlank(rec.getString("zipcode")))
result.append(rec.getString("zipcode") + " ");
if (isNotBlank(rec.getString("city")))
result.append(rec.getString("city"));
return result.toString();
List.of("co", "street", "zipcode", "city", "country").forEach(key -> {
if (isNotBlank(rec.getString(key)))
result.put(key, rec.getString(key));
});
return result;
}
private String toCaption(

View File

@ -21,10 +21,13 @@ import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import static java.util.Map.entry;
import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid;
import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.hasEntry;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
@ -214,7 +217,11 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
"emailAddresses": {
"main": "patched@example.org"
},
"postalAddress": "Patched Address",
"postalAddress": {
"extra": "Extra Property",
"co": "P. Patcher",
"street": "Patchstraße 5"
},
"phoneNumbers": {
"phone_office": "+01 100 123456"
}
@ -229,7 +236,10 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.body("uuid", isUuidValid())
.body("caption", is("Temp patched contact"))
.body("emailAddresses", is(Map.of("main", "patched@example.org")))
.body("postalAddress", is("Patched Address"))
.body("postalAddress", hasEntry("name", givenContact.getPostalAddress().get("name"))) // unchanged
.body("postalAddress", hasEntry("extra", "Extra Property")) // unchanged
.body("postalAddress", hasEntry("co", "P. Patcher")) // patched
.body("postalAddress", hasEntry("street", "Patchstraße 5")) // patched
.body("phoneNumbers", is(Map.of("phone_office", "+01 100 123456")));
// @formatter:on
@ -239,7 +249,11 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.matches(person -> {
assertThat(person.getCaption()).isEqualTo("Temp patched contact");
assertThat(person.getEmailAddresses()).containsExactlyEntriesOf(Map.of("main", "patched@example.org"));
assertThat(person.getPostalAddress()).isEqualTo("Patched Address");
assertThat(person.getPostalAddress()).containsAllEntriesOf(Map.ofEntries(
entry("name", givenContact.getPostalAddress().get("name")),
entry("co", "P. Patcher"),
entry("street", "Patchstraße 5")
));
assertThat(person.getPhoneNumbers()).containsExactlyEntriesOf(Map.of("phone_office", "+01 100 123456"));
return true;
});
@ -274,7 +288,6 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.body("uuid", isUuidValid())
.body("caption", is(givenContact.getCaption()))
.body("emailAddresses", is(Map.of("main", "patched@example.org")))
.body("postalAddress", is(givenContact.getPostalAddress()))
.body("phoneNumbers", is(Map.of("phone_office", "+01 100 123456")));
// @formatter:on
@ -283,12 +296,11 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
.matches(person -> {
assertThat(person.getCaption()).isEqualTo(givenContact.getCaption());
assertThat(person.getEmailAddresses()).containsExactlyEntriesOf(Map.of("main", "patched@example.org"));
assertThat(person.getPostalAddress()).isEqualTo(givenContact.getPostalAddress());
assertThat(person.getPostalAddress()).containsExactlyInAnyOrderEntriesOf(givenContact.getPostalAddress());
assertThat(person.getPhoneNumbers()).containsExactlyEntriesOf(Map.of("phone_office", "+01 100 123456"));
return true;
});
}
}
@Nested
@ -361,8 +373,13 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
final var newContact = HsOfficeContactRbacEntity.builder()
.uuid(UUID.randomUUID())
.caption("Temp from " + Context.getCallerMethodNameFromStackFrame(1) )
.postalAddress(Map.ofEntries(
entry("name", RandomStringUtils.randomAlphabetic(6) + " " + RandomStringUtils.randomAlphabetic(10)),
entry("street", RandomStringUtils.randomAlphabetic(10) + randomInt(1, 99)),
entry("zipcode", "D-" + randomInt(10000, 99999)),
entry("city", RandomStringUtils.randomAlphabetic(10))
))
.emailAddresses(Map.of("main", RandomStringUtils.randomAlphabetic(10) + "@example.org"))
.postalAddress("Postal Address " + RandomStringUtils.randomAlphabetic(10))
.phoneNumbers(Map.of("phone_office", "+01 200 " + RandomStringUtils.randomNumeric(8)))
.build();
@ -378,4 +395,8 @@ class HsOfficeContactControllerAcceptanceTest extends ContextBasedTestWithCleanu
em.createQuery("DELETE FROM HsOfficeContactRbacEntity c WHERE c.caption LIKE 'Temp %'").executeUpdate();
}).assertSuccessful();
}
private int randomInt(final int min, final int max) {
return ThreadLocalRandom.current().nextInt(min, max);
}
}

View File

@ -19,6 +19,19 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
> {
private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID();
private static final Map<String, String> PATCH_POSTAL_ADDRESS = patchMap(
entry("name", "Patty Patch"),
entry("street", "Patchstreet 10"),
entry("zipcode", null),
entry("city", "Hamburg")
);
private static final Map<String, String> PATCHED_POSTAL_ADDRESS = patchMap(
entry("name", "Patty Patch"),
entry("street", "Patchstreet 10"),
entry("city", "Hamburg")
);
private static final Map<String, String> PATCH_EMAIL_ADDRESSES = patchMap(
entry("main", "patched@example.com"),
entry("paul", null),
@ -46,6 +59,11 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
final var entity = new HsOfficeContactRbacEntity();
entity.setUuid(INITIAL_CONTACT_UUID);
entity.setCaption("initial caption");
entity.putPostalAddress(Map.ofEntries(
entry("name", "Ina Initial"),
entry("street", "Initialstraße 50"),
entry("zipcode", "20000"),
entry("city", "Hamburg")));
entity.putEmailAddresses(Map.ofEntries(
entry("main", "initial@example.org"),
entry("paul", "paul@example.com"),
@ -54,7 +72,6 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
entry("phone_office", "+49 40 12345-00"),
entry("phone_mobile", "+49 1555 1234567"),
entry("fax", "+49 40 12345-90")));
entity.setPostalAddress("Initialstraße 50\n20000 Hamburg");
return entity;
}
@ -77,24 +94,26 @@ class HsOfficeContactPatcherUnitTest extends PatchUnitTestBase<
"patched caption",
HsOfficeContactRbacEntity::setCaption),
new SimpleProperty<>(
"resources",
"postalAddress",
HsOfficeContactPatchResource::setPostalAddress,
PATCH_POSTAL_ADDRESS,
HsOfficeContactRbacEntity::putPostalAddress,
PATCHED_POSTAL_ADDRESS)
.notNullable(),
new SimpleProperty<>(
"emailAddresses",
HsOfficeContactPatchResource::setEmailAddresses,
PATCH_EMAIL_ADDRESSES,
HsOfficeContactRbacEntity::putEmailAddresses,
PATCHED_EMAIL_ADDRESSES)
.notNullable(),
new SimpleProperty<>(
"resources",
"phoneNumbers",
HsOfficeContactPatchResource::setPhoneNumbers,
PATCH_PHONE_NUMBERS,
HsOfficeContactRbacEntity::putPhoneNumbers,
PATCHED_PHONE_NUMBERS)
.notNullable(),
new JsonNullableProperty<>(
"patched given name",
HsOfficeContactPatchResource::setPostalAddress,
"patched given name",
HsOfficeContactRbacEntity::setPostalAddress)
.notNullable()
);
}
}

View File

@ -2,6 +2,8 @@ package net.hostsharing.hsadminng.hs.office.contact;
import java.util.Map;
import static java.util.Map.entry;
public class HsOfficeContactRbacTestEntity {
public static final HsOfficeContactRbacEntity TEST_RBAC_CONTACT = hsOfficeContact("some contact", "some-contact@example.com");
@ -9,7 +11,12 @@ public class HsOfficeContactRbacTestEntity {
static public HsOfficeContactRbacEntity hsOfficeContact(final String caption, final String emailAddr) {
return HsOfficeContactRbacEntity.builder()
.caption(caption)
.postalAddress("address of " + caption)
.postalAddress(Map.ofEntries(
entry("name", "M. Meyer"),
entry("street", "Teststraße 11"),
entry("zipcode", "D-12345"),
entry("city", "Berlin")
))
.emailAddresses(Map.of("main", emailAddr))
.build();
}

View File

@ -2,6 +2,8 @@ package net.hostsharing.hsadminng.hs.office.contact;
import java.util.Map;
import static java.util.Map.entry;
public class HsOfficeContactRealTestEntity {
public static final HsOfficeContactRealEntity TEST_REAL_CONTACT = hsOfficeContact("some contact", "some-contact@example.com");
@ -9,7 +11,12 @@ public class HsOfficeContactRealTestEntity {
static public HsOfficeContactRealEntity hsOfficeContact(final String caption, final String emailAddr) {
return HsOfficeContactRealEntity.builder()
.caption(caption)
.postalAddress("address of " + caption)
.postalAddress(Map.ofEntries(
entry("name", "M. Meyer"),
entry("street", "Teststraße 11"),
entry("zipcode", "D-12345"),
entry("city", "Berlin")
))
.emailAddresses(Map.of("main", emailAddr))
.build();
}

View File

@ -460,10 +460,13 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
"type": "DEBITOR",
"contact": {
"caption": "first contact",
"postalAddress": "Vorname Nachname\\nStraße Hnr\\nPLZ Stadt",
"postalAddress": {
"country": "Germany"
},
"emailAddresses": { "main": "contact-admin@firstcontact.example.com" },
"phoneNumbers": { "phone_office": "+49 123 1234567" }
}
}
},
"debitorNumber": 1000111,
"debitorNumberSuffix": "11",
@ -476,10 +479,11 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu
"mark": null,
"contact": {
"caption": "first contact",
"postalAddress": "Vorname Nachname\\nStraße Hnr\\nPLZ Stadt",
"postalAddress": {
"country": "Germany"
},
"emailAddresses": { "main": "contact-admin@firstcontact.example.com" },
"phoneNumbers": { "phone_office": "+49 123 1234567" }
}
},
"details": {
"registrationOffice": "Hamburg",

View File

@ -199,7 +199,9 @@ class HsOfficeRelationControllerAcceptanceTest extends ContextBasedTestWithClean
"type": "REPRESENTATIVE",
"contact": {
"caption": "first contact",
"postalAddress": "Vorname Nachname\\nStraße Hnr\\nPLZ Stadt",
"postalAddress": {
"country": "Germany"
},
"emailAddresses": {
"main": "contact-admin@firstcontact.example.com"
},

View File

@ -55,8 +55,11 @@ class HsOfficeScenarioTests extends ScenarioTest {
.given("tradeName", "Test AG")
.given("contactCaption", "Test AG - Hamburg")
.given("postalAddress", """
Shanghai-Allee 1
20123 Hamburg
"firm": "Test AG",
"street": "Shanghai-Allee 1",
"zipcode": "20123",
"city": "Hamburg",
"country": "Germany"
""")
.given("officePhoneNumber", "+49 40 654321-0")
.given("emailAddress", "hamburg@test-ag.example.org")
@ -75,8 +78,11 @@ class HsOfficeScenarioTests extends ScenarioTest {
.given("familyName", "Matthieu")
.given("contactCaption", "Michelle Matthieu")
.given("postalAddress", """
An der Wandse 34
22123 Hamburg
"name": "Michelle Matthieu",
"street": "An der Wandse 34",
"zipcode": "22123",
"city": "Hamburg",
"country": "Germany"
""")
.given("officePhoneNumber", "+49 40 123456")
.given("emailAddress", "michelle.matthieu@example.org")
@ -94,8 +100,11 @@ class HsOfficeScenarioTests extends ScenarioTest {
.given("representativeFamilyName", "Trust")
.given("representativeGivenName", "Tracy")
.given("representativePostalAddress", """
An der Alster 100
20000 Hamburg
"name": "Michelle Matthieu",
"street": "An der Alster 100",
"zipcode": "20000",
"city": "Hamburg",
"country": "Germany"
""")
.given("representativePhoneNumber", "+49 40 123456")
.given("representativeEMailAddress", "tracy.trust@example.org")
@ -172,12 +181,18 @@ class HsOfficeScenarioTests extends ScenarioTest {
void shouldReplaceContactData() {
new ReplaceContactData(this)
.given("partnerName", "Test AG")
.given("newContactCaption", "Test AG - Norden")
.given("newContactCaption", "Test AG - China")
.given("newPostalAddress", """
Am Hafen 11
26506 Norden
"firm": "Test AG",
"name": "Fi Zhong-Kha",
"building": "Thi Chi Koh Building",
"street": "No.2 Commercial Second Street",
"district": "Niushan Wei Wu",
"city": "Dongguan City",
"province": "Guangdong Province",
"country": "China"
""")
.given("newOfficePhoneNumber", "+49 4931 654321-0")
.given("newOfficePhoneNumber", "++15 999 654321" )
.given("newEmailAddress", "norden@test-ag.example.org")
.doRun();
}

View File

@ -27,7 +27,9 @@ public class AmendContactData extends UseCase<AmendContactData> {
httpPatch("/api/hs/office/contacts/%{partnerContactUuid}", usingJsonBody("""
{
"caption": ${newContactCaption???},
"postalAddress": ${newPostalAddress???},
"postalAddress": {
%{newPostalAddress???}
},
"phoneNumbers": {
"office": ${newOfficePhoneNumber???}
},

View File

@ -27,7 +27,9 @@ public class ReplaceContactData extends UseCase<ReplaceContactData> {
httpPost("/api/hs/office/contacts", usingJsonBody("""
{
"caption": ${newContactCaption},
"postalAddress": ${newPostalAddress???},
"postalAddress": {
%{newPostalAddress???}
},
"phoneNumbers": {
"phone": ${newOfficePhoneNumber???}
},
@ -37,8 +39,10 @@ public class ReplaceContactData extends UseCase<ReplaceContactData> {
}
"""))
.expecting(CREATED).expecting(JSON),
"Please check first if that contact already exists, if so, use it's UUID below."
);
"Please check first if that contact already exists, if so, use it's UUID below.",
"If any `postalAddress` sub-properties besides those specified in the API " +
"(currently `firm`, `name`, `co`, `street`, `zipcode`, `city`, `country`) " +
"its values might not appear in external systems.");
withTitle("Replace the Contact-Reference in the Partner-Relation", () ->
httpPatch("/api/hs/office/relations/%{partnerRelationUuid}", usingJsonBody("""

View File

@ -42,7 +42,9 @@ public class AddRepresentativeToPartner extends UseCase<AddRepresentativeToPartn
httpPost("/api/hs/office/contacts", usingJsonBody("""
{
"caption": "%{representativeGivenName} %{representativeFamilyName}",
"postalAddress": ${representativePostalAddress},
"postalAddress": {
%{representativePostalAddress}
},
"phoneNumbers": {
"main": ${representativePhoneNumber}
},

View File

@ -44,7 +44,9 @@ public class CreatePartner extends UseCase<CreatePartner> {
httpPost("/api/hs/office/contacts", usingJsonBody("""
{
"caption": ${contactCaption},
"postalAddress": ${postalAddress???},
"postalAddress": {
%{postalAddress???}
},
"phoneNumbers": {
"office": ${officePhoneNumber???}
},