Skip to content

Commit

Permalink
Implement @Attribute Injection. (#5547)
Browse files Browse the repository at this point in the history
### Motivation:
- In the past, users could get a value from `ServiceRequestContext` by
using `ServiceRequestContext.attr(key)`. however, if it were possible to
inject values which in `ServiceRequestContext` into method parameters
using any annotation, users would be able to use it more conveniently.

### Modifications:
- New annotation. `@Attribute` is added.
- Make new field `rawType` in `AnnotatedValueResolver` to validate type
cast.
- New methods `ofAttribute()` and `attributeResolver()` are added to
`AnnotatedValueResolver` to get values from `ServiceRequestContext` and
inject them method parameters.
- `findName()` in `AnnotatedElementNameUtil` method have been merged
into a single method. (refactoring for internal use)
- Add test codes. 
- Add document explaining how to use the `@Attribute` annotation.

### Result:
- Closes #5514
- Users can inject values from the `ServiceRequestContext` into method
parameters using the `@Attribute` annotation without
`ServiceRequestContext.attr(key)`.

---------

Co-authored-by: Trustin Lee <t@motd.kr>
Co-authored-by: Ikhun Um <ih.pert@gmail.com>
Co-authored-by: minux <songmw725@gmail.com>
  • Loading branch information
4 people authored May 9, 2024
1 parent 61bdfe4 commit 727ac80
Show file tree
Hide file tree
Showing 5 changed files with 394 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,52 +21,29 @@
import java.lang.reflect.Field;
import java.lang.reflect.Parameter;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Ascii;
import com.google.common.base.CaseFormat;

import com.linecorp.armeria.server.annotation.Attribute;
import com.linecorp.armeria.server.annotation.Header;
import com.linecorp.armeria.server.annotation.Param;

final class AnnotatedElementNameUtil {

/**
* Returns the value of the {@link Param} annotation which is specified on the {@code element} if
* the value is not blank. If the value is blank, it returns the name of the specified
* {@code nameRetrievalTarget} object which is an instance of {@link Parameter} or {@link Field}.
* Returns the value of {@link Header}, {@link Param}, {@link Attribute} if the value is not blank.
* If the value is blank, it returns the name of the specified {@code nameRetrievalTarget} object
* which is an instance of {@link Header}, {@link Param}, {@link Attribute} or {@link Field}.
*/
static String findName(Param param, Object nameRetrievalTarget) {
static String findName(Object nameRetrievalTarget, String value) {
requireNonNull(nameRetrievalTarget, "nameRetrievalTarget");

final String value = param.value();
if (DefaultValues.isSpecified(value)) {
checkArgument(!value.isEmpty(), "value is empty.");
return value;
}
return getName(nameRetrievalTarget);
}

/**
* Returns the value of the {@link Header} annotation which is specified on the {@code element} if
* the value is not blank. If the value is blank, it returns the name of the specified
* {@code nameRetrievalTarget} object which is an instance of {@link Parameter} or {@link Field}.
*
* <p>Note that the name of the specified {@code nameRetrievalTarget} will be converted as
* {@link CaseFormat#LOWER_HYPHEN} that the string elements are separated with one hyphen({@code -})
* character. The value of the {@link Header} annotation will not be converted because it is clearly
* specified by a user.
*/
static String findName(Header header, Object nameRetrievalTarget) {
requireNonNull(nameRetrievalTarget, "nameRetrievalTarget");

final String value = header.value();
if (DefaultValues.isSpecified(value)) {
checkArgument(!value.isEmpty(), "value is empty.");
return value;
}
return toHeaderName(getName(nameRetrievalTarget));
}

/**
* Returns the name of the specified element or the default name if it can't get.
*/
Expand All @@ -89,8 +66,7 @@ static String getName(Object element) {
throw new IllegalArgumentException(
"cannot obtain the name of the parameter or field automatically. " +
"Please make sure you compiled your code with '-parameters' option. " +
"If not, you need to specify parameter and header names with @" +
Param.class.getSimpleName() + " and @" + Header.class.getSimpleName() + '.');
"Alternatively, you could specify the name explicitly in the annotation.");
}
return parameter.getName();
}
Expand All @@ -100,7 +76,6 @@ static String getName(Object element) {
throw new IllegalArgumentException("cannot find the name: " + element.getClass().getName());
}

@VisibleForTesting
static String toHeaderName(String name) {
requireNonNull(name, "name");
checkArgument(!name.isEmpty(), "name is empty.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import static com.linecorp.armeria.internal.server.annotation.AnnotatedElementNameUtil.findName;
import static com.linecorp.armeria.internal.server.annotation.AnnotatedElementNameUtil.getName;
import static com.linecorp.armeria.internal.server.annotation.AnnotatedElementNameUtil.getNameOrDefault;
import static com.linecorp.armeria.internal.server.annotation.AnnotatedElementNameUtil.toHeaderName;
import static com.linecorp.armeria.internal.server.annotation.AnnotatedServiceFactory.findDescription;
import static com.linecorp.armeria.internal.server.annotation.AnnotatedServiceTypeUtil.stringToType;
import static com.linecorp.armeria.internal.server.annotation.DefaultValues.getSpecifiedValue;
Expand Down Expand Up @@ -64,6 +65,7 @@
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.primitives.Primitives;

import com.linecorp.armeria.common.AggregatedHttpRequest;
import com.linecorp.armeria.common.Cookie;
Expand All @@ -85,6 +87,7 @@
import com.linecorp.armeria.internal.server.annotation.AnnotatedBeanFactoryRegistry.BeanFactoryId;
import com.linecorp.armeria.server.ServiceRequestContext;
import com.linecorp.armeria.server.annotation.AnnotatedService;
import com.linecorp.armeria.server.annotation.Attribute;
import com.linecorp.armeria.server.annotation.ByteArrayRequestConverterFunction;
import com.linecorp.armeria.server.annotation.Default;
import com.linecorp.armeria.server.annotation.Delimiter;
Expand All @@ -100,6 +103,7 @@
import com.linecorp.armeria.server.docs.DescriptionInfo;

import io.netty.handler.codec.http.HttpConstants;
import io.netty.util.AttributeKey;
import scala.concurrent.ExecutionContext;

final class AnnotatedValueResolver {
Expand Down Expand Up @@ -445,9 +449,16 @@ private static AnnotatedValueResolver of(AnnotatedElement annotatedElement,
requireNonNull(dependencyInjector, "dependencyInjector");

final DescriptionInfo description = findDescription(annotatedElement);

final Attribute attr = annotatedElement.getAnnotation(Attribute.class);
if (attr != null) {
final String name = findName(typeElement, attr.value());
return ofAttribute(name, attr, annotatedElement, typeElement, type, description);
}

final Param param = annotatedElement.getAnnotation(Param.class);
if (param != null) {
final String name = findName(param, typeElement);
final String name = findName(typeElement, param.value());
if (type == File.class || type == Path.class || type == MultipartFile.class) {
return ofFileParam(name, annotatedElement, typeElement, type, description);
}
Expand All @@ -460,7 +471,7 @@ private static AnnotatedValueResolver of(AnnotatedElement annotatedElement,

final Header header = annotatedElement.getAnnotation(Header.class);
if (header != null) {
final String name = findName(header, typeElement);
final String name = toHeaderName(findName(typeElement, header.value()));
return ofHeader(name, annotatedElement, typeElement, type, description);
}

Expand Down Expand Up @@ -521,6 +532,7 @@ static List<RequestObjectResolver> addToFirstIfExists(List<RequestObjectResolver

private static boolean isAnnotationPresent(AnnotatedElement element) {
return element.isAnnotationPresent(Param.class) ||
element.isAnnotationPresent(Attribute.class) ||
element.isAnnotationPresent(Header.class) ||
element.isAnnotationPresent(RequestObject.class);
}
Expand Down Expand Up @@ -622,6 +634,34 @@ private static AnnotatedValueResolver ofRequestObject(String name, AnnotatedElem
.build();
}

private static AnnotatedValueResolver ofAttribute(String name,
Attribute attr,
AnnotatedElement annotatedElement,
AnnotatedElement typeElement, Class<?> type,
DescriptionInfo description) {

final ImmutableList.Builder<AttributeKey<?>> builder = ImmutableList.builder();

if (attr.prefix() != Attribute.class) {
builder.add(AttributeKey.valueOf(attr.prefix(), name));
} else {
final Class<?> serviceClass = ((Parameter) annotatedElement).getDeclaringExecutable()
.getDeclaringClass();
builder.add(AttributeKey.valueOf(serviceClass, name));
builder.add(AttributeKey.valueOf(name));
}

final ImmutableList<AttributeKey<?>> attrKeys = builder.build();
return new Builder(annotatedElement, type, name)
.annotationType(Attribute.class)
.typeElement(typeElement)
.supportDefault(true)
.supportContainer(true)
.description(description)
.resolver(attributeResolver(attrKeys))
.build();
}

@Nullable
private static AnnotatedValueResolver ofInjectableTypes(String name, AnnotatedElement annotatedElement,
Class<?> type, boolean useBlockingExecutor) {
Expand Down Expand Up @@ -819,6 +859,36 @@ private static AnnotatedValueResolver ofInjectableTypes0(String name, AnnotatedE
};
}

/**
* Returns an attribute resolver which retrieves a value specified by {@code attrKeys}
* from the {@link RequestContext}.
*/
private static BiFunction<AnnotatedValueResolver, ResolverContext, Object>
attributeResolver(Iterable<AttributeKey<?>> attrKeys) {
return (resolver, ctx) -> {
Class<?> targetType = resolver.rawType();
if (targetType.isPrimitive()) {
targetType = Primitives.wrap(targetType);
}

for (AttributeKey<?> attrKey : attrKeys) {
final Object value = ctx.context.attr(attrKey);
if (value != null) {
if (!targetType.isInstance(value)) {
throw new IllegalStateException(
String.format("Cannot inject the attribute '%s' due to " +
"mismatching type (expected: %s, actual: %s)",
attrKey.name(),
targetType.getName(),
value.getClass().getName()));
}
return value;
}
}
return resolver.defaultOrException();
};
}

private static BiFunction<AnnotatedValueResolver, ResolverContext, Object> fileResolver() {
return (resolver, ctx) -> {
final FileAggregatedMultipart fileAggregatedMultipart = ctx.aggregatedMultipart();
Expand Down Expand Up @@ -897,6 +967,7 @@ static boolean isAnnotatedNullable(AnnotatedElement annotatedElement) {

@Nullable
private final Class<?> containerType;
private final Class<?> rawType;
private final Class<?> elementType;
@Nullable
private final ParameterizedType parameterizedElementType;
Expand All @@ -921,6 +992,7 @@ private AnnotatedValueResolver(@Nullable Class<? extends Annotation> annotationT
boolean isPathVariable, boolean shouldExist,
boolean shouldWrapValueAsOptional,
@Nullable Class<?> containerType, Class<?> elementType,
Class<?> rawType,
@Nullable ParameterizedType parameterizedElementType,
@Nullable String defaultValue,
DescriptionInfo description,
Expand All @@ -936,6 +1008,7 @@ private AnnotatedValueResolver(@Nullable Class<? extends Annotation> annotationT
this.parameterizedElementType = parameterizedElementType;
this.description = requireNonNull(description, "description");
this.containerType = containerType;
this.rawType = rawType;
this.resolver = requireNonNull(resolver, "resolver");
this.beanFactoryId = beanFactoryId;
this.aggregationStrategy = requireNonNull(aggregationStrategy, "aggregationStrategy");
Expand Down Expand Up @@ -981,6 +1054,10 @@ Class<?> containerType() {
return containerType;
}

Class<?> rawType() {
return rawType;
}

Class<?> elementType() {
return elementType;
}
Expand Down Expand Up @@ -1053,7 +1130,8 @@ private Object defaultOrException() {
}
return defaultValue;
}
throw new IllegalArgumentException("Mandatory parameter/header is missing: " + httpElementName);
throw new IllegalArgumentException("Mandatory parameter/header/attribute is missing: " +
httpElementName);
}

@Override
Expand Down Expand Up @@ -1107,6 +1185,7 @@ private Builder(AnnotatedElement annotatedElement, Type type, String name) {
*/
private Builder annotationType(Class<? extends Annotation> annotationType) {
assert annotationType == Param.class ||
annotationType == Attribute.class ||
annotationType == Header.class ||
annotationType == RequestObject.class : annotationType.getSimpleName();
this.annotationType = annotationType;
Expand Down Expand Up @@ -1241,6 +1320,13 @@ private AnnotatedValueResolver build() {
}

final Class<?> containerType = getContainerType(unwrappedParameterizedType);
final Class<?> rawType;
final Class<?> mayRawType = toRawType(unwrappedParameterizedType);
if (mayRawType.isPrimitive()) {
rawType = Primitives.wrap(mayRawType);
} else {
rawType = mayRawType;
}
final Class<?> elementType;
final ParameterizedType parameterizedElementType;

Expand Down Expand Up @@ -1269,7 +1355,7 @@ private AnnotatedValueResolver build() {
}

return new AnnotatedValueResolver(annotationType, httpElementName, pathVariable, shouldExist,
isOptional, containerType, elementType,
isOptional, containerType, elementType, rawType,
parameterizedElementType, defaultValue, description, resolver,
beanFactoryId, aggregation);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright 2016 LINE Corporation
*
* LINE Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package com.linecorp.armeria.server.annotation;

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

import com.linecorp.armeria.common.RequestContext;
import com.linecorp.armeria.common.annotation.UnstableApi;

import io.netty.util.AttributeKey;

/**
* Annotation for mapping an attribute of the given {@link AttributeKey}, retrieved
* from a {@link RequestContext}, onto the following elements.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@UnstableApi
public @interface Attribute {

/**
* The class of the {@link AttributeKey} to bind to. If you created an {@link AttributeKey} with
* {@code AttributeKey.valueOf(MyAttributeKeys.class, "INT_ATTR")},
* the {@link #prefix()} should be {@code MyAttributeKeys.class}.
* See <a href="https://armeria.dev/docs/advanced-custom-attributes/">advanced-customer-attributes</a>.
*/
Class<?> prefix() default Attribute.class;

/**
* The name of the {@link AttributeKey} to bind to.
* You might also want to specify the {@link #prefix()}.
*/
String value();
}
Loading

0 comments on commit 727ac80

Please sign in to comment.