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
Expand Up @@ -21,10 +21,10 @@
import org.patinanetwork.codebloom.common.dto.Empty;
import org.patinanetwork.codebloom.common.dto.autogen.UnsafeGenericFailureResponse;
import org.patinanetwork.codebloom.common.dto.lobby.DuelData;
import org.patinanetwork.codebloom.common.ff.annotation.FF;
import org.patinanetwork.codebloom.common.security.AuthenticationObject;
import org.patinanetwork.codebloom.common.security.annotation.Protected;
import org.patinanetwork.codebloom.common.utils.sse.SseWrapper;
import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration;
import org.patinanetwork.codebloom.scheduled.pg.handler.LobbyNotifyHandler;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -48,19 +48,16 @@ public class DuelController {
private final PartyManager partyManager;
private final LobbyRepository lobbyRepository;
private final LobbyNotifyHandler lobbyNotifyHandler;
private final FeatureFlagConfiguration ff;

public DuelController(
final DuelManager duelManager,
final PartyManager partyManager,
final LobbyRepository lobbyRepository,
final LobbyNotifyHandler lobbyNotifyHandler,
final FeatureFlagConfiguration ff) {
final LobbyNotifyHandler lobbyNotifyHandler) {
this.duelManager = duelManager;
this.partyManager = partyManager;
this.lobbyRepository = lobbyRepository;
this.lobbyNotifyHandler = lobbyNotifyHandler;
this.ff = ff;
}

@Operation(summary = "Join party", description = "Join a party by providing the lobby code.")
Expand Down Expand Up @@ -91,13 +88,10 @@ public DuelController(
@ApiResponse(responseCode = "200", description = "Party has been successfully joined!"),
})
@PostMapping("/party/join")
@FF("duels")
public ResponseEntity<ApiResponder<Empty>> joinParty(
@Protected final AuthenticationObject authenticationObject,
@RequestBody final JoinLobbyBody joinPartyBody) {
if (!ff.isDuels()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional");
}

joinPartyBody.validate();

var user = authenticationObject.getUser();
Expand Down Expand Up @@ -136,11 +130,8 @@ public ResponseEntity<ApiResponder<Empty>> joinParty(
@ApiResponse(responseCode = "200", description = "Duel successfully started!"),
})
@PostMapping("/start")
@FF("duels")
public ResponseEntity<ApiResponder<Empty>> startDuel(@Protected final AuthenticationObject authenticationObject) {
if (!ff.isDuels()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional");
}

var user = authenticationObject.getUser();

try {
Expand Down Expand Up @@ -172,11 +163,8 @@ public ResponseEntity<ApiResponder<Empty>> startDuel(@Protected final Authentica
@ApiResponse(responseCode = "200", description = "Party left successfully"),
})
@PostMapping("/party/leave")
@FF("duels")
public ResponseEntity<ApiResponder<Empty>> leaveParty(@Protected final AuthenticationObject authenticationObject) {
if (!ff.isDuels()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional");
}

User user = authenticationObject.getUser();

try {
Expand Down Expand Up @@ -216,11 +204,8 @@ public ResponseEntity<ApiResponder<Empty>> leaveParty(@Protected final Authentic
@ApiResponse(responseCode = "200", description = "Duel has been successfully ended!"),
})
@PostMapping("/end")
@FF("duels")
public ResponseEntity<ApiResponder<Empty>> endDuel(@Protected final AuthenticationObject authenticationObject) {
if (!ff.isDuels()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional");
}

User user = authenticationObject.getUser();

var lobby = lobbyRepository
Expand Down Expand Up @@ -261,12 +246,9 @@ public ResponseEntity<ApiResponder<Empty>> endDuel(@Protected final Authenticati
@ApiResponse(responseCode = "200", description = "Party created successfully"),
})
@PostMapping("/party/create")
@FF("duels")
public ResponseEntity<ApiResponder<PartyCodeBody>> createParty(
@Protected final AuthenticationObject authenticationObject) {
if (!ff.isDuels()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional");
}

User user = authenticationObject.getUser();

String joinCode;
Expand Down Expand Up @@ -303,11 +285,8 @@ public ResponseEntity<ApiResponder<PartyCodeBody>> createParty(
content = @Content(schema = @Schema(implementation = UnsafeGenericFailureResponse.class)))
})
@PostMapping(value = "/{lobbyCode}/sse")
@FF("duels")
public SseWrapper<ApiResponder<DuelData>> getDuelData(@PathVariable final String lobbyCode) {
if (!ff.isDuels()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional");
}

var lobby = lobbyRepository
.findActiveLobbyByJoinCode(lobbyCode)
.or(() -> lobbyRepository.findAvailableLobbyByJoinCode(lobbyCode))
Expand Down Expand Up @@ -347,12 +326,9 @@ public SseWrapper<ApiResponder<DuelData>> getDuelData(@PathVariable final String
@ApiResponse(responseCode = "200", description = "Party or duel code was successfully found!"),
})
@GetMapping("/current")
@FF("duels")
public ResponseEntity<ApiResponder<PartyCodeBody>> getPartyOrDuelCodeForUser(
@Protected final AuthenticationObject authenticationObject) {
if (!ff.isDuels()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional");
}

var user = authenticationObject.getUser();

Lobby lobby;
Expand Down Expand Up @@ -393,12 +369,9 @@ public ResponseEntity<ApiResponder<PartyCodeBody>> getPartyOrDuelCodeForUser(
"The user's solved questions were processed (could still mean no new points were awarded)"),
})
@PostMapping("/process")
@FF("duels")
public ResponseEntity<ApiResponder<Empty>> processSolvedProblemsInDuel(
@Protected final AuthenticationObject authenticationObject) {
if (!ff.isDuels()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional");
}

var user = authenticationObject.getUser();

Lobby duel;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@
import org.patinanetwork.codebloom.common.dto.question.QuestionDto;
import org.patinanetwork.codebloom.common.dto.user.UserDto;
import org.patinanetwork.codebloom.common.dto.user.metrics.MetricsDto;
import org.patinanetwork.codebloom.common.ff.annotation.FF;
import org.patinanetwork.codebloom.common.lag.FakeLag;
import org.patinanetwork.codebloom.common.page.Page;
import org.patinanetwork.codebloom.common.time.StandardizedOffsetDateTime;
import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
Expand Down Expand Up @@ -58,19 +58,16 @@ public class UserController {
private final UserRepository userRepository;
private final QuestionTopicService questionTopicService;
private final UserMetricsRepository userMetricsRepository;
private final FeatureFlagConfiguration ff;

public UserController(
final QuestionRepository questionRepository,
final UserRepository userRepository,
final QuestionTopicService questionTopicService,
final UserMetricsRepository userMetricsRepository,
final FeatureFlagConfiguration ff) {
final UserMetricsRepository userMetricsRepository) {
this.questionRepository = questionRepository;
this.userRepository = userRepository;
this.questionTopicService = questionTopicService;
this.userMetricsRepository = userMetricsRepository;
this.ff = ff;
}

@Operation(
Expand Down Expand Up @@ -227,6 +224,7 @@ public ResponseEntity<ApiResponder<Page<UserDto>>> getAllUsers(
content = @Content(schema = @Schema(implementation = UnsafeGenericFailureResponse.class))),
})
@GetMapping("{userId}/metrics")
@FF("userMetrics")
public ResponseEntity<ApiResponder<Page<MetricsDto>>> getUserMetrics(
final HttpServletRequest request,
@PathVariable final String userId,
Expand All @@ -244,10 +242,6 @@ public ResponseEntity<ApiResponder<Page<MetricsDto>>> getUserMetrics(

FakeLag.sleep(500);

if (!ff.isUserMetrics()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is not available.");
}

if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "startDate cannot be after endDate.");
}
Expand Down
53 changes: 53 additions & 0 deletions src/main/java/org/patinanetwork/codebloom/common/ff/FFAspect.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package org.patinanetwork.codebloom.common.ff;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.patinanetwork.codebloom.common.ff.annotation.FF;
import org.springframework.context.expression.MapAccessor;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;

@Aspect
@Component
@Slf4j
public class FFAspect {

private final FeatureFlagManager featureFlagManager;
private final ExpressionParser parser;

public FFAspect(final FeatureFlagManager featureFlagManager) {
this.featureFlagManager = featureFlagManager;
this.parser = new SpelExpressionParser();
}

@Around("@annotation(ff)")
public Object gateMethodByFeatureFlags(final ProceedingJoinPoint joinPoint, final FF ff) throws Throwable {
String expression = ff.value();

var flags = featureFlagManager.getAllFlags();
StandardEvaluationContext context = new StandardEvaluationContext(flags);
context.addPropertyAccessor(new MapAccessor());

Boolean isEnabled;
try {
isEnabled = parser.parseExpression(expression).getValue(context, Boolean.class);
} catch (Exception e) {
log.error("Invalid @FF expression: " + expression, e);
throw new IllegalArgumentException("Invalid @FF expression: " + expression, e);
}

if (!Boolean.TRUE.equals(isEnabled)) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Endpoint is not available. Feature flag expression evaluated to false: " + expression);
}

return joinPoint.proceed();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.patinanetwork.codebloom.common.ff;

import java.beans.Introspector;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import org.patinanetwork.codebloom.jda.properties.FeatureFlagConfiguration;
import org.springframework.stereotype.Component;

@Component
public class FeatureFlagManager {
private final FeatureFlagConfiguration ff;
private final Map<String, Boolean> cachedFlags;

public FeatureFlagManager(final FeatureFlagConfiguration ff) {
this.ff = ff;
this.cachedFlags = initializeFlags();
}

private Map<String, Boolean> initializeFlags() {
Map<String, Boolean> flags = new HashMap<>();
for (Method method : ff.getClass().getMethods()) {
if (method.getName().startsWith("is")
&& method.getReturnType() == boolean.class
&& method.getParameterCount() == 0) {
try {
String flagName = Introspector.decapitalize(method.getName().substring(2));
flags.put(flagName, (Boolean) method.invoke(ff));
} catch (Exception e) {
throw new IllegalStateException(
"Failed to resolve feature flag value from method: " + method.getName(), e);
}
}
}
return Map.copyOf(flags);
}

public Map<String, Boolean> getAllFlags() {
return cachedFlags;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.patinanetwork.codebloom.common.ff.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Feature flag gate
*
* <p>The value must be a SpEL boolean expression using feature flag names from FeatureFlagManager. Example:
* {@code "duels && userMetrics"}
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface FF {
String value();
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/** Feature flags that we supported. Must be defined through application.yml */
@Getter
@Setter
@Component
Expand Down
Loading
Loading