All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.mastfrog.http.harness.AssertionsImpl Maven / Gradle / Ivy

/*
 * The MIT License
 *
 * Copyright 2022 Tim Boudreau.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package com.mastfrog.http.harness;

import com.mastfrog.concurrent.IncrementableLatch;
import com.mastfrog.http.harness.difference.Difference;
import com.mastfrog.http.harness.difference.Differencing;
import com.mastfrog.predicates.Predicates;
import com.mastfrog.util.codec.Codec;
import static com.mastfrog.util.preconditions.Checks.notNull;
import com.mastfrog.util.preconditions.Exceptions;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Version;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Flow;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.IntPredicate;
import java.util.function.Predicate;
import java.util.function.Supplier;

/**
 * Implementation of Assertions, which intercepts callbacks from the HTTP client
 * and runs the assertions associated with the given callback (headers, body
 * chunks, body).
 */
final class AssertionsImpl implements Assertions, HttpResponse.BodyHandler, HttpResponse.BodySubscriber, Runnable {

    private static final ThreadLocal CURR_SEVERITY = ThreadLocal.withInitial(() -> FailureSeverity.FATAL);
    private final List> headerAssertions = new ArrayList<>(8);
    private final List> bodyAssertions = new ArrayList<>(8);
    private final List> chunkAssertions = new ArrayList<>(8);
    private final List> thrownAssertions = new ArrayList<>(8);
    private final List> timeoutAssertions = new ArrayList<>(1);
    private final Set> invokedAssertions = ConcurrentHashMap.newKeySet();
    private final String reqInfo;
    final Consumer resultConsumer;
    final AtomicBoolean aborted;
    private final AtomicBoolean done = new AtomicBoolean();
    private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
    private final Codec mapper;
    private final CompletableFuture future = new CompletableFuture<>();
    private final IncrementableLatch latch;
    private final Optional overallResponseTimeout;
    private final AtomicLong invokedAt = new AtomicLong();
    private volatile boolean timedOut;
    private volatile Task task;
    private volatile Flow.Subscription subscription;

    AssertionsImpl(String reqInfo, Consumer resultConsumer, AtomicBoolean aborted,
            Codec mapper, IncrementableLatch latch,
            Optional overallResponseTimeout) {
        this.reqInfo = reqInfo;
        this.resultConsumer = resultConsumer;
        this.aborted = aborted;
        this.mapper = mapper;
        this.latch = latch;
        this.overallResponseTimeout = overallResponseTimeout;
    }

    private AssertionsImpl addHeaderAssertion(Assertion a) {
        headerAssertions.add(a);
        return this;
    }

    private AssertionsImpl addBodyAssertion(Assertion a) {
        bodyAssertions.add(a);
        return this;
    }

    private AssertionsImpl addChunkAssertion(Assertion a) {
        chunkAssertions.add(a);
        return this;
    }

    private AssertionsImpl addThrownAssertion(Assertion a) {
        thrownAssertions.add(a);
        return this;
    }

    private AssertionsImpl addTimeoutAssertion(Assertion a) {
        timeoutAssertions.add(a);
        return this;
    }

    /**
     * Called by the watchdog thread to ensure we time out if nothing is
     * happening to trigger tests otherwise.
     */
    @Override
    public void run() {
        abortIfTimedOut();
    }

    AssertionsImpl launched(long when, Task task) {
        invokedAt.compareAndSet(0L, when);
        this.task = task;
        return this;
    }

    boolean isTimedOut() {
        long when = invokedAt.get();
        if (when != 0L && overallResponseTimeout.isPresent()) {
            Duration dur = overallResponseTimeout.get();
            long elapsed = System.currentTimeMillis() - when;
            boolean result = dur.toMillis() < elapsed;
            if (result) {
                onTimeout();
            }
            return result;
        }
        return false;
    }

    void onTimeout() {
        if (!timedOut) {
            timedOut = true;
            runAssertions(true, timeoutAssertions);
            Task t = task;
            if (t != null) {
                t.cancel();
            }
            pendingAssertions().forEach(a -> {
                resultConsumer.accept(a.didNotRunResult());
            });
        }
    }

    boolean abortIfTimedOut() {
        boolean result = !timedOut && isTimedOut();
        if (result) {
            task.cancel();
            Flow.Subscription sub = subscription;
            if (sub != null) {
                sub.cancel();
            }
        }
        return result;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder(reqInfo);
        Consumer> c = a -> {
            sb.append('\n');
            sb.append(" * ").append(a);
        };
        headerAssertions.forEach(c);
        chunkAssertions.forEach(c);
        bodyAssertions.forEach(c);
        timeoutAssertions.forEach(c);
        invokedAssertions.forEach(c);
        thrownAssertions.forEach(c);
        return sb.toString();
    }

    @Override
    public HttpResponse.BodySubscriber apply(HttpResponse.ResponseInfo responseInfo) {
        if (aborted.get() || abortIfTimedOut()) {
            return null;
        }
        runAssertions(responseInfo, headerAssertions);
        return this;
    }

    @Override
    public AssertionsImpl assertHeader(String header, Predicate valueTest) {
        return addHeaderAssertion(new HeaderAssertion(header, severity(), valueTest));
    }

    @Override
    public AssertionsImpl assertResponseCode(IntPredicate responseCode) {
        return addHeaderAssertion(new ResponseCodeAssertion("Response code", severity(), adapt(responseCode)));
    }

    @Override
    public AssertionsImpl assertBody(Predicate bodyTest) {
        return addBodyAssertion(new BodyAssertion<>(out -> new String(out.toByteArray(), StandardCharsets.UTF_8), "Body", severity(), bodyTest));
    }

    @Override
    public AssertionsImpl assertVersion(Predicate versionTest) {
        return addHeaderAssertion(new VersionAssertion("HTTP version", severity(),
                versionTest));
    }

    @Override
    public AssertionsImpl assertVersion(HttpClient.Version expected) {
        return addHeaderAssertion(new ExactVersionAssertion(severity(),
                expected));
    }

    @Override
    public  AssertionsImpl assertObject(String description, Class type, Predicate test) {
        return addBodyAssertion(new BodyAssertion<>(new JsonConverter<>(type, mapper),
                "Body as " + type.getSimpleName(), severity(), Predicates.namedPredicate(description, test)));
    }

    @Override
    public  Assertions assertDeserializedBodyEquals(Class type, T object) {
        return addBodyAssertion(new ObjectEqualityAssertion<>(
                new JsonConverter<>(type, mapper), severity(), object));
    }

    @Override
    @SuppressWarnings("unchecked")
    public  Assertions assertDeserializedBodyEquals(T object) {
        notNull("object", object);
        return addBodyAssertion(new ObjectEqualityAssertion<>(
                new JsonConverter<>((Class) object.getClass(), mapper), severity(), object));
    }

    @Override
    public AssertionsImpl assertTimesOut() {
        return addTimeoutAssertion(new TimeoutAssertion(true, severity()));
    }

    @Override
    public Assertions assertDoesNotTimeOut() {
        return addTimeoutAssertion(new TimeoutAssertion(false, severity()));
    }

    @Override
    public AssertionsImpl assertThrown(Class expectedFailure) {
        return addThrownAssertion(new ThrowableAssertion("Exception should be thrown",
                severity(), expectedFailure));
    }

    @Override
    public Assertions assertChunk(String desc, Predicate chunkTest) {
        return addChunkAssertion(new ChunkAssertion(desc, severity(), chunkTest));
    }

    private FailureSeverity severity() {
        return CURR_SEVERITY.get();
    }

    @Override
    public Assertions withSeverity(FailureSeverity severity, Consumer c) {
        FailureSeverity old = CURR_SEVERITY.get();
        CURR_SEVERITY.set(severity);
        try {
            c.accept(this);
        } finally {
            CURR_SEVERITY.set(old);
        }
        return this;
    }

    private Set> pendingAssertions() {
        Set> result = new LinkedHashSet<>(
                headerAssertions.size()
                + bodyAssertions.size()
                + chunkAssertions.size()
                + timeoutAssertions.size()
                + invokedAssertions.size()
                + thrownAssertions.size()
        );
        result.addAll(headerAssertions);
        result.addAll(chunkAssertions);
        result.addAll(bodyAssertions);
        result.addAll(timeoutAssertions);
        result.addAll(thrownAssertions);
        result.removeAll(invokedAssertions);
        return result;
    }

    @Override
    public CompletionStage getBody() {
        return future;
    }

    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        this.subscription = subscription;
        if (aborted.get() || abortIfTimedOut()) {
            subscription.cancel();
        } else {
            subscription.request(Long.MAX_VALUE);
        }
    }

    private  void runAssertion(Assertion a, T obj) {
        AssertionResult result;
        try {
            invokedAssertions.add(a);
            result = a.test(obj);
        } catch (Exception | Error e) {
            result = a.errorResult(e);
        }
        resultConsumer.accept(result);
    }

    private  void runAssertions(T obj, Collection> all) {
        all.forEach(a -> {
            runAssertion(a, obj);
        });
    }

    private  void runAssertions(Collection> all,
            Supplier supp) {
        all.forEach(a -> {
            runAssertion(a, supp.get());
        });
    }

    @Override
    public synchronized void onNext(List item) {
        // We really do need some lock here.
        for (ByteBuffer buf : item) {
            try {
                // DO NOT FLIP THE BUFFER HERE.  LOOKS LIKE YOU SHOULD, BUT NO.
                // The JDK's HTTP client does *not* use Buffer.slice() to give
                // you a view of just what you need - if you flip the first
                // buffer, you get the headers and not your content.
                runAssertions(chunkAssertions, buf::duplicate);
                byte[] all = new byte[buf.remaining()];
                buf.get(all);
                bytes.write(all);
            } catch (IOException ex) {
                throw new Error(ex);
            }
        }
        abortIfTimedOut();
    }

    private volatile Throwable lastThrown;

    @Override
    public void onError(Throwable throwable) {
        // The HTTP client will call this method only _after_ the flow
        // subscription is set up, which means that early timeouts and
        // other kinds of failures may never get here at all.  For that
        // reason we also call this method from the future consumer in
        // TestHarness, to make sure we notice whatever has happened
        if (lastThrown == throwable) {
            return;
        }
        lastThrown = throwable;
        if (timedOut && (throwable instanceof CancellationException || throwable instanceof IOException)) {
            // make sure
            task.cancel();
            done();
            return;
        }
        try {
            if (throwable instanceof HttpTimeoutException) {
                onTimeout();
            }
            runAssertions(throwable, thrownAssertions);
            pendingAssertions().forEach(a -> {
                resultConsumer.accept(a.errorResult(throwable));
            });
        } finally {
            done();
        }
    }

    @Override
    public synchronized void onComplete() {
        byte[] b = bytes.toByteArray();
        try {
            runAssertions(bytes, bodyAssertions);
        } finally {
            try {
                if (!timedOut) {
                    runAssertions(false, timeoutAssertions);
                }
            } finally {
                try {
                    future.complete(new String(b, StandardCharsets.UTF_8));
                } finally {
                    done();
                }
            }
        }
    }

    void done() {
        subscription = null;
        if (done.compareAndSet(false, true)) {
            latch.countDown();
        }
    }

    static class JsonConverter implements Function {

        private final Class type;
        private final Codec mapper;

        JsonConverter(Class type, Codec mapper) {
            this.type = type;
            this.mapper = mapper;
        }

        @Override
        public T apply(ByteArrayOutputStream t) {
            try {
                return mapper.readValue(t.toByteArray(), type);
            } catch (IOException ex) {
                return Exceptions.chuck(ex);
            }
        }
    }

    private static final class TimeoutAssertion extends Assertion {

        TimeoutAssertion(boolean expectation, FailureSeverity severity) {
            super(expectation
                    ? "The request should time out"
                    : "The test should not time out",
                    severity, new BooleanPredicate(expectation));
        }

        @Override
        Boolean convert(Boolean obj) {
            return obj;
        }

        private static class BooleanPredicate implements Predicate {

            private final boolean expectation;

            BooleanPredicate(boolean expectation) {
                this.expectation = expectation;
            }

            @Override
            public boolean test(Boolean t) {
                return t != null && (t.booleanValue() == expectation);
            }

            @Override
            public String toString() {
                return Boolean.toString(expectation);
            }
        }
    }

    private static final class ThrowableAssertion extends Assertion {

         ThrowableAssertion(String messageHead, FailureSeverity severity, Class type) {
            super(messageHead, severity, new IsInstancePredicate(type));
        }

        @Override
        Throwable convert(Throwable obj) {
            return obj;
        }
    }

    private static final class IsInstancePredicate implements Predicate {

        private final Class type;

        IsInstancePredicate(Class type) {
            this.type = notNull("type", type);
        }

        @Override
        public boolean test(R t) {
            return type.isInstance(t);
        }

        @Override
        public String toString() {
            return "Is instance of " + type.getName();
        }
    }

    private static final class ChunkAssertion extends Assertion {

        ChunkAssertion(String messageHead, FailureSeverity severity, Predicate test) {
            super(messageHead, severity, test);
        }

        @Override
        ByteBuffer convert(ByteBuffer obj) {
            return obj;
        }
    }

    private static final class BodyAssertion extends Assertion {

        private final Function converter;

        BodyAssertion(Function converter, String description, FailureSeverity severity, Predicate test) {
            super(description, severity, test);
            this.converter = converter;
        }

        @Override
        T convert(ByteArrayOutputStream obj) {
            return converter.apply(obj);
        }
    }

    private static final class ObjectEqualityAssertion extends Assertion implements Differencing {

        private final Function converter;
        private final T mustEqual;

        ObjectEqualityAssertion(Function converter, FailureSeverity severity, T mustEqual) {
            super("Object equality", severity, new ObjectEquality(mustEqual));
            this.mustEqual = mustEqual;
            this.converter = converter;
        }
        
        @Override
        public String toString() {
            return "equal to " + mustEqual;
        }

        @Override
        T convert(ByteArrayOutputStream obj) {
            return converter.apply(obj);
        }

        @Override
        public Map>> differences() {
            return ((Differencing) test).differences();
        }

        static class ObjectEquality implements Predicate, Differencing {

            private final T expected;
            private volatile T got;

            ObjectEquality(T expected) {
                this.expected = expected;
            }

            @Override
            public boolean test(T t) {
                boolean result = Objects.equals(expected, t);
                if (!result) {
                    got = t;
                }
                return result;
            }

            @Override
            public Map>> differences() {
                T g = got;
                got = null;
                Map>> result = Differencing.difference(expected, g);
                return result;
            }
        }
    }

    private static final class HeaderAssertion extends Assertion {

        HeaderAssertion(String headerName, FailureSeverity severity, Predicate test) {
            super(headerName, severity, test);
        }

        @Override
        String convert(HttpResponse.ResponseInfo obj) {
            return obj.headers().firstValue(messageHead).orElse(null);
        }
    }

    private static final class ResponseCodeAssertion extends Assertion {

        ResponseCodeAssertion(String messageHead, FailureSeverity severity, Predicate test) {
            super(messageHead, severity, test);
        }

        @Override
        Integer convert(HttpResponse.ResponseInfo obj) {
            return obj.statusCode();
        }
    }

    private static final class VersionAssertion extends Assertion {

        VersionAssertion(String messageHead, FailureSeverity severity, Predicate test) {
            super(messageHead, severity, test);
        }

        @Override
        HttpClient.Version convert(HttpResponse.ResponseInfo obj) {
            return obj.version();
        }
    }

    private static final class ExactVersionAssertion extends Assertion {

        ExactVersionAssertion(FailureSeverity severity, Version expected) {
            super("HTTP Version", severity, new EqualityPredicate(expected));
        }

        @Override
        HttpClient.Version convert(HttpResponse.ResponseInfo obj) {
            return obj.version();
        }
    }

    private static final class EqualityPredicate implements Predicate {

        private final T what;

        EqualityPredicate(T what) {
            this.what = what;
        }

        @Override
        public boolean test(T t) {
            return Objects.equals(what, t);
        }

        @Override
        public String toString() {
            return "equals(" + what + ")";
        }

    }

    private static Predicate adapt(IntPredicate pred) {
        return new IntPredicateAdapter(pred);
    }

    static final class IntPredicateAdapter implements Predicate {

        // And the JDK's IntPredicate doesn't implement this why?
        private final IntPredicate delegate;

        IntPredicateAdapter(IntPredicate delegate) {
            this.delegate = delegate;
        }

        @Override
        public String toString() {
            return delegate.toString();
        }

        @Override
        public boolean test(Integer t) {
            return delegate.test(t);
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy