1 /* 2 // Copyright (c) 2020 Intel Corporation 3 // 4 // Licensed under the Apache License, Version 2.0 (the "License"); 5 // you may not use this file except in compliance with the License. 6 // You may obtain a copy of the License at 7 // 8 // http://www.apache.org/licenses/LICENSE-2.0 9 // 10 // Unless required by applicable law or agreed to in writing, software 11 // distributed under the License is distributed on an "AS IS" BASIS, 12 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 // See the License for the specific language governing permissions and 14 // limitations under the License. 15 */ 16 #pragma once 17 #include <boost/asio/ip/address.hpp> 18 #include <boost/asio/ip/basic_endpoint.hpp> 19 #include <boost/asio/steady_timer.hpp> 20 #include <boost/beast/core/flat_buffer.hpp> 21 #include <boost/beast/core/tcp_stream.hpp> 22 #include <boost/beast/http/message.hpp> 23 #include <boost/beast/version.hpp> 24 #include <boost/container/devector.hpp> 25 #include <include/async_resolve.hpp> 26 27 #include <cstdlib> 28 #include <functional> 29 #include <iostream> 30 #include <memory> 31 #include <queue> 32 #include <string> 33 34 namespace crow 35 { 36 37 // It is assumed that the BMC should be able to handle 4 parallel connections 38 constexpr uint8_t maxPoolSize = 4; 39 constexpr uint8_t maxRequestQueueSize = 50; 40 constexpr unsigned int httpReadBodyLimit = 8192; 41 42 enum class ConnState 43 { 44 initialized, 45 resolveInProgress, 46 resolveFailed, 47 connectInProgress, 48 connectFailed, 49 connected, 50 sendInProgress, 51 sendFailed, 52 recvInProgress, 53 recvFailed, 54 idle, 55 closeInProgress, 56 closed, 57 suspended, 58 terminated, 59 abortConnection, 60 retry 61 }; 62 63 // We need to allow retry information to be set before a message has been sent 64 // and a connection pool has been created 65 struct RetryPolicyData 66 { 67 uint32_t maxRetryAttempts = 5; 68 std::chrono::seconds retryIntervalSecs = std::chrono::seconds(0); 69 std::string retryPolicyAction = "TerminateAfterRetries"; 70 std::string name; 71 }; 72 73 struct PendingRequest 74 { 75 std::string requestData; 76 std::function<void(bool, uint32_t, Response&)> callback; 77 RetryPolicyData retryPolicy; 78 PendingRequest( 79 const std::string& requestData, 80 const std::function<void(bool, uint32_t, Response&)>& callback, 81 const RetryPolicyData& retryPolicy) : 82 requestData(requestData), 83 callback(callback), retryPolicy(retryPolicy) 84 {} 85 }; 86 87 class ConnectionInfo : public std::enable_shared_from_this<ConnectionInfo> 88 { 89 private: 90 ConnState state = ConnState::initialized; 91 uint32_t retryCount = 0; 92 bool runningTimer = false; 93 std::string subId; 94 std::string host; 95 uint16_t port; 96 uint32_t connId; 97 98 // Retry policy information 99 // This should be updated before each message is sent 100 RetryPolicyData retryPolicy; 101 102 // Data buffers 103 std::string data; 104 boost::beast::http::request<boost::beast::http::string_body> req; 105 std::optional< 106 boost::beast::http::response_parser<boost::beast::http::string_body>> 107 parser; 108 boost::beast::flat_static_buffer<httpReadBodyLimit> buffer; 109 Response res; 110 111 // Ascync callables 112 std::function<void(bool, uint32_t, Response&)> callback; 113 crow::async_resolve::Resolver resolver; 114 boost::beast::tcp_stream conn; 115 boost::asio::steady_timer timer; 116 117 friend class ConnectionPool; 118 119 void doResolve() 120 { 121 state = ConnState::resolveInProgress; 122 BMCWEB_LOG_DEBUG << "Trying to resolve: " << host << ":" 123 << std::to_string(port) 124 << ", id: " << std::to_string(connId); 125 126 auto respHandler = 127 [self(shared_from_this())]( 128 const boost::beast::error_code ec, 129 const std::vector<boost::asio::ip::tcp::endpoint>& 130 endpointList) { 131 if (ec || (endpointList.empty())) 132 { 133 BMCWEB_LOG_ERROR << "Resolve failed: " << ec.message(); 134 self->state = ConnState::resolveFailed; 135 self->waitAndRetry(); 136 return; 137 } 138 BMCWEB_LOG_DEBUG << "Resolved " << self->host << ":" 139 << std::to_string(self->port) 140 << ", id: " << std::to_string(self->connId); 141 self->doConnect(endpointList); 142 }; 143 144 resolver.asyncResolve(host, port, std::move(respHandler)); 145 } 146 147 void doConnect( 148 const std::vector<boost::asio::ip::tcp::endpoint>& endpointList) 149 { 150 state = ConnState::connectInProgress; 151 152 BMCWEB_LOG_DEBUG << "Trying to connect to: " << host << ":" 153 << std::to_string(port) 154 << ", id: " << std::to_string(connId); 155 156 conn.expires_after(std::chrono::seconds(30)); 157 conn.async_connect( 158 endpointList, [self(shared_from_this())]( 159 const boost::beast::error_code ec, 160 const boost::asio::ip::tcp::endpoint& endpoint) { 161 if (ec) 162 { 163 BMCWEB_LOG_ERROR << "Connect " 164 << endpoint.address().to_string() << ":" 165 << std::to_string(endpoint.port()) 166 << ", id: " << std::to_string(self->connId) 167 << " failed: " << ec.message(); 168 self->state = ConnState::connectFailed; 169 self->waitAndRetry(); 170 return; 171 } 172 BMCWEB_LOG_DEBUG 173 << "Connected to: " << endpoint.address().to_string() << ":" 174 << std::to_string(endpoint.port()) 175 << ", id: " << std::to_string(self->connId); 176 self->state = ConnState::connected; 177 self->sendMessage(); 178 }); 179 } 180 181 void sendMessage() 182 { 183 state = ConnState::sendInProgress; 184 185 req.body() = data; 186 req.prepare_payload(); 187 188 // Set a timeout on the operation 189 conn.expires_after(std::chrono::seconds(30)); 190 191 // Send the HTTP request to the remote host 192 boost::beast::http::async_write( 193 conn, req, 194 [self(shared_from_this())](const boost::beast::error_code& ec, 195 const std::size_t& bytesTransferred) { 196 if (ec) 197 { 198 BMCWEB_LOG_ERROR << "sendMessage() failed: " 199 << ec.message(); 200 self->state = ConnState::sendFailed; 201 self->waitAndRetry(); 202 return; 203 } 204 BMCWEB_LOG_DEBUG << "sendMessage() bytes transferred: " 205 << bytesTransferred; 206 boost::ignore_unused(bytesTransferred); 207 208 self->recvMessage(); 209 }); 210 } 211 212 void recvMessage() 213 { 214 state = ConnState::recvInProgress; 215 216 parser.emplace(std::piecewise_construct, std::make_tuple()); 217 parser->body_limit(httpReadBodyLimit); 218 219 // Receive the HTTP response 220 boost::beast::http::async_read( 221 conn, buffer, *parser, 222 [self(shared_from_this())](const boost::beast::error_code& ec, 223 const std::size_t& bytesTransferred) { 224 if (ec) 225 { 226 BMCWEB_LOG_ERROR << "recvMessage() failed: " 227 << ec.message(); 228 self->state = ConnState::recvFailed; 229 self->waitAndRetry(); 230 return; 231 } 232 BMCWEB_LOG_DEBUG << "recvMessage() bytes transferred: " 233 << bytesTransferred; 234 BMCWEB_LOG_DEBUG << "recvMessage() data: " 235 << self->parser->get().body(); 236 237 unsigned int respCode = self->parser->get().result_int(); 238 BMCWEB_LOG_DEBUG << "recvMessage() Header Response Code: " 239 << respCode; 240 241 // 2XX response is considered to be successful 242 if ((respCode < 200) || (respCode >= 300)) 243 { 244 // The listener failed to receive the Sent-Event 245 BMCWEB_LOG_ERROR 246 << "recvMessage() Listener Failed to " 247 "receive Sent-Event. Header Response Code: " 248 << respCode; 249 self->state = ConnState::recvFailed; 250 self->waitAndRetry(); 251 return; 252 } 253 254 // Send is successful 255 // Reset the counter just in case this was after retrying 256 self->retryCount = 0; 257 258 // Keep the connection alive if server supports it 259 // Else close the connection 260 BMCWEB_LOG_DEBUG << "recvMessage() keepalive : " 261 << self->parser->keep_alive(); 262 263 // Copy the response into a Response object so that it can be 264 // processed by the callback function. 265 self->res.clear(); 266 self->res.stringResponse = self->parser->release(); 267 self->callback(self->parser->keep_alive(), self->connId, 268 self->res); 269 }); 270 } 271 272 void waitAndRetry() 273 { 274 if (retryCount >= retryPolicy.maxRetryAttempts) 275 { 276 BMCWEB_LOG_ERROR << "Maximum number of retries reached."; 277 BMCWEB_LOG_DEBUG << "Retry policy: " 278 << retryPolicy.retryPolicyAction; 279 280 // We want to return a 502 to indicate there was an error with the 281 // external server 282 res.clear(); 283 redfish::messages::operationFailed(res); 284 285 if (retryPolicy.retryPolicyAction == "TerminateAfterRetries") 286 { 287 // TODO: delete subscription 288 state = ConnState::terminated; 289 callback(false, connId, res); 290 } 291 if (retryPolicy.retryPolicyAction == "SuspendRetries") 292 { 293 state = ConnState::suspended; 294 callback(false, connId, res); 295 } 296 // Reset the retrycount to zero so that client can try connecting 297 // again if needed 298 retryCount = 0; 299 return; 300 } 301 302 if (runningTimer) 303 { 304 BMCWEB_LOG_DEBUG << "Retry timer is already running."; 305 return; 306 } 307 runningTimer = true; 308 309 retryCount++; 310 311 BMCWEB_LOG_DEBUG << "Attempt retry after " 312 << std::to_string( 313 retryPolicy.retryIntervalSecs.count()) 314 << " seconds. RetryCount = " << retryCount; 315 timer.expires_after(retryPolicy.retryIntervalSecs); 316 timer.async_wait( 317 [self(shared_from_this())](const boost::system::error_code ec) { 318 if (ec == boost::asio::error::operation_aborted) 319 { 320 BMCWEB_LOG_DEBUG 321 << "async_wait failed since the operation is aborted" 322 << ec.message(); 323 } 324 else if (ec) 325 { 326 BMCWEB_LOG_ERROR << "async_wait failed: " << ec.message(); 327 // Ignore the error and continue the retry loop to attempt 328 // sending the event as per the retry policy 329 } 330 self->runningTimer = false; 331 332 // Let's close the connection and restart from resolve. 333 self->doCloseAndRetry(); 334 }); 335 } 336 337 void doClose() 338 { 339 state = ConnState::closeInProgress; 340 boost::beast::error_code ec; 341 conn.socket().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); 342 conn.close(); 343 344 // not_connected happens sometimes so don't bother reporting it. 345 if (ec && ec != boost::beast::errc::not_connected) 346 { 347 BMCWEB_LOG_ERROR << host << ":" << std::to_string(port) 348 << ", id: " << std::to_string(connId) 349 << "shutdown failed: " << ec.message(); 350 return; 351 } 352 BMCWEB_LOG_DEBUG << host << ":" << std::to_string(port) 353 << ", id: " << std::to_string(connId) 354 << " closed gracefully"; 355 if ((state != ConnState::suspended) && (state != ConnState::terminated)) 356 { 357 state = ConnState::closed; 358 } 359 } 360 361 void doCloseAndRetry() 362 { 363 state = ConnState::closeInProgress; 364 boost::beast::error_code ec; 365 conn.socket().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); 366 conn.close(); 367 368 // not_connected happens sometimes so don't bother reporting it. 369 if (ec && ec != boost::beast::errc::not_connected) 370 { 371 BMCWEB_LOG_ERROR << host << ":" << std::to_string(port) 372 << ", id: " << std::to_string(connId) 373 << "shutdown failed: " << ec.message(); 374 return; 375 } 376 BMCWEB_LOG_DEBUG << host << ":" << std::to_string(port) 377 << ", id: " << std::to_string(connId) 378 << " closed gracefully"; 379 if ((state != ConnState::suspended) && (state != ConnState::terminated)) 380 { 381 // Now let's try to resend the data 382 state = ConnState::retry; 383 this->doResolve(); 384 } 385 } 386 387 public: 388 explicit ConnectionInfo(boost::asio::io_context& ioc, const std::string& id, 389 const std::string& destIP, const uint16_t destPort, 390 const std::string& destUri, 391 const boost::beast::http::fields& httpHeader, 392 const unsigned int connId) : 393 subId(id), 394 host(destIP), port(destPort), connId(connId), 395 req(boost::beast::http::verb::post, destUri, 11, "", httpHeader), 396 conn(ioc), timer(ioc) 397 { 398 req.set(boost::beast::http::field::host, host); 399 req.keep_alive(true); 400 } 401 }; 402 403 class ConnectionPool : public std::enable_shared_from_this<ConnectionPool> 404 { 405 private: 406 boost::asio::io_context& ioc; 407 const std::string id; 408 const std::string destIP; 409 const uint16_t destPort; 410 const std::string destUri; 411 const boost::beast::http::fields httpHeader; 412 std::vector<std::shared_ptr<ConnectionInfo>> connections; 413 boost::container::devector<PendingRequest> requestQueue; 414 415 friend class HttpClient; 416 417 // Configure a connections's data, callback, and retry info in preparation 418 // to begin sending a request 419 void setConnProps(ConnectionInfo& conn) 420 { 421 if (requestQueue.empty()) 422 { 423 BMCWEB_LOG_ERROR 424 << "setConnProps() should not have been called when requestQueue is empty"; 425 return; 426 } 427 428 auto req = requestQueue.front(); 429 conn.retryPolicy = std::move(req.retryPolicy); 430 conn.data = std::move(req.requestData); 431 conn.callback = std::move(req.callback); 432 433 BMCWEB_LOG_DEBUG << "Setting properties for connection " << conn.host 434 << ":" << std::to_string(conn.port) 435 << ", id: " << std::to_string(conn.connId) 436 << ", retry policy is \"" << conn.retryPolicy.name 437 << "\""; 438 439 // We can remove the request from the queue at this point 440 requestQueue.pop_front(); 441 } 442 443 // Configures a connection to use the specific retry policy. 444 inline void setConnRetryPolicy(ConnectionInfo& conn, 445 const RetryPolicyData& retryPolicy) 446 { 447 BMCWEB_LOG_DEBUG << destIP << ":" << std::to_string(destPort) 448 << ", id: " << std::to_string(conn.connId) 449 << " using retry policy \"" << retryPolicy.name 450 << "\""; 451 452 conn.retryPolicy = retryPolicy; 453 } 454 455 // Gets called as part of callback after request is sent 456 // Reuses the connection if there are any requests waiting to be sent 457 // Otherwise closes the connection if it is not a keep-alive 458 void sendNext(bool keepAlive, uint32_t connId) 459 { 460 auto conn = connections[connId]; 461 // Reuse the connection to send the next request in the queue 462 if (!requestQueue.empty()) 463 { 464 BMCWEB_LOG_DEBUG << std::to_string(requestQueue.size()) 465 << " requests remaining in queue for " << destIP 466 << ":" << std::to_string(destPort) 467 << ", reusing connnection " 468 << std::to_string(connId); 469 470 setConnProps(*conn); 471 472 if (keepAlive) 473 { 474 conn->sendMessage(); 475 } 476 else 477 { 478 // Server is not keep-alive enabled so we need to close the 479 // connection and then start over from resolve 480 conn->doClose(); 481 conn->doResolve(); 482 } 483 return; 484 } 485 486 // No more messages to send so close the connection if necessary 487 if (keepAlive) 488 { 489 conn->state = ConnState::idle; 490 } 491 else 492 { 493 // Abort the connection since server is not keep-alive enabled 494 conn->state = ConnState::abortConnection; 495 conn->doClose(); 496 } 497 } 498 499 void sendData(std::string& data, const RetryPolicyData& retryPolicy, 500 std::function<void(Response&)>& resHandler) 501 { 502 std::weak_ptr<ConnectionPool> weakSelf = weak_from_this(); 503 504 // Callback to be called once the request has been sent 505 auto cb = [weakSelf, resHandler](bool keepAlive, uint32_t connId, 506 Response& res) { 507 // Allow provided callback to perform additional processing of the 508 // request 509 resHandler(res); 510 511 // If requests remain in the queue then we want to reuse this 512 // connection to send the next request 513 std::shared_ptr<ConnectionPool> self = weakSelf.lock(); 514 if (!self) 515 { 516 BMCWEB_LOG_CRITICAL << self << " Failed to capture connection"; 517 return; 518 } 519 520 self->sendNext(keepAlive, connId); 521 }; 522 523 // Reuse an existing connection if one is available 524 for (unsigned int i = 0; i < connections.size(); i++) 525 { 526 auto conn = connections[i]; 527 if ((conn->state == ConnState::idle) || 528 (conn->state == ConnState::initialized) || 529 (conn->state == ConnState::closed)) 530 { 531 conn->data = std::move(data); 532 conn->callback = std::move(cb); 533 conn->retryPolicy = retryPolicy; 534 setConnRetryPolicy(*conn, retryPolicy); 535 std::string commonMsg = std::to_string(i) + " from pool " + 536 destIP + ":" + std::to_string(destPort); 537 538 if (conn->state == ConnState::idle) 539 { 540 BMCWEB_LOG_DEBUG << "Grabbing idle connection " 541 << commonMsg; 542 conn->sendMessage(); 543 } 544 else 545 { 546 BMCWEB_LOG_DEBUG << "Reusing existing connection " 547 << commonMsg; 548 conn->doResolve(); 549 } 550 return; 551 } 552 } 553 554 // All connections in use so create a new connection or add request to 555 // the queue 556 if (connections.size() < maxPoolSize) 557 { 558 BMCWEB_LOG_DEBUG << "Adding new connection to pool " << destIP 559 << ":" << std::to_string(destPort); 560 auto conn = addConnection(); 561 conn->data = std::move(data); 562 conn->callback = std::move(cb); 563 setConnRetryPolicy(*conn, retryPolicy); 564 conn->doResolve(); 565 } 566 else if (requestQueue.size() < maxRequestQueueSize) 567 { 568 BMCWEB_LOG_ERROR << "Max pool size reached. Adding data to queue."; 569 requestQueue.emplace_back(std::move(data), std::move(cb), 570 retryPolicy); 571 } 572 else 573 { 574 BMCWEB_LOG_ERROR << destIP << ":" << std::to_string(destPort) 575 << " request queue full. Dropping request."; 576 } 577 } 578 579 std::shared_ptr<ConnectionInfo>& addConnection() 580 { 581 unsigned int newId = static_cast<unsigned int>(connections.size()); 582 583 auto& ret = connections.emplace_back(std::make_shared<ConnectionInfo>( 584 ioc, id, destIP, destPort, destUri, httpHeader, newId)); 585 586 BMCWEB_LOG_DEBUG << "Added connection " 587 << std::to_string(connections.size() - 1) 588 << " to pool " << destIP << ":" 589 << std::to_string(destPort); 590 591 return ret; 592 } 593 594 public: 595 explicit ConnectionPool(boost::asio::io_context& ioc, const std::string& id, 596 const std::string& destIP, const uint16_t destPort, 597 const std::string& destUri, 598 const boost::beast::http::fields& httpHeader) : 599 ioc(ioc), 600 id(id), destIP(destIP), destPort(destPort), destUri(destUri), 601 httpHeader(httpHeader) 602 { 603 std::string clientKey = destIP + ":" + std::to_string(destPort); 604 BMCWEB_LOG_DEBUG << "Initializing connection pool for " << destIP << ":" 605 << std::to_string(destPort); 606 607 // Initialize the pool with a single connection 608 addConnection(); 609 } 610 }; 611 612 class HttpClient 613 { 614 private: 615 std::unordered_map<std::string, std::shared_ptr<ConnectionPool>> 616 connectionPools; 617 boost::asio::io_context& ioc = 618 crow::connections::systemBus->get_io_context(); 619 std::unordered_map<std::string, RetryPolicyData> retryInfo; 620 HttpClient() = default; 621 622 // Used as a dummy callback by sendData() in order to call 623 // sendDataWithCallback() 624 static void genericResHandler(Response& res) 625 { 626 BMCWEB_LOG_DEBUG << "Response handled with return code: " 627 << std::to_string(res.resultInt()); 628 }; 629 630 public: 631 HttpClient(const HttpClient&) = delete; 632 HttpClient& operator=(const HttpClient&) = delete; 633 HttpClient(HttpClient&&) = delete; 634 HttpClient& operator=(HttpClient&&) = delete; 635 ~HttpClient() = default; 636 637 static HttpClient& getInstance() 638 { 639 static HttpClient handler; 640 return handler; 641 } 642 643 // Send a request to destIP:destPort where additional processing of the 644 // result is not required 645 void sendData(std::string& data, const std::string& id, 646 const std::string& destIP, const uint16_t destPort, 647 const std::string& destUri, 648 const boost::beast::http::fields& httpHeader, 649 std::string& retryPolicyName) 650 { 651 std::function<void(Response&)> cb = genericResHandler; 652 sendDataWithCallback(data, id, destIP, destPort, destUri, httpHeader, 653 retryPolicyName, cb); 654 } 655 656 // Send request to destIP:destPort and use the provided callback to 657 // handle the response 658 void sendDataWithCallback(std::string& data, const std::string& id, 659 const std::string& destIP, 660 const uint16_t destPort, 661 const std::string& destUri, 662 const boost::beast::http::fields& httpHeader, 663 std::string& retryPolicyName, 664 std::function<void(Response&)>& resHandler) 665 { 666 std::string clientKey = destIP + ":" + std::to_string(destPort); 667 // Use nullptr to avoid creating a ConnectionPool each time 668 auto result = connectionPools.try_emplace(clientKey, nullptr); 669 if (result.second) 670 { 671 // Now actually create the ConnectionPool shared_ptr since it does 672 // not already exist 673 result.first->second = std::make_shared<ConnectionPool>( 674 ioc, id, destIP, destPort, destUri, httpHeader); 675 BMCWEB_LOG_DEBUG << "Created connection pool for " << clientKey; 676 } 677 else 678 { 679 BMCWEB_LOG_DEBUG << "Using existing connection pool for " 680 << clientKey; 681 } 682 683 // Get the associated retry policy 684 auto policy = retryInfo.try_emplace(retryPolicyName); 685 if (policy.second) 686 { 687 BMCWEB_LOG_DEBUG << "Creating retry policy \"" << retryPolicyName 688 << "\" with default values"; 689 policy.first->second.name = retryPolicyName; 690 } 691 692 // Send the data using either the existing connection pool or the newly 693 // created connection pool 694 result.first->second->sendData(data, policy.first->second, resHandler); 695 } 696 697 void setRetryConfig(const uint32_t retryAttempts, 698 const uint32_t retryTimeoutInterval, 699 const std::string& retryPolicyName) 700 { 701 // We need to create the retry policy if one does not already exist for 702 // the given retryPolicyName 703 auto result = retryInfo.try_emplace(retryPolicyName); 704 if (result.second) 705 { 706 BMCWEB_LOG_DEBUG << "setRetryConfig(): Creating new retry policy \"" 707 << retryPolicyName << "\""; 708 result.first->second.name = retryPolicyName; 709 } 710 else 711 { 712 BMCWEB_LOG_DEBUG << "setRetryConfig(): Updating retry info for \"" 713 << retryPolicyName << "\""; 714 } 715 716 result.first->second.maxRetryAttempts = retryAttempts; 717 result.first->second.retryIntervalSecs = 718 std::chrono::seconds(retryTimeoutInterval); 719 } 720 721 void setRetryPolicy(const std::string& retryPolicy, 722 const std::string& retryPolicyName) 723 { 724 // We need to create the retry policy if one does not already exist for 725 // the given retryPolicyName 726 auto result = retryInfo.try_emplace(retryPolicyName); 727 if (result.second) 728 { 729 BMCWEB_LOG_DEBUG << "setRetryPolicy(): Creating new retry policy \"" 730 << retryPolicyName << "\""; 731 result.first->second.name = retryPolicyName; 732 } 733 else 734 { 735 BMCWEB_LOG_DEBUG << "setRetryPolicy(): Updating retry policy for \"" 736 << retryPolicyName << "\""; 737 } 738 739 result.first->second.retryPolicyAction = retryPolicy; 740 } 741 }; 742 } // namespace crow 743