DefaultAuthVerifierImpl.java
/*
* discogs-java-client - A Java SDK to access the Discogs API
* Copyright © 2025 Andy Miles (andy.miles@amilesend.com)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.amilesend.discogs.connection.auth;
import com.amilesend.client.connection.auth.AuthException;
import com.amilesend.client.connection.auth.AuthManager;
import com.amilesend.discogs.model.Api;
import com.amilesend.discogs.model.AuthenticationOptional;
import com.amilesend.discogs.model.AuthenticationRequired;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import static java.lang.StackWalker.Option.RETAIN_CLASS_REFERENCE;
/**
* Helper to verify authentication defined for specific API calls to fail early if not configured for required calls.
* <p/>
* Note: This class checks the call stack for classes annotated with the {@link Api} annotation. Next, it
* checks the method to see if it is annotated with either {@link AuthenticationRequired} or
* {@link AuthenticationOptional}. If auth is required, then an {@link AuthException} is thrown; else, an info
* log statement is recorded.
*
* @see AuthVerifier
* @see Api
* @see AuthenticationRequired
* @see AuthenticationOptional
*/
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class DefaultAuthVerifierImpl implements AuthVerifier {
// Accounts for this class (i.e., #checkIfAuthenticated() -> #getApiMethodDescriptor()
private static final int DEFAULT_SKIP_FRAMES = 2;
private static final int MAX_STACK_WALK_DEPTH = 20;
/** The configured stack walker. */
@NonNull
private final StackWalker stackWalker;
/** The logger. */
private final Logger log;
/** Creates a new {@link DefaultAuthVerifierImpl} instance with the default settings configured. */
public DefaultAuthVerifierImpl() {
this(StackWalker.getInstance(Set.of(RETAIN_CLASS_REFERENCE), MAX_STACK_WALK_DEPTH),
LoggerFactory.getLogger(DefaultAuthVerifierImpl.class));
}
@Override
public void checkIfAuthenticated(final AuthManager<?> authManager) {
final MethodDescriptor methodDescriptor = getApiMethodDescriptor();
final MethodAuthType methodAuthType = methodDescriptor.getType();
if (methodAuthType == MethodAuthType.NONE || isAuthenticated(authManager)) {
return;
}
final String msg = new StringBuilder("Client authorization is not configured to access ")
.append(methodDescriptor.getApiClass().getSimpleName())
.append("::")
.append(methodDescriptor.getMethodName())
.toString();
switch (methodAuthType) {
case REQUIRED:
throw new AuthException(msg);
default: /* fall through */
case OPTIONAL:
log.info(msg);
break;
}
}
private boolean isAuthenticated(final AuthManager<?> authManager) {
return Optional.ofNullable(authManager)
.map(AuthManager::isAuthenticated)
.orElse(false);
}
private MethodDescriptor getApiMethodDescriptor() {
try {
// This finds the list of stack frames that reference the API class
final List<StackWalker.StackFrame> apiStackFrames = stackWalker.walk(
stream -> stream.skip(DEFAULT_SKIP_FRAMES)
.filter(sf -> sf.getDeclaringClass().isAnnotationPresent(Api.class))
.collect(Collectors.toList()));
// This iterates through the frames specific to the API class to find any methods that are annotated
return apiStackFrames.stream()
.filter(sf -> containsAuthenticationAnnotation(sf))
.findFirst()
.map(sf -> MethodDescriptor.builder()
.methodName(sf.getMethodName())
.apiClass(sf.getDeclaringClass())
.type(getMethodFromStackFrame(sf)
.map(MethodAuthType::from)
.orElse(MethodAuthType.NONE))
.build())
.orElse(MethodDescriptor.builder()
.methodName("???")
.apiClass(null)
.type(MethodAuthType.NONE)
.build());
} catch (final Exception ex) {
// This check should not blow up the API invocation (i.e., let the service verify and throw an exception).
return MethodDescriptor.builder()
.methodName("???")
.apiClass(null)
.type(MethodAuthType.NONE)
.build();
}
}
private boolean containsAuthenticationAnnotation(final StackWalker.StackFrame stackFrame) {
return getMethodFromStackFrame(stackFrame)
.map(m -> m.isAnnotationPresent(AuthenticationRequired.class)
|| m.isAnnotationPresent(AuthenticationOptional.class))
.orElse(false);
}
private Optional<Method> getMethodFromStackFrame(final StackWalker.StackFrame stackFrame) {
final Class<?> apiClass = stackFrame.getDeclaringClass();
try {
return Optional.of(apiClass.getMethod(stackFrame.getMethodName()));
} catch (final NoSuchMethodException ex) {
return Optional.empty();
}
}
/** Describes the current API method authorization requirement type. */
private enum MethodAuthType {
REQUIRED, OPTIONAL, NONE;
public static MethodAuthType from(final Method method) {
if (Objects.isNull(method)) {
return NONE;
}
if (method.isAnnotationPresent(AuthenticationRequired.class)) {
return REQUIRED;
}
if (method.isAnnotationPresent(AuthenticationOptional.class)) {
return OPTIONAL;
}
return NONE;
}
}
/** Describes the calling API method. */
@Builder
@Getter
private static class MethodDescriptor {
/** The type. */
private final MethodAuthType type;
/** The API class. */
private final Class<?> apiClass;
/** The API method name. */
private final String methodName;
}
}