mirror of https://github.com/apache/jclouds.git
introduced functional model for dynamic proxies
This commit is contained in:
parent
c42f59a8da
commit
bd9e998b12
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Licensed to jclouds, Inc. (jclouds) under one or more
|
||||
* contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. jclouds 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
|
||||
*
|
||||
* http://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 org.jclouds.reflect;
|
||||
|
||||
import static com.google.common.base.Objects.equal;
|
||||
import static com.google.common.base.Preconditions.checkArgument;
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
import static com.google.common.base.Predicates.notNull;
|
||||
import static com.google.common.base.Throwables.propagate;
|
||||
import static com.google.common.collect.Iterables.all;
|
||||
import static org.jclouds.util.Throwables2.propagateIfPossible;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.jclouds.reflect.Invocation.Result;
|
||||
|
||||
import com.google.common.annotations.Beta;
|
||||
import com.google.common.base.Function;
|
||||
import com.google.common.base.Objects;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.reflect.Invokable;
|
||||
|
||||
/**
|
||||
* Static utilities relating to functional Java reflection.
|
||||
*
|
||||
* @since 1.6
|
||||
*/
|
||||
@Beta
|
||||
public final class FunctionalReflection {
|
||||
/**
|
||||
* Returns a proxy instance that implements {@code interfaceType} by dispatching method invocations to
|
||||
* {@code invocationFunction}. The class loader of {@code interfaceType} will be used to define the proxy class.
|
||||
* <p>
|
||||
* Usage example:
|
||||
*
|
||||
* <pre>
|
||||
* httpAdapter = new Function<Invocation, Result>() {
|
||||
* public Result apply(Invocation in) {
|
||||
* try {
|
||||
* HttpRequest request = parseRequest(in);
|
||||
* HttpResponse response = invoke(request);
|
||||
* return Result.success(parseJson(response));
|
||||
* } catch (Exception e) {
|
||||
* return Result.failure(e);
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
*
|
||||
* client = FunctionalReflection.newProxy(Client.class, httpAdapter);
|
||||
* </pre>
|
||||
*
|
||||
* @param invocationFunction
|
||||
* returns a result or a top-level exception, or result
|
||||
* @throws IllegalArgumentException
|
||||
* if {@code interfaceType} does not specify the type of a Java interface
|
||||
* @see com.google.common.reflect.AbstractInvocationHandler#invoke(Object, Method, Object[])
|
||||
* @see com.google.common.reflect.Reflection#newProxy(Class, java.lang.reflect.InvocationHandler)
|
||||
*/
|
||||
public static <T> T newProxy(Class<T> interfaceType, Function<Invocation, Result> invocationFunction) {
|
||||
checkNotNull(interfaceType, "interfaceType");
|
||||
checkNotNull(invocationFunction, "invocationFunction");
|
||||
checkArgument(interfaceType.isInterface(), "%s is not an interface", interfaceType);
|
||||
Object object = Proxy.newProxyInstance(interfaceType.getClassLoader(), new Class<?>[] { interfaceType },
|
||||
new FunctionalInvocationHandler<T>(interfaceType, invocationFunction));
|
||||
return interfaceType.cast(object);
|
||||
}
|
||||
|
||||
private static final class FunctionalInvocationHandler<T> extends
|
||||
com.google.common.reflect.AbstractInvocationHandler {
|
||||
private final Class<T> interfaceType;
|
||||
private final Function<Invocation, Result> invocationFunction;
|
||||
|
||||
private FunctionalInvocationHandler(Class<T> interfaceType, Function<Invocation, Result> invocationFunction) {
|
||||
this.interfaceType = interfaceType;
|
||||
this.invocationFunction = invocationFunction;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object handleInvocation(Object proxy, Method invoked, Object[] argv) throws Throwable {
|
||||
List<Object> args = Arrays.asList(argv);
|
||||
if (all(args, notNull()))
|
||||
args = ImmutableList.copyOf(args);
|
||||
else
|
||||
args = Collections.unmodifiableList(args);
|
||||
Invokable<?, ?> invokable = Invokable.class.cast(Invokable.from(invoked));
|
||||
// not yet support the proxy arg
|
||||
Invocation invocation = Invocation.create(interfaceType, invokable, args);
|
||||
Result result;
|
||||
try {
|
||||
result = invocationFunction.apply(invocation);
|
||||
} catch (RuntimeException e) {
|
||||
result = Result.fail(e);
|
||||
}
|
||||
if (result.getThrowable().isPresent()) {
|
||||
propagateIfPossible(result.getThrowable().get(), invocation.getInvokable().getExceptionTypes());
|
||||
throw propagate(result.getThrowable().get());
|
||||
}
|
||||
return result.getResult().orNull();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (o == null || getClass() != o.getClass())
|
||||
return false;
|
||||
FunctionalInvocationHandler<?> that = FunctionalInvocationHandler.class.cast(o);
|
||||
return equal(this.interfaceType, that.interfaceType)
|
||||
&& equal(this.invocationFunction, that.invocationFunction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(interfaceType, invocationFunction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return invocationFunction.toString();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Licensed to jclouds, Inc. (jclouds) under one or more
|
||||
* contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. jclouds 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
|
||||
*
|
||||
* http://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 org.jclouds.reflect;
|
||||
|
||||
import static com.google.common.base.Objects.equal;
|
||||
import static com.google.common.base.Preconditions.checkArgument;
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.jclouds.javax.annotation.Nullable;
|
||||
|
||||
import com.google.common.annotations.Beta;
|
||||
import com.google.common.base.Objects;
|
||||
import com.google.common.base.Objects.ToStringHelper;
|
||||
import com.google.common.base.Optional;
|
||||
import com.google.common.reflect.Invokable;
|
||||
|
||||
/**
|
||||
* Context needed to call {@link com.google.common.reflect.Invokable#invoke(Object, Object...)}
|
||||
*
|
||||
* @author Adrian Cole
|
||||
*/
|
||||
@Beta
|
||||
public final class Invocation {
|
||||
|
||||
/**
|
||||
* Use this class when the invokable could be inherited. For example, a method is inherited when it cannot be
|
||||
* retrieved by {@link Class#getDeclaredMethods()}, but it can be retrieved by {@link Class#getMethods()}.
|
||||
*
|
||||
* @param interfaceType
|
||||
* type that either declared or inherited {@code invokable}, or was forwarded a call to it.
|
||||
* @param args
|
||||
* as these represent parameters, can contain nulls
|
||||
*/
|
||||
public static Invocation create(Class<?> interfaceType, Invokable<?, ?> invokable, List<Object> args) {
|
||||
checkArgument(invokable.getDeclaringClass().isAssignableFrom(interfaceType), "%s isn't assignable from %s",
|
||||
invokable.getDeclaringClass(), interfaceType);
|
||||
return new Invocation(interfaceType, invokable, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: use {@link #create(Class, Invokable, List)} when the invokable was inherited.
|
||||
*
|
||||
* @param args
|
||||
* as these represent parameters, can contain nulls
|
||||
*/
|
||||
public static Invocation create(Invokable<?, ?> invokable, List<Object> args) {
|
||||
return new Invocation(invokable.getDeclaringClass(), invokable, args);
|
||||
}
|
||||
|
||||
private final Class<?> interfaceType;
|
||||
private final Invokable<?, ?> invokable;
|
||||
private final List<Object> args;
|
||||
|
||||
private Invocation(Class<?> interfaceType, Invokable<?, ?> invokable, List<Object> args) {
|
||||
this.interfaceType = checkNotNull(interfaceType, "interfaceType");
|
||||
this.invokable = checkNotNull(invokable, "invokable");
|
||||
this.args = checkNotNull(args, "args");
|
||||
}
|
||||
|
||||
/**
|
||||
* different than {@link Invokable#getDeclaringClass()} when {@link #getInvokable()} is a member of a class it was
|
||||
* not declared in.
|
||||
*/
|
||||
public Class<?> getInterfaceType() {
|
||||
return interfaceType;
|
||||
}
|
||||
|
||||
/**
|
||||
* what we can invoke
|
||||
*/
|
||||
public Invokable<?, ?> getInvokable() {
|
||||
return invokable;
|
||||
}
|
||||
|
||||
/**
|
||||
* arguments applied to {@link #getInvokable()} during {@link Invokable#invoke(Object, Object...)}
|
||||
*
|
||||
* @param args
|
||||
* as these represent parameters, can contain nulls
|
||||
*/
|
||||
public List<Object> getArgs() {
|
||||
return args;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (o == null || getClass() != o.getClass())
|
||||
return false;
|
||||
Invocation that = Invocation.class.cast(o);
|
||||
return equal(this.interfaceType, that.interfaceType) && equal(this.invokable, that.invokable)
|
||||
&& equal(this.args, that.args);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(interfaceType, invokable, args);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return Objects.toStringHelper("").omitNullValues().add("interfaceType", interfaceType)
|
||||
.add("invokable", invokable).add("args", args.size() != 0 ? args : null).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* result of an invocation which is either successful or failed, but not both.
|
||||
*/
|
||||
@Beta
|
||||
public final static class Result {
|
||||
public static Result success(@Nullable Object result) {
|
||||
return new Result(Optional.fromNullable(result), Optional.<Throwable> absent());
|
||||
}
|
||||
|
||||
public static Result fail(Throwable throwable) {
|
||||
return new Result(Optional.absent(), Optional.of(throwable));
|
||||
}
|
||||
|
||||
private final Optional<Object> result;
|
||||
private final Optional<Throwable> throwable;
|
||||
|
||||
private Result(Optional<Object> result, Optional<Throwable> throwable) {
|
||||
this.result = checkNotNull(result, "result");
|
||||
this.throwable = checkNotNull(throwable, "throwable");
|
||||
}
|
||||
|
||||
/**
|
||||
* result of{@link Invokable#invoke(Object, Object...)}
|
||||
*/
|
||||
public Optional<Object> getResult() {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* throwable received during {@link Invokable#invoke(Object, Object...)}
|
||||
*/
|
||||
public Optional<Throwable> getThrowable() {
|
||||
return throwable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (o == null || getClass() != o.getClass())
|
||||
return false;
|
||||
Result that = Result.class.cast(o);
|
||||
return equal(this.result.orNull(), that.result.orNull())
|
||||
&& equal(this.throwable.orNull(), that.throwable.orNull());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(result.orNull(), throwable.orNull());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return string().toString();
|
||||
}
|
||||
|
||||
protected ToStringHelper string() {
|
||||
return Objects.toStringHelper("").omitNullValues().add("result", result.orNull())
|
||||
.add("throwable", throwable.orNull());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Licensed to jclouds, Inc. (jclouds) under one or more
|
||||
* contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. jclouds 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
|
||||
*
|
||||
* http://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 org.jclouds.reflect;
|
||||
|
||||
import static com.google.common.base.Objects.equal;
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
|
||||
import org.jclouds.javax.annotation.Nullable;
|
||||
|
||||
import com.google.common.annotations.Beta;
|
||||
import com.google.common.base.Objects;
|
||||
import com.google.common.base.Objects.ToStringHelper;
|
||||
import com.google.common.base.Optional;
|
||||
|
||||
/**
|
||||
* Holds the context of a successful call to {@link com.google.common.reflect.Invokable#invoke(Object, Object...)}
|
||||
*
|
||||
* @author Adrian Cole
|
||||
*/
|
||||
@Beta
|
||||
public final class InvocationSuccess {
|
||||
public static InvocationSuccess create(Invocation invocation, @Nullable Object result) {
|
||||
return new InvocationSuccess(invocation, Optional.fromNullable(result));
|
||||
}
|
||||
|
||||
private final Invocation invocation;
|
||||
private final Optional<Object> result;
|
||||
|
||||
private InvocationSuccess(Invocation invocation, Optional<Object> result) {
|
||||
this.invocation = checkNotNull(invocation, "invocation");
|
||||
this.result = checkNotNull(result, "result");
|
||||
}
|
||||
|
||||
/**
|
||||
* what was invocation
|
||||
*/
|
||||
public Invocation getInvocation() {
|
||||
return invocation;
|
||||
}
|
||||
|
||||
public Optional<Object> getResult() {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (o == null || getClass() != o.getClass())
|
||||
return false;
|
||||
InvocationSuccess that = InvocationSuccess.class.cast(o);
|
||||
return equal(this.invocation, that.invocation) && equal(this.result.orNull(), that.result.orNull());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(invocation, result.orNull());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return string().toString();
|
||||
}
|
||||
|
||||
protected ToStringHelper string() {
|
||||
return Objects.toStringHelper("").omitNullValues().add("invocation", invocation).add("result", result.orNull());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Licensed to jclouds, Inc. (jclouds) under one or more
|
||||
* contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. jclouds 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
|
||||
*
|
||||
* http://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 org.jclouds.reflect;
|
||||
|
||||
import static org.testng.Assert.assertEquals;
|
||||
import static org.testng.Assert.assertNotEquals;
|
||||
import static org.testng.Assert.assertNotNull;
|
||||
import static org.testng.Assert.assertNull;
|
||||
import static org.testng.Assert.assertTrue;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
import java.util.SortedSet;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.jclouds.reflect.Invocation.Result;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
import com.google.common.base.Function;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Adrian Cole
|
||||
*/
|
||||
@Test(singleThreaded = true)
|
||||
public class FunctionalReflectionTest {
|
||||
|
||||
/**
|
||||
* a method only has reference to its declaring type, not the interface specified to the proxy. this shows how to get
|
||||
* access to the actual proxied interface
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public void testCanAccessInterfaceTypeInsideFunction() {
|
||||
final Function<Invocation, Result> test = new Function<Invocation, Result>() {
|
||||
public Result apply(Invocation e) {
|
||||
assertEquals(e.getInvokable().getDeclaringClass(), Set.class);
|
||||
assertEquals(e.getInterfaceType(), SortedSet.class);
|
||||
return Result.success(true);
|
||||
}
|
||||
};
|
||||
FunctionalReflection.newProxy(SortedSet.class, test).add(null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test(expectedExceptions = UnsupportedOperationException.class)
|
||||
public void testNullArgsAreAllowedAndUnmodifiable() {
|
||||
final Function<Invocation, Result> test = new Function<Invocation, Result>() {
|
||||
public Result apply(Invocation e) {
|
||||
assertNotNull(e.getArgs());
|
||||
assertNull(e.getArgs().get(0));
|
||||
e.getArgs().add("foo");
|
||||
throw new AssertionError("shouldn't be able to mutate the list!");
|
||||
}
|
||||
};
|
||||
FunctionalReflection.newProxy(Set.class, test).add(null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test(expectedExceptions = UnsupportedOperationException.class)
|
||||
public void testImmutableListWhenArgsAreNotNull() {
|
||||
final Function<Invocation, Result> test = new Function<Invocation, Result>() {
|
||||
public Result apply(Invocation e) {
|
||||
assertNotNull(e.getArgs());
|
||||
assertTrue(e.getArgs() instanceof ImmutableList);
|
||||
assertEquals(e.getArgs().get(0), "foo");
|
||||
e.getArgs().add("bar");
|
||||
throw new AssertionError("shouldn't be able to mutate the list!");
|
||||
}
|
||||
};
|
||||
FunctionalReflection.newProxy(Set.class, test).add("foo");
|
||||
}
|
||||
|
||||
@Test(expectedExceptions = IOException.class, expectedExceptionsMessageRegExp = "io")
|
||||
public void testPropagatesDeclaredException() throws IOException {
|
||||
final Function<Invocation, Result> test = new Function<Invocation, Result>() {
|
||||
public Result apply(Invocation e) {
|
||||
return Result.fail(new IOException("io"));
|
||||
}
|
||||
};
|
||||
Closeable closeable = FunctionalReflection.newProxy(Closeable.class, test);
|
||||
closeable.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* for example, someone could have enabled assertions, or there could be a recoverable ServiceConfigurationError
|
||||
*/
|
||||
@Test(expectedExceptions = AssertionError.class, expectedExceptionsMessageRegExp = "assert")
|
||||
public void testPropagatesError() throws IOException {
|
||||
final Function<Invocation, Result> test = new Function<Invocation, Result>() {
|
||||
public Result apply(Invocation e) {
|
||||
return Result.fail(new AssertionError("assert"));
|
||||
}
|
||||
};
|
||||
Closeable closeable = FunctionalReflection.newProxy(Closeable.class, test);
|
||||
closeable.close();
|
||||
}
|
||||
|
||||
// TODO: coerce things like this to UncheckedTimeoutException and friends
|
||||
@Test(expectedExceptions = RuntimeException.class, expectedExceptionsMessageRegExp = ".*timeout")
|
||||
public void testWrapsDeclaredException() throws IOException {
|
||||
final Function<Invocation, Result> test = new Function<Invocation, Result>() {
|
||||
public Result apply(Invocation e) {
|
||||
return Result.fail(new TimeoutException("timeout"));
|
||||
}
|
||||
};
|
||||
Closeable closeable = FunctionalReflection.newProxy(Closeable.class, test);
|
||||
closeable.close();
|
||||
}
|
||||
|
||||
public void testToStringEqualsFunction() {
|
||||
final Function<Invocation, Result> test = new Function<Invocation, Result>() {
|
||||
public Result apply(Invocation e) {
|
||||
return Result.success("foo");
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return "bar";
|
||||
}
|
||||
};
|
||||
Closeable closeable = FunctionalReflection.newProxy(Closeable.class, test);
|
||||
assertEquals(closeable.toString(), "bar");
|
||||
}
|
||||
|
||||
public void testHashCodeDifferentiatesOnInterface() {
|
||||
final Function<Invocation, Result> test = new Function<Invocation, Result>() {
|
||||
public Result apply(Invocation e) {
|
||||
return Result.success(null);
|
||||
}
|
||||
|
||||
public int hashCode() {
|
||||
return 1111;
|
||||
}
|
||||
};
|
||||
Appendable appendable1 = FunctionalReflection.newProxy(Appendable.class, test);
|
||||
Appendable appendable2 = FunctionalReflection.newProxy(Appendable.class, test);
|
||||
assertEquals(appendable1.hashCode(), appendable2.hashCode());
|
||||
|
||||
Closeable closeable = FunctionalReflection.newProxy(Closeable.class, test);
|
||||
assertNotEquals(appendable1.hashCode(), closeable.hashCode());
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue