Completed
Push — master ( 19120d...b3609d )
by Paul
02:53
created

SIP2Client::connect()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 8
dl 0
loc 13
ccs 8
cts 8
cp 1
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 3
crap 2
1
<?php
2
3
namespace lordelph\SIP2;
4
5
/**
6
 * SIP2Client Class
7
 *
8
 * This class provides a method of communicating with an Integrated
9
 * Library System using 3M's SIP2 standard.
10
 *
11
 * @licence    https://opensource.org/licenses/MIT
12
 * @copyright  John Wohlers <[email protected]>
13
 * @copyright  Paul Dixon <[email protected]>
14
 */
15
16
use lordelph\SIP2\Exception\RuntimeException;
17
use lordelph\SIP2\Request\SIP2Request;
18
use lordelph\SIP2\Response\SIP2Response;
19
use Psr\Log\LoggerAwareInterface;
20
use Psr\Log\LoggerAwareTrait;
21
use Psr\Log\LoggerInterface;
22
use Psr\Log\NullLogger;
23
use Socket\Raw\Factory;
24
use Socket\Raw\Socket;
25
26
/**
27
 * SIP2Client provides a simple client for SIP2 library services
28
 *
29
 * In the specification, and the comments below, 'SC' (or Self Check) denotes the client, and ACS (or Automated
30
 * Circulation System) denotes the server.
31
 */
32
class SIP2Client implements LoggerAwareInterface
33
{
34
    use LoggerAwareTrait;
35
36
    //-----------------------------------------------------
37
    // request options
38
    //-----------------------------------------------------
39
40
    /**
41
     * @var array name=>value request defaults used for every request
42
     */
43
    private $default=[];
44
45
    //-----------------------------------------------------
46
    // connection handling
47
    //-----------------------------------------------------
48
49
    /** @var int maximum number of resends in the event of CRC failure */
50
    public $maxretry = 3;
51
52
    /** @var Socket */
53
    private $socket;
54
55
    /** @var Factory injectable factory for creating socket connections */
56
    private $socketFactory;
57
58
    /**
59
     * Constructor allows you to provide a PSR-3 logger, but you can also use the setLogger method
60
     * later on.
61
     *
62
     * @param LoggerInterface|null $logger
63
     */
64 5
    public function __construct(LoggerInterface $logger = null)
65
    {
66 5
        $this->logger = $logger ?? new NullLogger();
67 5
        $this->setDefault('InstitutionId', 'WohlersSIP');
68 5
    }
69
70 5
    public function setDefault($name, $value)
71
    {
72 5
        $this->default[$name] = $value;
73 5
    }
74
75
    /**
76
     * Allows an alternative socket factory to be injected. The allows us to
77
     * mock socket connections for testing
78
     *
79
     * @param Factory $factory
80
     */
81 5
    public function setSocketFactory(Factory $factory)
82
    {
83 5
        $this->socketFactory = $factory;
84 5
    }
85
86
    /**
87
     * Get the current socket factory, creating a default one if necessary
88
     * @return Factory
89
     */
90 5
    private function getSocketFactory()
91
    {
92 5
        if (is_null($this->socketFactory)) {
93
            $this->socketFactory = new Factory(); //@codeCoverageIgnore
94
        }
95 5
        return $this->socketFactory;
96
    }
97
98
99
    /**
100
     * @param SIP2Request $request
101
     * @return SIP2Response
102
     * @throws RuntimeException if server fails to produce a valid response
103
     */
104 3
    public function sendRequest(SIP2Request $request) : SIP2Response
105
    {
106 3
        foreach ($this->default as $name => $value) {
107 3
            $request->setDefault($name, $value);
108
        }
109
110 3
        $raw = $this->getRawResponse($request);
111 2
        return SIP2Response::parse($raw);
112
    }
113
114 3
    private function getRawResponse(SIP2Request $request, $depth = 0)
115
    {
116 3
        $result = '';
117 3
        $terminator = '';
118
119 3
        $message = $request->getMessageString();
120
121 3
        $this->logger->debug('SIP2: Sending SIP2 request '.trim($message));
122 3
        $this->socket->write($message);
123
124 3
        $this->logger->debug('SIP2: Request Sent, Reading response');
125
126 3
        while ($terminator != "\x0D") {
127
            //@codeCoverageIgnoreStart
128
            try {
129
                $terminator = $this->socket->recv(1, 0);
130
            } catch (\Exception $e) {
131
                break;
132
            }
133
            //@codeCoverageIgnoreEnd
134
135 3
            $result = $result . $terminator;
136
        }
137
138 3
        $this->logger->info("SIP2: result={$result}");
139
140
        // test message for CRC validity
141 3
        if (SIP2Response::checkCRC($result)) {
142 2
            $this->logger->debug("SIP2: Message from ACS passed CRC check");
143
        } else {
144
            //CRC check failed, we resend the request
145 2
            if ($depth < $this->maxretry) {
146 2
                $depth++;
147 2
                $this->logger->warning("SIP2: Message failed CRC check, retry {$depth})");
148 2
                $result = $this->getRawResponse($request, $depth);
149
            } else {
150 1
                $errMsg="SIP2: Failed to get valid CRC after {$this->maxretry} retries.";
151 1
                $this->logger->critical($errMsg);
152 1
                throw new RuntimeException($errMsg);
153
            }
154
        }
155
156 2
        return $result;
157
    }
158
159
    /**
160
     * Connect to ACS via SIP2
161
     *
162
     * The $bind parameter can be useful where a machine which has multiple outbound connections and its important
163
     * to control which one is used (normally because the remote SIP2 service is firewalled to particular IPs
164
     *
165
     * @param string $address ip:port of remote SIP2 service
166
     * @param string|null $bind local ip to bind socket to
167
     * @param int $timeout number of seconds to allow for connection to succeed
168
     */
169 5
    public function connect($address, $bind = null, $timeout = 15)
170
    {
171 5
        $this->logger->debug("SIP2Client: Attempting connection to $address");
172
173
        try {
174 5
            $this->socket = $this->createClient($address, $bind, $timeout);
175 1
        } catch (\Exception $e) {
176 1
            $this->socket = null;
177 1
            $this->logger->error("SIP2Client: Failed to connect: " . $e->getMessage());
178 1
            throw new RuntimeException("Connection failure", 0, $e);
179
        }
180
181 4
        $this->logger->debug("SIP2Client: connected");
182 4
    }
183
184
    /**
185
     * A 'missing' factory method which allows us to establish a socket bound to a particular interface
186
     * @param $address
187
     * @param $bind
188
     * @param $timeout
189
     * @return Socket
190
     * @throws \Exception
191
     */
192 5
    private function createClient($address, $bind, $timeout) : Socket
193
    {
194 5
        $factory = $this->getSocketFactory();
195
196 5
        $socket = $factory->createFromString($address, $scheme);
197
198
        try {
199 5
            if (!empty($bind)) {
200 1
                $this->logger->debug("SIP2Client: binding socket to $bind");
201 1
                $socket->bind($bind);
202
            }
203
204 5
            if ($timeout === null) {
205
                $socket->connect($address);
206
            } else {
207
                // connectTimeout enables non-blocking mode, so turn blocking on again
208 5
                $socket->connectTimeout($address, $timeout);
209 4
                $socket->setBlocking(true);
210
            }
211 1
        } catch (\Exception $e) {
212 1
            $socket->close();
213 1
            throw $e;
214
        }
215
216 4
        return $socket;
217
    }
218
219
220
    /**
221
     * Disconnect from ACS
222
     */
223 1
    public function disconnect()
224
    {
225 1
        $this->socket->close();
226 1
        $this->socket = null;
227 1
    }
228
}
229