Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.brennaswitzer.cookbook.graphql;

import com.brennaswitzer.cookbook.services.UnknownPreferenceException;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import jakarta.persistence.EntityNotFoundException;
Expand All @@ -13,9 +14,20 @@ public class GlobalExceptionHandler {
@GraphQlExceptionHandler
public GraphQLError handle(GraphqlErrorBuilder<?> errorBuilder,
EntityNotFoundException enfe) {
return badRequest(errorBuilder, enfe);
}

@GraphQlExceptionHandler
public GraphQLError handle(GraphqlErrorBuilder<?> errorBuilder,
UnknownPreferenceException upe) {
return badRequest(errorBuilder, upe);
}

private GraphQLError badRequest(GraphqlErrorBuilder<?> errorBuilder,
Exception upe) {
return errorBuilder
.errorType(ErrorType.BAD_REQUEST)
.message(enfe.getMessage())
.message(upe.getMessage())
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,21 @@
import graphql.execution.instrumentation.SimplePerformantInstrumentation;
import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.text.StringEscapeUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.regex.Pattern;

@Component
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE)
public class GraphQLLoggingInstrumentation extends SimplePerformantInstrumentation {

private static final Pattern RE_QUERY_WHITESPACE = Pattern.compile("[\n\r\t]");

@Autowired
private ObjectMapper objectMapper;

Expand All @@ -41,9 +43,10 @@ public InstrumentationContext<ExecutionResult> beginExecution(
public void onDispatched() {
startMillis = System.currentTimeMillis();
if (debugEnabled) {
log.debug("graphql {} query: \"{}\"",
log.debug("graphql {} query: {}",
executionId,
StringEscapeUtils.escapeJson(parameters.getQuery().strip()));
RE_QUERY_WHITESPACE.matcher(parameters.getQuery())
.replaceAll(" "));
log.debug("graphql {} variables: {}",
executionId,
maybeAsJson(parameters.getVariables()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,11 @@ public Collection<UserPreference> preferences(User user,
return assembleUserPreferences.assemble(user, deviceKey);
}

@SchemaMapping
public UserPreference preference(User user,
@Argument String name,
@Argument String deviceKey) {
return assembleUserPreferences.assemble(user, name, deviceKey);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
import com.brennaswitzer.cookbook.domain.Preference;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface PreferenceRepository extends BaseEntityRepository<Preference> {

Optional<Preference> findByName(String name);

Preference getByName(String name);

}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ public Collection<UserPreference> assemble(User user,
return assemble(user, ensureUserDevice.forRead(user, deviceKey));
}

public UserPreference assemble(User user,
String prefName,
String deviceKey) {
return assemble(user, prefName, ensureUserDevice.forRead(user, deviceKey));
}

public Collection<UserPreference> assemble(User user,
UserDevice device) {
Map<Preference, UserPreference> byPref = user.getPreferences()
Expand All @@ -47,4 +53,17 @@ public Collection<UserPreference> assemble(User user,
.toList();
}

public UserPreference assemble(User user,
String prefName,
UserDevice device) {
return user.getPreferences()
.stream()
.filter(p -> Objects.equals(device, p.getDevice()))
.filter(up -> prefName.equals(up.getName()))
.findFirst()
.orElseGet(() -> defaultUserPreference.factory(user, device)
.apply(preferenceRepo.findByName(prefName)
.orElseThrow(() -> new UnknownPreferenceException(prefName))));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@
import com.brennaswitzer.cookbook.domain.UserDevice;
import com.brennaswitzer.cookbook.repositories.UserDeviceRepository;
import com.brennaswitzer.cookbook.repositories.UserRepository;
import com.google.common.annotations.VisibleForTesting;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;

@Service
@Slf4j
public class EnsureUserDevice {

@Autowired
Expand Down Expand Up @@ -40,18 +45,34 @@ public UserDevice forWrite(User user, String key) {
}

public UserDevice loadEnsureAndSave(Identified user, String key) {
var device = userDeviceRepo.findByUserIdAndKey(user.getId(), key)
.orElseGet(() -> {
var d = new UserDevice();
User u = userRepo.getReferenceById(user.getId());
d.setUser(u);
u.getDevices().add(d);
d.setKey(key);
d.setName("New Device (" + LocalDate.now() + ')');
return d;
});
Optional<UserDevice> optDevice = userDeviceRepo.findByUserIdAndKey(user.getId(), key);
UserDevice device;
if (optDevice.isPresent()) {
device = optDevice.get();
if (shouldSkipEnsure(device)) {
log.info("Skip ensuring device '{}' for user '{}'.",
key,
user.getId());
return device;
}
} else {
device = new UserDevice();
User u = userRepo.getReferenceById(user.getId());
device.setUser(u);
u.getDevices().add(device);
device.setKey(key);
device.setName("New Device (" + LocalDate.now() + ')');
}
device.markEnsured();
return userDeviceRepo.save(device);
}

@VisibleForTesting
boolean shouldSkipEnsure(UserDevice device) {
// Skip it 90% of the time, if it's already been ensured today.
return Math.random() < 0.9
&& device.getLastEnsuredAt().isAfter(
Instant.now().minusSeconds(86400));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.brennaswitzer.cookbook.services;

public class UnknownPreferenceException extends IllegalArgumentException {

public UnknownPreferenceException(String prefName) {
super("No '" + prefName + "' preference is known");
}

}
14 changes: 11 additions & 3 deletions src/main/resources/graphqls/library.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,22 @@ interface Ingredient implements Node {
name: String!
}

interface IngredientCollection implements Node {
id: ID!
name: String!
directions: String
ingredients: [IngredientRef!]!
labels: [String!]
}

enum ChronoUnit {
MILLIS
SECONDS
MINUTES
HOURS
}

type Recipe implements Node & Owned & Ingredient {
type Recipe implements Node & Owned & Ingredient & IngredientCollection {
id: ID!
owner: User!
ownedBy: User
Expand All @@ -195,7 +203,7 @@ type Recipe implements Node & Owned & Ingredient {
ingredients(
"""Ingredient(s) to include. Missing/empty means "all".
"""
ingredients: [ID!]! = []
ingredients: [ID!]
): [IngredientRef!]!
"""Sections of the recipe, owned or by reference. Use `sectionOf` if you
need to differentiate. Sections and ingredients are disjoint.
Expand Down Expand Up @@ -232,7 +240,7 @@ type Recipe implements Node & Owned & Ingredient {
share: ShareInfo!
}

type Section implements Node {
type Section implements Node & IngredientCollection {
id: ID!
"""The recipe this section belongs to, if owned. When a top-level recipe is
used as a section, this will be null.
Expand Down
7 changes: 6 additions & 1 deletion src/main/resources/graphqls/profile.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,15 @@ type User implements Node {
roles: [String!]!
devices: [UserDevice!]!
"""A value for every preference is returned. If a deviceKey is provided,
that device's values are preferred. Otherwise, the user's global preferences
that device's values will be used. Otherwise, the user's global preferences
are used, if they exists. If not, the static default is returned.
"""
preferences(deviceKey: String): [UserPreference!]!
"""Return a single preference. If a deviceKey is provided, that device's
value will be used. Otherwise, the user's global preference will be used, if
one exists. If not, the static default is returned.
"""
preference(name: String!, deviceKey: String): UserPreference!
me: Boolean!
notMe: Boolean!
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
Expand All @@ -39,7 +40,7 @@ class EnsureUserDeviceTest {
private UserDeviceRepository userDeviceRepo;

@Test
void forReadNull_user() {
void forRead_null() {
User user = mock(User.class);

ensureUserDevice.forRead(user, null);
Expand All @@ -58,6 +59,29 @@ void forRead_exists() {
.thenReturn(Optional.of(another));
when(userDeviceRepo.save(any()))
.thenAnswer(iom -> iom.getArgument(0));
doReturn(false)
.when(ensureUserDevice)
.shouldSkipEnsure(another);

var result = ensureUserDevice.forRead(user, "another");

assertSame(another, result);
verifyNoInteractions(userRepo);
verify(userDeviceRepo).findByUserIdAndKey(userId, "another");
verifyNoMoreInteractions(userDeviceRepo);
}

@Test
void forRead_exists_skipEnsure() {
long userId = 123456L;
UserDevice another = mock(UserDevice.class);
User user = mock(User.class);
when(user.getId()).thenReturn(userId);
when(userDeviceRepo.findByUserIdAndKey(any(), any()))
.thenReturn(Optional.of(another));
doReturn(true)
.when(ensureUserDevice)
.shouldSkipEnsure(another);

var result = ensureUserDevice.forRead(user, "another");

Expand Down