Replace boost thread with std thread; update rest book example; move soap tutorials to wiki.

master
Adam Gu 7 years ago
parent 6475a3fd35
commit 33f2b84f8c

@ -2,6 +2,8 @@
A lightweight C++ REST and SOAP client and server library based on *Boost.Asio*. A lightweight C++ REST and SOAP client and server library based on *Boost.Asio*.
Please turn to our [Wiki](https://github.com/sprinfall/webcc/wiki) for more tutorials and guides.
## Quick Start ## Quick Start
### REST Server ### REST Server

@ -1,101 +0,0 @@
# SOAP Client Tutorial
Firstly, please install **SoapUI** if you don't have it. We need SoapUI to generate sample requests for each web service operation. The open source version is good enough.
Take the calculator web service provided by ParaSoft as an example. Download the WSDL from http://ws1.parasoft.com/glue/calculator.wsdl, create a SOAP project within SoapUI (remember to check "**Create sample requests for all operations?**"), you will see the sample request for "add" operation as the following:
```xml
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cal="http://www.parasoft.com/wsdl/calculator/">
<soapenv:Header/>
<soapenv:Body>
<cal:add>
<cal:x>1</cal:x>
<cal:y>2</cal:y>
</cal:add>
</soapenv:Body>
</soapenv:Envelope>
```
In order to call the "add" operation, we have to send a HTTP request with the above SOAP envelope as the content. Let's see how to do this with *webcc*.
Firstly, create a class `CalcClient` which is derived from `webcc::SoapClient`:
```cpp
#include <string>
#include "webcc/soap_client.h"
class CalcClient : public webcc::SoapClient {
public:
CalcClient() {
Init();
}
```
Initialize the URL, host, port, etc. in `Init()`:
```cpp
private:
void Init() {
url_ = "/glue/calculator";
host_ = "ws1.parasoft.com";
port_ = ""; // Default to "80".
service_ns_ = { "cal", "http://www.parasoft.com/wsdl/calculator/" };
result_name_ = "Result";
}
```
Because four calculator operations (*add*, *subtract*, *multiply* and *divide*) all have two parameters, we create a wrapper for `SoapClient::Call()`, name is as `Calc`:
```cpp
bool Calc(const std::string& operation,
const std::string& x_name,
const std::string& y_name,
double x,
double y,
double* result) {
// Prepare parameters.
std::vector<webcc::Parameter> parameters{
{ x_name, x },
{ y_name, y }
};
// Make the call.
std::string result_str;
webcc::Error error = Call(operation, std::move(parameters), &result_str);
// Error handling if any.
if (error != webcc::kNoError) {
std::cerr << "Error: " << error;
std::cerr << ", " << webcc::GetErrorMessage(error) << std::endl;
return false;
}
// Convert the result from string to double.
try {
*result = boost::lexical_cast<double>(result_str);
} catch (boost::bad_lexical_cast&) {
return false;
}
return true;
}
```
Note that the local parameters are moved (with `std::move()`). This is to avoid expensive copy of long string parameters, e.g., XML strings.
Finally, we implement the four operations simply as the following:
```cpp
bool Add(double x, double y, double* result) {
return Calc("add", "x", "y", x, y, result);
}
bool Subtract(double x, double y, double* result) {
return Calc("subtract", "x", "y", x, y, result);
}
bool Multiply(double x, double y, double* result) {
return Calc("multiply", "x", "y", x, y, result);
}
bool Divide(double x, double y, double* result) {
return Calc("divide", "numerator", "denominator", x, y, result);
}
```
See? It's not that complicated. Check folder ***demo/calculator_client*** for the full example.

@ -1,153 +0,0 @@
# SOAP Client Tutorial (zh-CN)
## 背景
首先,[gSoap](http://www.cs.fsu.edu/~engelen/soap.html) 肯定是个不错的选择,但是如果你的程序要调用多个 Web Services即有多个 WSDLgSoap 会比较麻烦。还有一个问题就是gSoap 从 WSDL 自动生成的代码实在是太难用了。当然这些都不是什么问题真在的问题是许可证LicensegSoap 用在商业产品中是要收费的。
公司比较穷,舍不得花钱买 gSoap但是 C++ 调 Web Service 还真没什么好办法。尝试了五六个半死不活的库后,最终锁定了 [WWSAPI](https://msdn.microsoft.com/en-us/library/windows/desktop/dd430435%28v=vs.85%29.aspx)Windows Web Services API
WWSAPI 的官方文档经常让人摸不着头脑没有完整的示例给出一段代码常常需要几经调整才能使用。WWSAPI 自动生成的代码,是纯 C 的接口,在难用程度上,较 gSoap 有过之而无不及。在消息参数上,它强制使用双字节 Unicode我们的输入输出都是 UTF8 的 `std::string`于是莫名地多出很多编码转换。WWSAPI 需要你手动分配堆heap需要你指定消息的缓冲大小而最严重的问题是它不够稳定特别是在子线程里调用时莫名其妙连接就会断掉。
于是,我就动手自己写了个 [webcc](https://github.com/sprinfall/webcc)。
一开始 webcc 只支持 SOAP名字就叫 csoap后来支持了 REST于是改名为 webcc取 Web C++ 的意思。
## 原理
Webcc 没有提供从 WSDL 自动生成代码的功能,一来是因为这一过程太复杂了,二来是自动生成的代码一般都不好用。所以 webcc 最好搭配 [SoapUI](https://www.soapui.org) 一起使用。SoapUI 可以帮助我们为每一个 Web Service 操作operation生成请求的样例基于请求样例就很容易发起调用了也避免了直接阅读 WSDL。
下面以 ParaSoft 提供的 [Calculator](http://ws1.parasoft.com/glue/calculator.wsdl) 为例,首先下载 WSDL然后在 SoapUI 里创建一个 SOAP 项目,记得勾上 "Create sample requests for all operations?" 这个选项,然后就能看到下面这样的请求样例了:
```xml
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cal="http://www.parasoft.com/wsdl/calculator/">
<soapenv:Header/>
<soapenv:Body>
<cal:add>
<cal:x>1</cal:x>
<cal:y>2</cal:y>
</cal:add>
</soapenv:Body>
</soapenv:Envelope>
```
这个操作是 "add"有两个参数x 和 y。此外值得注意的还有 XML namespace比如 `xmlns:cal="http://www.parasoft.com/wsdl/calculator/"`
要调用这个 "add" 操作,只要发一个 HTTP 请求,并把上面这个 SOAP Envelope 作为请求的 Content。在 SoapUI 里把 Request 切换到 “Raw" 模式,就可以看到下面这样完整的 HTTP 请求:
```
POST http://ws1.parasoft.com/glue/calculator HTTP/1.1
Accept-Encoding: gzip,deflate
Content-Type: text/xml;charset=UTF-8
SOAPAction: "add"
Content-Length: 300
Host: ws1.parasoft.com
Connection: Keep-Alive
User-Agent: Apache-HttpClient/4.1.1 (java 1.5)
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cal="http://www.parasoft.com/wsdl/calculator/">
<soapenv:Header/>
<soapenv:Body>
<cal:add>
<cal:x>1</cal:x>
<cal:y>1</cal:y>
</cal:add>
</soapenv:Body>
</soapenv:Envelope>
```
所以 webcc 所做的,只不过是跟 `ws1.parasoft.com` 建立 TCP Socket 连接,然后发送上面这段内容而已。
## 用法
首先,创建一个类 `CalcClient`,继承自 `webcc::SoapClient`
```cpp
#include <string>
#include "webcc/soap_client.h"
class CalcClient : public webcc::SoapClient {
public:
CalcClient() {
Init();
}
```
`Init()` 函数里,初始化 URL、host、port 等等:
```cpp
private:
void Init() {
url_ = "/glue/calculator";
host_ = "ws1.parasoft.com";
port_ = ""; // Default to "80".
service_ns_ = { "cal", "http://www.parasoft.com/wsdl/calculator/" };
result_name_ = "Result";
}
```
由于四个计算器操作(*add*, *subtract*, *multiply**divide*)都一致的具有两个参数,我们可以稍微封装一下,弄一个辅助函数叫 `Calc`
```cpp
bool Calc(const std::string& operation,
const std::string& x_name,
const std::string& y_name,
double x,
double y,
double* result) {
// Prepare parameters.
std::vector<webcc::Parameter> parameters{
{ x_name, x },
{ y_name, y }
};
// Make the call.
std::string result_str;
webcc::Error error = Call(operation, std::move(parameters), &result_str);
// Error handling if any.
if (error != webcc::kNoError) {
std::cerr << "Error: " << error;
std::cerr << ", " << webcc::GetErrorMessage(error) << std::endl;
return false;
}
// Convert the result from string to double.
try {
*result = boost::lexical_cast<double>(result_str);
} catch (boost::bad_lexical_cast&) {
return false;
}
return true;
}
```
值得注意的是作为局部变量的参数parameters利用了 C++11 的 Move 语义,避免了额外的拷贝开销。
当参数为很长的字符串时(比如 XML string这一点特别有用。
最后,四个操作就是简单的转调 `Calc` 而已:
```cpp
bool Add(double x, double y, double* result) {
return Calc("add", "x", "y", x, y, result);
}
bool Subtract(double x, double y, double* result) {
return Calc("subtract", "x", "y", x, y, result);
}
bool Multiply(double x, double y, double* result) {
return Calc("multiply", "x", "y", x, y, result);
}
bool Divide(double x, double y, double* result) {
return Calc("divide", "numerator", "denominator", x, y, result);
}
```
## 局限
当然webcc 有很多局限,比如:
- 只支持 `int`, `double`, `bool``string` 这几种参数类型;
- 只支持 UTF-8 编码的消息内容;
- 一次调用一个连接;
- 连接是同步(阻塞)模式,可以指定 timeout缺省为 15s
## 依赖
在实现上webcc 有下面这些依赖:
- Boost 1.66+
- XML 解析和构造基于 pugixml
- 构建系统是 CMake应该可以很方便地集成到其他 C++ 项目中。

@ -1,137 +0,0 @@
# SOAP Server Tutorial
Suppose you want to provide a calculator web service just like the one from [ParaSoft](http://ws1.parasoft.com/glue/calculator.wsdl).
Firstly, create a class `CalcService` which is derived from `webcc::SoapService`, override the `Handle` method:
```cpp
// calc_service.h
#include "webcc/soap_service.h"
class CalcService : public webcc::SoapService {
public:
bool Handle(const webcc::SoapRequest& soap_request,
webcc::SoapResponse* soap_response) override;
};
```
The `Handle` method has two parameters, one for request (input), one for response (output).
The implementation is quite straightforward:
- Get operation (e.g., add) from request;
- Get parameters (e.g., x and y) from request;
- Calculate the result.
- Set namespaces, result name and so on to response.
- Set result to response.
```cpp
// calc_service.cpp
#include "calc_service.h"
#include "boost/lexical_cast.hpp"
#include "webcc/soap_request.h"
#include "webcc/soap_response.h"
bool CalcService::Handle(const webcc::SoapRequest& soap_request,
webcc::SoapResponse* soap_response) {
try {
if (soap_request.operation() == "add") {
double x = boost::lexical_cast<double>(soap_request.GetParameter("x"));
double y = boost::lexical_cast<double>(soap_request.GetParameter("y"));
double result = x + y;
soap_response->set_soapenv_ns(webcc::kSoapEnvNamespace);
soap_response->set_service_ns({ "cal", "http://www.example.com/calculator/" });
soap_response->set_operation(soap_request.operation());
soap_response->set_result_name("Result");
soap_response->set_result(std::to_string(result));
return true;
}
// Other operations ...
} catch (boost::bad_lexical_cast&) {
// ...
}
return false;
}
```
Next step, create a `SoapServer` and register `CalcService` to it with a URL.
```cpp
int main(int argc, char* argv[]) {
// Check argc and argv ...
unsigned short port = std::atoi(argv[1]);
// Number of worker threads.
std::size_t workers = 2;
try {
webcc::SoapServer server(port, workers);
server.RegisterService(std::make_shared<CalcService>(), "/calculator");
server.Run();
} catch (std::exception& e) {
std::cerr << "Exception: " << e.what() << std::endl;
return 1;
}
return 0;
}
```
The server is created with a **port number** which will be listened on to **asynchnously** accept client connections. The connections will be firstly put into a **queue** and then processed by the **worker threads**. The number of worker threads is determined by the `workers` parameter.
When register service, the URL is what the clients will put in the HTTP request to access your service:
```
POST /calculator HTTP/1.1
```
Registering multiple services to a server is allowed, but the URL must be unique for each service.
To invoke the `add` operation of the calculator service, an example of the client HTTP request would be:
```
POST /calculator HTTP/1.1
Content-Type: text/xml; charset=utf-8
Content-Length: 263
Host: localhost:8080
SOAPAction: add
<?xml version="1.0"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<ser:add xmlns:ser="http://www.example.com/calculator/">
<ser:x>1.000000</ser:x>
<ser:y>2.000000</ser:y>
</ser:add>
</soap:Body>
</soap:Envelope>
```
And the HTTP response is:
```
HTTP/1.1 200 OK
Content-Type: text/xml; charset=utf-8
Content-Length: 262
<?xml version="1.0"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<cal:addResponse xmlns:cal="http://www.example.com/calculator/">
<cal:Result>3.000000</cal:Result>
</cal:addResponse>
</soap:Body>
</soap:Envelope>
```
See [example/soap_calc_server](https://github.com/sprinfall/webcc/tree/master/example/soap_calc_server) for the full example.

@ -20,12 +20,24 @@
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Write a JSON object to string. static std::string JsonToString(const Json::Value& json) {
std::string JsonToString(const Json::Value& json) {
Json::StreamWriterBuilder builder; Json::StreamWriterBuilder builder;
return Json::writeString(builder, json); return Json::writeString(builder, json);
} }
static Json::Value StringToJson(const std::string& str) {
Json::Value json;
Json::CharReaderBuilder builder;
std::stringstream stream(str);
std::string errs;
if (!Json::parseFromStream(builder, stream, &json, &errs)) {
std::cerr << errs << std::endl;
}
return json;
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
class BookClientBase { class BookClientBase {
@ -78,26 +90,25 @@ public:
return true; return true;
} }
bool CreateBook(const std::string& id, bool CreateBook(const std::string& title, double price, std::string* id) {
const std::string& title,
double price) {
PrintSeparateLine(); PrintSeparateLine();
std::cout << "CreateBook: " << id << ", " << title << ", " << price std::cout << "CreateBook: " << title << ", " << price << std::endl;
<< std::endl;
Json::Value json(Json::objectValue); Json::Value req_json(Json::objectValue);
json["id"] = id; req_json["title"] = title;
json["title"] = title; req_json["price"] = price;
json["price"] = price;
if (!rest_client_.Post("/books", JsonToString(json))) { if (!rest_client_.Post("/books", JsonToString(req_json))) {
PrintError(); PrintError();
return false; return false;
} }
std::cout << rest_client_.response_status() << std::endl; std::cout << rest_client_.response_status() << std::endl;
return true; Json::Value rsp_json = StringToJson(rest_client_.response_content());
*id = rsp_json["id"].asString();
return !id->empty();
} }
}; };
@ -123,8 +134,7 @@ public:
return true; return true;
} }
bool UpdateBook(const std::string& id, bool UpdateBook(const std::string& id, const std::string& title,
const std::string& title,
double price) { double price) {
PrintSeparateLine(); PrintSeparateLine();
std::cout << "UpdateBook: " << id << ", " << title << ", " << price std::cout << "UpdateBook: " << id << ", " << title << ", " << price
@ -187,12 +197,14 @@ int main(int argc, char* argv[]) {
BookDetailClient detail_client(host, port, timeout_seconds); BookDetailClient detail_client(host, port, timeout_seconds);
list_client.ListBooks(); list_client.ListBooks();
list_client.CreateBook("1", "1984", 12.3);
detail_client.GetBook("1"); std::string id;
detail_client.UpdateBook("1", "1Q84", 32.1); list_client.CreateBook("1984", 12.3, &id);
detail_client.GetBook("1");
detail_client.DeleteBook("1"); detail_client.GetBook(id);
detail_client.UpdateBook(id, "1Q84", 32.1);
detail_client.GetBook(id);
detail_client.DeleteBook(id);
list_client.ListBooks(); list_client.ListBooks();

@ -2,8 +2,8 @@
#include <iostream> #include <iostream>
#include <list> #include <list>
#include <thread>
#include "boost/thread/thread.hpp"
#include "json/json.h" #include "json/json.h"
#include "webcc/logger.h" #include "webcc/logger.h"
@ -42,11 +42,11 @@ static bool JsonToBook(const std::string& json, Book* book) {
// Return all books as a JSON array. // Return all books as a JSON array.
// TODO: Support query parameters. // TODO: Support query parameters.
bool BookListService::Get(const webcc::UrlQuery& /* query */, bool BookListService::Get(const webcc::UrlQuery& /*query*/,
std::string* response_content) { std::string* response_content) {
if (sleep_seconds_ > 0) { if (sleep_seconds_ > 0) {
LOG_INFO("Sleep %d seconds...", sleep_seconds_); LOG_INFO("Sleep %d seconds...", sleep_seconds_);
boost::this_thread::sleep_for(boost::chrono::seconds(sleep_seconds_)); std::this_thread::sleep_for(std::chrono::seconds(sleep_seconds_));
} }
Json::Value root(Json::arrayValue); Json::Value root(Json::arrayValue);
@ -61,17 +61,23 @@ bool BookListService::Get(const webcc::UrlQuery& /* query */,
} }
// Add a new book. // Add a new book.
// No response content.
bool BookListService::Post(const std::string& request_content, bool BookListService::Post(const std::string& request_content,
std::string* /* response_content */) { std::string* response_content) {
if (sleep_seconds_ > 0) { if (sleep_seconds_ > 0) {
LOG_INFO("Sleep %d seconds...", sleep_seconds_); LOG_INFO("Sleep %d seconds...", sleep_seconds_);
boost::this_thread::sleep_for(boost::chrono::seconds(sleep_seconds_)); std::this_thread::sleep_for(std::chrono::seconds(sleep_seconds_));
} }
Book book; Book book;
if (JsonToBook(request_content, &book)) { if (JsonToBook(request_content, &book)) {
g_book_store.AddBook(book); // TODO: return ID std::string id = g_book_store.AddBook(book);
Json::Value root;
root["id"] = id;
Json::StreamWriterBuilder builder;
*response_content = Json::writeString(builder, root);
return true; return true;
} }
@ -85,7 +91,7 @@ bool BookDetailService::Get(const std::vector<std::string>& url_sub_matches,
std::string* response_content) { std::string* response_content) {
if (sleep_seconds_ > 0) { if (sleep_seconds_ > 0) {
LOG_INFO("Sleep %d seconds...", sleep_seconds_); LOG_INFO("Sleep %d seconds...", sleep_seconds_);
boost::this_thread::sleep_for(boost::chrono::seconds(sleep_seconds_)); std::this_thread::sleep_for(std::chrono::seconds(sleep_seconds_));
} }
if (url_sub_matches.size() != 1) { if (url_sub_matches.size() != 1) {
@ -110,7 +116,7 @@ bool BookDetailService::Put(const std::vector<std::string>& url_sub_matches,
std::string* response_content) { std::string* response_content) {
if (sleep_seconds_ > 0) { if (sleep_seconds_ > 0) {
LOG_INFO("Sleep %d seconds...", sleep_seconds_); LOG_INFO("Sleep %d seconds...", sleep_seconds_);
boost::this_thread::sleep_for(boost::chrono::seconds(sleep_seconds_)); std::this_thread::sleep_for(std::chrono::seconds(sleep_seconds_));
} }
if (url_sub_matches.size() != 1) { if (url_sub_matches.size() != 1) {
@ -132,7 +138,7 @@ bool BookDetailService::Delete(
const std::vector<std::string>& url_sub_matches) { const std::vector<std::string>& url_sub_matches) {
if (sleep_seconds_ > 0) { if (sleep_seconds_ > 0) {
LOG_INFO("Sleep %d seconds...", sleep_seconds_); LOG_INFO("Sleep %d seconds...", sleep_seconds_);
boost::this_thread::sleep_for(boost::chrono::seconds(sleep_seconds_)); std::this_thread::sleep_for(std::chrono::seconds(sleep_seconds_));
} }
if (url_sub_matches.size() != 1) { if (url_sub_matches.size() != 1) {

@ -79,8 +79,7 @@ class CalcClient {
int main() { int main() {
WEBCC_LOG_INIT("", webcc::LOG_CONSOLE); WEBCC_LOG_INIT("", webcc::LOG_CONSOLE);
// Default port 80. CalcClient calc("ws1.parasoft.com", ""); // Use default port 80
CalcClient calc("ws1.parasoft.com", "");
double x = 1.0; double x = 1.0;
double y = 2.0; double y = 2.0;

@ -11,7 +11,11 @@ bool CalcService::Handle(const webcc::SoapRequest& soap_request,
webcc::SoapResponse* soap_response) { webcc::SoapResponse* soap_response) {
double x = 0.0; double x = 0.0;
double y = 0.0; double y = 0.0;
if (!GetParameters(soap_request, &x, &y)) { try {
x = std::stod(soap_request.GetParameter("x"));
y = std::stod(soap_request.GetParameter("y"));
} catch (const std::exception& e) {
LOG_ERRO("SoapParameter cast error: %s", e.what());
return false; return false;
} }
@ -61,16 +65,3 @@ bool CalcService::Handle(const webcc::SoapRequest& soap_request,
return true; return true;
} }
bool CalcService::GetParameters(const webcc::SoapRequest& soap_request,
double* x, double* y) {
try {
*x = std::stod(soap_request.GetParameter("x"));
*y = std::stod(soap_request.GetParameter("y"));
} catch (const std::exception& e) {
LOG_ERRO("SoapParameter cast error: %s", e.what());
return false;
}
return true;
}

@ -10,10 +10,6 @@ class CalcService : public webcc::SoapService {
bool Handle(const webcc::SoapRequest& soap_request, bool Handle(const webcc::SoapRequest& soap_request,
webcc::SoapResponse* soap_response) override; webcc::SoapResponse* soap_response) override;
private:
bool GetParameters(const webcc::SoapRequest& soap_request,
double* x, double* y);
}; };
#endif // CALC_SERVICE_H_ #endif // CALC_SERVICE_H_

@ -17,7 +17,7 @@ void HttpRequestHandler::Start(std::size_t count) {
assert(count > 0 && workers_.size() == 0); assert(count > 0 && workers_.size() == 0);
for (std::size_t i = 0; i < count; ++i) { for (std::size_t i = 0; i < count; ++i) {
workers_.create_thread(std::bind(&HttpRequestHandler::WorkerRoutine, this)); workers_.emplace_back(std::bind(&HttpRequestHandler::WorkerRoutine, this));
} }
} }
@ -33,7 +33,11 @@ void HttpRequestHandler::Stop() {
// Enqueue a null connection to trigger the first worker to stop. // Enqueue a null connection to trigger the first worker to stop.
queue_.Push(HttpConnectionPtr()); queue_.Push(HttpConnectionPtr());
workers_.join_all(); for (auto& worker : workers_) {
if (worker.joinable()) {
worker.join();
}
}
LOG_INFO("All workers have been stopped."); LOG_INFO("All workers have been stopped.");
} }

@ -2,10 +2,9 @@
#define WEBCC_HTTP_REQUEST_HANDLER_H_ #define WEBCC_HTTP_REQUEST_HANDLER_H_
#include <list> #include <list>
#include <thread>
#include <vector> #include <vector>
#include "boost/thread/thread.hpp"
#include "webcc/http_connection.h" #include "webcc/http_connection.h"
#include "webcc/queue.h" #include "webcc/queue.h"
#include "webcc/soap_service.h" #include "webcc/soap_service.h"
@ -39,7 +38,7 @@ class HttpRequestHandler {
virtual void HandleConnection(HttpConnectionPtr connection) = 0; virtual void HandleConnection(HttpConnectionPtr connection) = 0;
Queue<HttpConnectionPtr> queue_; Queue<HttpConnectionPtr> queue_;
boost::thread_group workers_; std::vector<std::thread> workers_;
}; };
} // namespace webcc } // namespace webcc

Loading…
Cancel
Save