Skip to content

Commit

Permalink
MemoryTape no longer maintains a read position so it is thread safe. F…
Browse files Browse the repository at this point in the history
…ixes #57
  • Loading branch information
robfletcher committed Aug 6, 2012
1 parent 55bd747 commit 467c884
Show file tree
Hide file tree
Showing 9 changed files with 94 additions and 69 deletions.
18 changes: 5 additions & 13 deletions src/main/groovy/co/freeside/betamax/Tape.groovy
Expand Up @@ -50,31 +50,23 @@ interface Tape {
int size() int size()


/** /**
* Attempts to find a recorded interaction on the tape that matches the supplied request's method and URI. If the * Attempts to find a recorded interaction on the tape that matches the supplied request.
* method succeeds then subsequent calls to `play` will play back the response that was found.
* @param request the HTTP request to match. * @param request the HTTP request to match.
* @return `true` if a matching recorded interaction was found, `false` otherwise. * @return `true` if a matching recorded interaction was found, `false` otherwise.
*/ */
boolean seek(Request request) boolean seek(Request request)


/**
* Resets the tape so that no recorded interaction is ready to play. Subsequent calls to `play` will throw
* `IllegalStateException` until a successful call to `seek` is made.
*/
void reset()

/** /**
* Plays back a previously recorded interaction to the supplied response. Status, headers and entities are copied * Plays back a previously recorded interaction to the supplied response. Status, headers and entities are copied
* from the recorded interaction to `response`. * from the recorded interaction to `response`.
* @param response the HTTP response to populate. * @param response the HTTP response to populate.
* @throws IllegalStateException if no recorded interaction has been found by a previous call to `seek`. * @throws IllegalStateException if no matching recorded interaction exists.
*/ */
void play(Response response) void play(Request request, Response response)


/** /**
* Records a new interaction to the tape. If the tape is currently positioned to read a recorded interaction due to * Records a new interaction to the tape. If `request` matches an existing interaction this method will overwrite
* a previous successful `seek` call then this method will overwrite the existing recorded interaction. Otherwise * it. Otherwise the newly recorded interaction is appended to the tape.
* the newly recorded interaction is appended to the tape.
* @param request the request to record. * @param request the request to record.
* @param response the response to record. * @param response the response to record.
* @throws UnsupportedOperationException if this `Tape` implementation is not writable. * @throws UnsupportedOperationException if this `Tape` implementation is not writable.
Expand Down
Expand Up @@ -43,7 +43,7 @@ class RecordAndPlaybackProxyInterceptor implements VetoingProxyInterceptor {
} else if (tape.seek(request) && tape.isReadable()) { } else if (tape.seek(request) && tape.isReadable()) {
log.info "playing back from tape '$tape.name'..." log.info "playing back from tape '$tape.name'..."
response.addHeader(X_BETAMAX, "PLAY") response.addHeader(X_BETAMAX, "PLAY")
tape.play(response) tape.play(request, response)
true true
} else if (!tape.isWritable()) { } else if (!tape.isWritable()) {
response.setError(HTTP_FORBIDDEN, "Tape is read-only") response.setError(HTTP_FORBIDDEN, "Tape is read-only")
Expand Down
40 changes: 21 additions & 19 deletions src/main/groovy/co/freeside/betamax/tape/MemoryTape.groovy
Expand Up @@ -35,7 +35,6 @@ class MemoryTape implements Tape {
List<RecordedInteraction> interactions = [] List<RecordedInteraction> interactions = []
private TapeMode mode = READ_WRITE private TapeMode mode = READ_WRITE
private Comparator<Request>[] matchRules = [method, uri] private Comparator<Request>[] matchRules = [method, uri]
private int position = -1


void setMode(TapeMode mode) { void setMode(TapeMode mode) {
this.mode = mode this.mode = mode
Expand All @@ -57,23 +56,20 @@ class MemoryTape implements Tape {
interactions.size() interactions.size()
} }



boolean seek(Request request) { boolean seek(Request request) {
def requestMatcher = new RequestMatcher(request, matchRules) def requestMatcher = new RequestMatcher(request, matchRules)
position = interactions.findIndexOf { interactions.any {
requestMatcher.matches(it.request) requestMatcher.matches(it.request)
} }
position >= 0
} }


void reset() { void play(Request request, Response response) {
position = -1 if (!mode.readable) throw new IllegalStateException("the tape is not readable")
}


void play(Response response) { int position = findMatch(request)
if (!mode.readable) { if (position < 0) {
throw new IllegalStateException("the tape is not readable") throw new IllegalStateException("no matching recording found")
} else if (position < 0) {
throw new IllegalStateException("the tape is not ready to play")
} else { } else {
def interaction = interactions[position] def interaction = interactions[position]
response.status = interaction.response.status response.status = interaction.response.status
Expand All @@ -94,15 +90,14 @@ class MemoryTape implements Tape {
} }


void record(Request request, Response response) { void record(Request request, Response response) {
if (mode.writable) { if (!mode.writable) throw new IllegalStateException("the tape is not writable")
def interaction = new RecordedInteraction(request: recordRequest(request), response: recordResponse(response), recorded: new Date())
if (position >= 0) { def interaction = new RecordedInteraction(request: recordRequest(request), response: recordResponse(response), recorded: new Date())
interactions[position] = interaction int position = findMatch(request)
} else { if (position >= 0) {
interactions << interaction interactions[position] = interaction
}
} else { } else {
throw new IllegalStateException("the tape is not writable") interactions << interaction
} }
} }


Expand All @@ -111,6 +106,13 @@ class MemoryTape implements Tape {
"Tape[$name]" "Tape[$name]"
} }


private int findMatch(Request request) {
def requestMatcher = new RequestMatcher(request, matchRules)
interactions.findIndexOf {
requestMatcher.matches(it.request)
}
}

private static RecordedRequest recordRequest(Request request) { private static RecordedRequest recordRequest(Request request) {
def clone = new RecordedRequest() def clone = new RecordedRequest()
clone.method = request.method clone.method = request.method
Expand Down
Expand Up @@ -54,9 +54,6 @@ class ProxyRecordAndPlaybackSpec extends Specification {


then: then:
recorder.tape.size() == 1 recorder.tape.size() == 1

cleanup:
recorder.tape.reset()
} }


@Timeout(10) @Timeout(10)
Expand Down
Expand Up @@ -50,7 +50,7 @@ class RecordAndPlaybackProxyInterceptorSpec extends Specification {
veto veto


and: and:
1 * tape.play(response) 1 * tape.play(request, response)
} }


def "vetos a request and sets failing response code if no tape is inserted"() { def "vetos a request and sets failing response code if no tape is inserted"() {
Expand Down
Expand Up @@ -67,11 +67,11 @@ interactions:
def tape = YamlTape.readFrom(new StringReader(yaml)) def tape = YamlTape.readFrom(new StringReader(yaml))


and: and:
def request = new BasicRequest("GET", "http://freeside.co/betamax")
def response = new BasicResponse(HTTP_OK, "OK") def response = new BasicResponse(HTTP_OK, "OK")


when: when:
tape.seek(new BasicRequest("GET", "http://freeside.co/betamax")) tape.play(request, response)
tape.play(response)


then: then:
def expected = encoder ? encoder.encode("\u00a3", charset) : "\u00a3".getBytes(charset) def expected = encoder ? encoder.encode("\u00a3", charset) : "\u00a3".getBytes(charset)
Expand Down
Expand Up @@ -63,11 +63,11 @@ interactions:
def tape = YamlTape.readFrom(new StringReader(yaml)) def tape = YamlTape.readFrom(new StringReader(yaml))


and: and:
def request = new BasicRequest("GET", "http://freeside.co/betamax")
def response = new BasicResponse(200, "OK") def response = new BasicResponse(200, "OK")


when: when:
tape.seek(new BasicRequest("GET", "http://freeside.co/betamax")) tape.play(request, response)
tape.play(response)


then: then:
response.getHeader(CONTENT_ENCODING) == encoding response.getHeader(CONTENT_ENCODING) == encoding
Expand Down
@@ -0,0 +1,59 @@
package co.freeside.betamax.tape

import co.freeside.betamax.Tape
import co.freeside.betamax.proxy.Request
import co.freeside.betamax.util.message.BasicRequest
import co.freeside.betamax.util.message.BasicResponse
import spock.lang.Issue
import spock.lang.Shared
import spock.lang.Specification

import java.util.concurrent.CountDownLatch

import static java.util.concurrent.TimeUnit.SECONDS

@Issue('https://github.com/robfletcher/betamax/issues/57')
class MultiThreadedTapeAccessSpec extends Specification {

@Shared Tape tape = new MemoryTape(name: 'multi_threaded_tape_access_spec')

void 'the correct response is replayed to each thread'() {
given: 'a number of requests'
List<Request> requests = (0..<threads).collect { i ->
def request = new BasicRequest('GET', "http://example.com/$i")
request.addHeader('X-Thread', i.toString())
request
}
println requests

and: 'some existing responses on tape'
requests.eachWithIndex { request, i ->
def response = new BasicResponse(status: 200, reason: 'OK', body: i.toString())
tape.record(request, response)
}

when: 'requests are replayed concurrently'
def finished = new CountDownLatch(threads)
def responses = [:]
requests.eachWithIndex { request, i ->
Thread.start {
def response = new BasicResponse()
tape.play(request, response)
responses[requests[i].getHeader('X-Thread')] = response.bodyAsText.text
finished.countDown()
}
}

then: 'all threads complete'
finished.await(1, SECONDS)

and: 'the correct response is returned to each request'
responses.every { key, value ->
key == value
}

where:
threads = 10
}

}
31 changes: 3 additions & 28 deletions src/test/groovy/co/freeside/betamax/tape/TapeSpec.groovy
Expand Up @@ -30,13 +30,12 @@ class TapeSpec extends Specification {
} }


def cleanup() { def cleanup() {
tape.reset()
tape.mode = READ_WRITE tape.mode = READ_WRITE
} }


def "reading from an empty tape throws an exception"() { def "reading from an empty tape throws an exception"() {
when: "an empty tape is played" when: "an empty tape is played"
tape.play(new BasicResponse()) tape.play(getRequest, new BasicResponse())


then: "an exception is thrown" then: "an exception is thrown"
thrown IllegalStateException thrown IllegalStateException
Expand All @@ -63,9 +62,6 @@ class TapeSpec extends Specification {
} }


def "can overwrite a recorded interaction"() { def "can overwrite a recorded interaction"() {
given: "the tape is ready to play"
tape.seek(getRequest)

when: "a recording is made" when: "a recording is made"
tape.record(getRequest, plainTextResponse) tape.record(getRequest, plainTextResponse)


Expand Down Expand Up @@ -93,11 +89,8 @@ class TapeSpec extends Specification {
given: "an http response to play back to" given: "an http response to play back to"
def response = new BasicResponse() def response = new BasicResponse()


and: "the tape is ready to play"
tape.seek(getRequest)

when: "the tape is played" when: "the tape is played"
tape.play(response) tape.play(getRequest, response)


then: "the recorded response data is copied onto the response" then: "the recorded response data is copied onto the response"
response.status == plainTextResponse.status response.status == plainTextResponse.status
Expand All @@ -119,30 +112,12 @@ class TapeSpec extends Specification {
interaction.request.body == request.bodyAsText.text interaction.request.body == request.bodyAsText.text
} }


def "can reset the tape position"() {
given: "the tape is ready to read"
tape.seek(getRequest)

when: "the tape position is reset"
tape.reset()

and: "the tape is played"
tape.play(new BasicResponse())

then: "an exception is thrown"
def e = thrown(IllegalStateException)
e.message == "the tape is not ready to play"
}

def "a write-only tape cannot be read from"() { def "a write-only tape cannot be read from"() {
given: "the tape is put into write-only mode" given: "the tape is put into write-only mode"
tape.mode = WRITE_ONLY tape.mode = WRITE_ONLY


and: "the tape is ready to read"
tape.seek(getRequest)

when: "the tape is played" when: "the tape is played"
tape.play(new BasicResponse()) tape.play(getRequest, new BasicResponse())


then: "an exception is thrown" then: "an exception is thrown"
def e = thrown(IllegalStateException) def e = thrown(IllegalStateException)
Expand Down

0 comments on commit 467c884

Please sign in to comment.