feat(account): push public profile flag and sections to Core
Build, Test & Push Auth Service / quality-check (push) Successful in 1m4s
Build, Test & Push Auth Service / docker (push) Successful in 1m23s

This commit is contained in:
lobo committed 2026-10-06 16:22:22 +02:00
1 parent d924c09953
commit d842d9e865
16 files changed
+234 -33

No files matched your search

+7 -4
View File
@@ -397,8 +397,11 @@ sequenceDiagram
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit
(`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly (`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly
cleanup sends every user. The state carries `listed` (active, public profile, opt-in and a display name: `ListedUser`), cleanup sends every user. A change of the profile tiles sends it too. The state carries `listed` (active, public profile, opt-in and a display
which Core uses for its owner lists. The call runs on the task executor, so neither the answer nor a database name: `ListedUser`), which Core uses for its owner lists, `publicProfile` (active, public profile and a display name:
`PublicUser`) and `profileSections` (the profile parts the user shows, by the names of the profile ticket:
`ProfileSections`), which Core uses to let others follow the user and to leave out in the follower note what the profile
hides (#27). The call runs on the task executor, so neither the answer nor a database
connection waits for Core; a failed call is tried again after 10 s, 1 min and 5 min (in memory), then it only warns. connection waits for Core; a failed call is tried again after 10 s, 1 min and 5 min (in memory), then it only warns.
```mermaid ```mermaid
@@ -406,8 +409,8 @@ sequenceDiagram
participant A as Auth participant A as Auth
participant C as Core participant C as Core
A->>A: link / unlink / block / unblock / name change (commit) A->>A: link / unlink / block / unblock / name change (commit)
A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed} A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}
C->>C: blocked_user, listed_user, account_role_change, Steam ID in user_steam_sync, display name in account_name C->>C: blocked_user, listed_user, public_user (+ sections), account_role_change, Steam ID in user_steam_sync, display name in account_name
Note over A,C: nightly 04:15 Note over A,C: nightly 04:15
A->>C: PUT /internal/accounts/states (every user) A->>C: PUT /internal/accounts/states (every user)
``` ```
@@ -1,13 +1,17 @@
package auth_service.modules.account.model; package auth_service.modules.account.model;
import java.time.Instant; import java.time.Instant;
import java.util.List;
/** /**
* One user as Core copies it. * One user as Core copies it.
* *
* @param roleChangedAt null when the role never changed on the admin page * @param roleChangedAt null when the role never changed on the admin page
* @param listed shown by name in Core's owner lists, see {@link auth_service.shared.account.ListedUser} * @param listed shown by name in Core's owner lists, see {@link auth_service.shared.account.ListedUser}
* @param publicProfile may be followed in Core, see {@link auth_service.shared.account.PublicUser}
* @param profileSections the profile parts the user shows ({@link auth_service.shared.account.ProfileSections}),
* null without a settings row
*/ */
public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt, public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
boolean listed) { boolean listed, boolean publicProfile, List<String> profileSections) {
} }
@@ -5,6 +5,8 @@ import auth_service.shared.account.ActiveAdmins;
import auth_service.shared.account.ActiveUser; import auth_service.shared.account.ActiveUser;
import auth_service.shared.account.EmailAddress; import auth_service.shared.account.EmailAddress;
import auth_service.shared.account.ListedUser; import auth_service.shared.account.ListedUser;
import auth_service.shared.account.ProfileSections;
import auth_service.shared.account.PublicUser;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
@@ -12,9 +14,12 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.sql.Array;
import java.sql.SQLException;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -115,10 +120,21 @@ public class AccountRepository {
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"), new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"),
!rs.getBoolean("enabled"), !rs.getBoolean("enabled"),
rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant(), rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant(),
rs.getBoolean("listed")); rs.getBoolean("listed"), rs.getBoolean("public_profile"), sections(rs.getArray("profile_tiles")));
// Stored as "ID:SIZE", see PublicProfileRepository.tile.
private static List<String> sections(Array storedTiles) throws SQLException {
if (storedTiles == null) {
return null;
}
return ProfileSections.of(Arrays.stream((String[]) storedTiles.getArray())
.map(stored -> stored.replaceFirst(":[^:]*$", ""))
.toList());
}
private static final String STATE_SELECT = "SELECT u.id, u.steam_id, u.display_name, u.enabled, u.role_changed_at, " private static final String STATE_SELECT = "SELECT u.id, u.steam_id, u.display_name, u.enabled, u.role_changed_at, "
+ "COALESCE(" + ListedUser.of("u", "s") + ", FALSE) AS listed " + "COALESCE(" + ListedUser.of("u", "s") + ", FALSE) AS listed, "
+ "COALESCE(" + PublicUser.of("u", "s") + ", FALSE) AS public_profile, s.profile_tiles "
+ "FROM users u LEFT JOIN user_settings s ON s.user_id = u.id"; + "FROM users u LEFT JOIN user_settings s ON s.user_id = u.id";
public Optional<CoreAccountState> findState(long userId) { public Optional<CoreAccountState> findState(long userId) {
@@ -2,16 +2,14 @@ package auth_service.modules.profile.service;
import auth_service.modules.profile.model.PublicProfile; import auth_service.modules.profile.model.PublicProfile;
import auth_service.modules.profile.repo.PublicProfileRepository; import auth_service.modules.profile.repo.PublicProfileRepository;
import auth_service.shared.account.ProfileSections;
import auth_service.shared.security.ProfileTicketIssuer; import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.Public; import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@Service @Service
@Public @Public
@@ -20,8 +18,6 @@ public class PublicProfileService {
public record TicketedProfile(PublicProfile profile, String ticket) {} public record TicketedProfile(PublicProfile profile, String ticket) {}
private static final String STATS_PREFIX = "STATS_";
private final PublicProfileRepository publicProfileRepository; private final PublicProfileRepository publicProfileRepository;
private final ProfileTicketIssuer profileTicketIssuer; private final ProfileTicketIssuer profileTicketIssuer;
@@ -33,15 +29,9 @@ public class PublicProfileService {
.map(profile -> new TicketedProfile(profile, ticket(profile))); .map(profile -> new TicketedProfile(profile, ticket(profile)));
} }
// Core knows sections and stat groups, not tiles: every STATS_ tile is one shared group of the STATS section.
private String ticket(PublicProfile profile) { private String ticket(PublicProfile profile) {
Map<Boolean, List<String>> byStats = profile.tiles().stream().map(PublicProfile.Tile::id) List<String> tileIds = profile.tiles().stream().map(PublicProfile.Tile::id).toList();
.collect(Collectors.partitioningBy(id -> id.startsWith(STATS_PREFIX))); return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(),
List<String> statGroups = byStats.get(true).stream().map(id -> id.substring(STATS_PREFIX.length())).toList(); ProfileSections.statGroups(tileIds), ProfileSections.of(tileIds));
List<String> sections = new ArrayList<>(byStats.get(false));
if (!statGroups.isEmpty()) {
sections.add("STATS");
}
return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(), statGroups, sections);
} }
} }
@@ -130,6 +130,7 @@ public class UserService {
@CacheEvict(value = "users", key = "#userId") @CacheEvict(value = "users", key = "#userId")
public void updateProfileTiles(long userId, List<Tile> tiles) { public void updateProfileTiles(long userId, List<Tile> tiles) {
userRepository.update(userId, user -> user.getSettings().setProfileTiles(Tile.distinct(tiles))); userRepository.update(userId, user -> user.getSettings().setProfileTiles(Tile.distinct(tiles)));
accountStatePublisher.publish(userId);
} }
/** /**
@@ -10,7 +10,6 @@ public final class ListedUser {
} }
public static String of(String user, String settings) { public static String of(String user, String settings) {
return ActiveUser.of(user) + " AND " + settings + ".public_profile AND " + settings + ".show_in_owner_lists AND " return PublicUser.of(user, settings) + " AND " + settings + ".show_in_owner_lists";
+ user + ".display_name IS NOT NULL";
} }
} }
@@ -0,0 +1,40 @@
package auth_service.shared.account;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* The profile parts a user shows, by the names of the profile ticket. Core knows sections, not tiles: every
* {@code STATS_} tile is one group of the single STATS section.
*/
public final class ProfileSections {
private static final String STATS_PREFIX = "STATS_";
private static final String STATS = "STATS";
// The names Core stores; a tile id of any other kind is a user's own choice and says nothing to Core.
private static final Set<String> KNOWN = Set.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", STATS);
private ProfileSections() {
}
/**
* @param tileIds the tile ids without their size
*/
public static List<String> of(List<String> tileIds) {
List<String> sections = new ArrayList<>(tileIds.stream().filter(KNOWN::contains).toList());
if (tileIds.stream().anyMatch(id -> id.startsWith(STATS_PREFIX)) && !sections.contains(STATS)) {
sections.add(STATS);
}
return sections;
}
/**
* @param tileIds the tile ids without their size
* @return the stat groups of Core, one per {@code STATS_} tile
*/
public static List<String> statGroups(List<String> tileIds) {
return tileIds.stream().filter(id -> id.startsWith(STATS_PREFIX)).map(id -> id.substring(STATS_PREFIX.length()))
.toList();
}
}
@@ -0,0 +1,16 @@
package auth_service.shared.account;
/**
* The rule Core uses to allow follows (#27): active, public profile on, with a display name. It matches
* {@code PublicProfileRepository.findPublicByDisplayName}. The settings alias may come from a LEFT JOIN: no settings
* row is not public.
*/
public final class PublicUser {
private PublicUser() {
}
public static String of(String user, String settings) {
return ActiveUser.of(user) + " AND " + settings + ".public_profile AND " + user + ".display_name IS NOT NULL";
}
}
@@ -56,9 +56,11 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() { void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() {
jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice); jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice);
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false)); assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false, false,
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false), List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")));
new CoreAccountState(bob, null, null, false, null, false)); assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false, false,
List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")),
new CoreAccountState(bob, null, null, false, null, false, false, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")));
assertThat(repository.findState(-1L)).isEmpty(); assertThat(repository.findState(-1L)).isEmpty();
} }
@@ -272,4 +274,48 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
assertThat(activeTokens(alice)).isZero(); assertThat(activeTokens(alice)).isZero();
assertThat(activeTokens(bob)).isEqualTo(2); assertThat(activeTokens(bob)).isEqualTo(2);
} }
@Test
void findStates_PublicProfileOnlyForAnActivePublicUserWithAName_NoOptInNeeded() {
long open = insertUser("open@example.com");
jdbc.update("update users set display_name = 'Open' where id = ?", open);
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = false where user_id = ?", open);
assertThat(repository.findState(open)).get().extracting(CoreAccountState::publicProfile).isEqualTo(true);
String[] breakers = {"update users set enabled = false where id = ?",
"update users set deleted_at = now() where id = ?",
"update users set status = 'PENDING' where id = ?",
"update users set display_name = null where id = ?",
"update user_settings set public_profile = false where user_id = ?"};
for (String breaker : breakers) {
jdbc.update("update users set enabled = true, deleted_at = null, status = 'ACTIVE', display_name = 'Open' where id = ?", open);
jdbc.update("update user_settings set public_profile = true where user_id = ?", open);
jdbc.update(breaker, open);
assertThat(repository.findState(open)).as(breaker).get().extracting(CoreAccountState::publicProfile).isEqualTo(false);
assertThat(repository.findAllStates()).as(breaker).filteredOn(state -> state.userId() == open)
.extracting(CoreAccountState::publicProfile).containsExactly(false);
}
}
@Test
void findStates_ProfileSectionsAreTheTileIdsWithAllStatsTilesCollapsedToOne() {
jdbc.update("update user_settings set profile_tiles = '{SUMMARY:L,STATS_BACKLOG:M,PLAYING:S,STATS_PLAYTIME:M}' where user_id = ?",
alice);
assertThat(repository.findState(alice)).get().extracting(CoreAccountState::profileSections)
.isEqualTo(List.of("SUMMARY", "PLAYING", "STATS"));
assertThat(repository.findAllStates()).extracting(CoreAccountState::profileSections)
.containsExactly(List.of("SUMMARY", "PLAYING", "STATS"), List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED"));
}
@Test
void findStates_UserWithoutASettingsRow_HasNoSectionsAndIsNotPublic() {
jdbc.update("delete from user_settings where user_id = ?", alice);
assertThat(repository.findState(alice)).get().satisfies(state -> {
assertThat(state.profileSections()).isNull();
assertThat(state.publicProfile()).isFalse();
});
}
} }
@@ -31,7 +31,7 @@ import static org.mockito.Mockito.when;
class AccountCleanupServiceTest extends AbstractUnitTest { class AccountCleanupServiceTest extends AbstractUnitTest {
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null, false)); private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null, false, false, null));
private static final Duration RETENTION = Duration.ofDays(30); private static final Duration RETENTION = Duration.ofDays(30);
@@ -73,8 +73,8 @@ class CoreAccountServiceTest extends AbstractUnitTest {
expect("replaceStates"); expect("replaceStates");
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true, service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true,
Instant.parse("2026-10-04T12:00:00Z"), true))); Instant.parse("2026-10-04T12:00:00Z"), true, true, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED"))));
service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null, false))); service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null, false, false, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED"))));
core.verify(); core.verify();
} }
@@ -35,7 +35,7 @@ import static org.mockito.Mockito.when;
class CoreAccountStatePublisherTest extends AbstractUnitTest { class CoreAccountStatePublisherTest extends AbstractUnitTest {
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, false); private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, false, false, null);
@Mock @Mock
private AccountRepository accountRepository; private AccountRepository accountRepository;
@@ -89,7 +89,7 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest {
@Test @Test
void coreDownOnce_RetriesAfter10sAndSendsTheCurrentState() { void coreDownOnce_RetriesAfter10sAndSendsTheCurrentState() {
CoreAccountState newer = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, true); CoreAccountState newer = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, true, false, null);
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE), Optional.of(newer)); when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE), Optional.of(newer));
doThrow(new RestClientException("Core down")).doNothing().when(coreAccountService).applyStates(any()); doThrow(new RestClientException("Core down")).doNothing().when(coreAccountService).applyStates(any());
ArgumentCaptor<Runnable> retry = ArgumentCaptor.forClass(Runnable.class); ArgumentCaptor<Runnable> retry = ArgumentCaptor.forClass(Runnable.class);
@@ -0,0 +1,47 @@
package auth_service.modules.profile.service;
import auth_service.modules.profile.model.PublicProfile;
import auth_service.modules.profile.repo.PublicProfileRepository;
import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class PublicProfileServiceTest extends AbstractUnitTest {
@Mock
private PublicProfileRepository publicProfileRepository;
@Mock
private ProfileTicketIssuer profileTicketIssuer;
@InjectMocks
private PublicProfileService service;
@Test
void findPublicProfile_TicketCarriesTheSectionsAndTheStatGroups() {
PublicProfile profile = new PublicProfile(7L, Role.USER, "lobo", null,
new PublicProfile.Style(null, null, null, null, Map.of()),
List.of(new PublicProfile.Tile("SUMMARY", "M"), new PublicProfile.Tile("STATS_BACKLOG", "M"),
new PublicProfile.Tile("STATS_PLAYTIME", "S")));
when(publicProfileRepository.findPublicByDisplayName("lobo")).thenReturn(Optional.of(profile));
when(profileTicketIssuer.generateProfileTicket(anyLong(), any(), anyList(), anyList())).thenReturn("ticket");
service.findPublicProfile("lobo");
verify(profileTicketIssuer).generateProfileTicket(7L, Role.USER, List.of("BACKLOG", "PLAYTIME"),
List.of("SUMMARY", "STATS"));
}
}
@@ -77,6 +77,13 @@ class UserServiceTest extends AbstractUnitTest {
assertEquals(List.of(new Tile("SUMMARY", "M")), changed.get(1).getSettings().getProfileTiles()); assertEquals(List.of(new Tile("SUMMARY", "M")), changed.get(1).getSettings().getProfileTiles());
} }
@Test
void updateProfileTiles_PublishesTheAccountStateOnce() {
userService.updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M")));
verify(accountStatePublisher, times(1)).publish(1L);
}
@Test @Test
void updateStatChart_PutsTheFormOfTheGroup() { void updateStatChart_PutsTheFormOfTheGroup() {
userService.updateStatChart(1L, "PLATFORMS", "PIE"); userService.updateStatChart(1L, "PLATFORMS", "PIE");
@@ -0,0 +1,32 @@
package auth_service.shared.account;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ProfileSectionsTest extends AbstractUnitTest {
@Test
void of_KeepsThePartsInOrder() {
assertThat(ProfileSections.of(List.of("SUMMARY", "PLAYING", "FINISHED"))).containsExactly("SUMMARY", "PLAYING", "FINISHED");
}
@Test
void of_CollapsesEveryStatsTileToOneStatsSectionAtTheEnd() {
assertThat(ProfileSections.of(List.of("STATS_A", "SUMMARY", "STATS_B"))).containsExactly("SUMMARY", "STATS");
}
@Test
void of_NoTilesNoSections() {
assertThat(ProfileSections.of(List.of())).isEmpty();
}
@Test
void of_DropsTileIdsThatCoreDoesNotKnow() {
assertThat(ProfileSections.of(List.of("SUMMARY", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "OWN_TILE", "STATS")))
.containsExactly("SUMMARY", "STATS");
}
}
@@ -37,12 +37,12 @@
"applyStates": { "applyStates": {
"method": "POST", "method": "POST",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z", "listed": true}]} "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z", "listed": true, "publicProfile": true, "profileSections": ["SUMMARY", "STATUS", "PLAYING", "FINISHED"]}]}
}, },
"replaceStates": { "replaceStates": {
"method": "PUT", "method": "PUT",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false}]} "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false, "publicProfile": false, "profileSections": ["SUMMARY", "STATUS", "PLAYING", "FINISHED"]}]}
}, },
"purge": { "purge": {
"method": "POST", "method": "POST",