Is the HTTP Interface Integration Too Complicated? Try the UniHttp Framework!

Source: juejin.cn/post/7389925676519948297Recommended programming resource site for developers:http://cxyroad.comSide hustle earning column:https://xbt100.topLatest activation method for IDEA in 2024Reply in the background:activation codeCSDN no-loginCopy codePlugin download:CSDN copy pluginThe following is the main text.

From an enterprise-level project perspective, if you are still using traditional programming-style HTTP clients like HttpClient or OkHttp to directly integrate with third-party HTTP interfaces, then your project is certainly filled with a lot of integration logic and code. Moreover, for different integration channels, you need to encapsulate the call each time, and if the encapsulation is not done well, the system will become difficult to maintain and read. Even different developers may use their own methods with different HTTP clients and different encapsulation logic to integrate interfaces. This situation generally occurs when the project changes maintainers, and the technical leader does not control code quality and standards.

If your project also has such problems or needs to solve such problems, then UniHttp is your version answer.

1. Introduction

A declarative HTTP interface integration framework that can quickly complete the integration and use of a third-party HTTP interface, after which it automatically initiates HTTP requests like calling local methods, without requiring developers to focus on how to send a request, how to pass HTTP request parameters, and how to process and deserialize the request results. This framework helps you implement all of these.

It’s as simple as configuring a Spring Controller, but it is essentially reverse configuration.

This framework focuses more on how to maintain high cohesion and high readability of code while quickly integrating with third-party channel interfaces, rather than focusing on how to send HTTP requests like traditional programming-style HTTP request clients (such as HttpClient or OkHttp), although it still uses OkHttp to send requests at the bottom.

Rather than saying it integrates HTTP interfaces, it is more about integrating third-party channels. UniHttp supports custom interface channel HTTP API annotations and some custom integration and interaction behaviors, for which it extends various lifecycle hooks for sending, responding, and deserializing an HTTP request, allowing developers to extend and implement them themselves.

2. Quick Start

2.1. Introduce Dependencies

<dependency>
  <groupId>io.github.burukeyou</groupId>
  <artifactId>uniapi-http</artifactId>
  <version>0.0.4</version>
</dependency>

2.2. Integrate Interfaces

First, create an interface and mark it with the @HttpApi annotation, then specify the request domain URL, and you can configure which interface to integrate in the method.

For example, the configuration of the following two methods integrates the following two interfaces:

  • GET http://localhost:8080/getUser
  • POST http://localhost:8080/addUser

Define the return type of the method as the type corresponding to the HTTP response body, and by default, fastjson will be used to deserialize the HTTP response body into that type object.

@HttpApi(url = "http://localhost:8080")
interface UserHttpApi {

@GetHttpInterface("/getUser")
BaseRsp<String> getUser(@QueryPar("name") String param, @HeaderPar("userId") Integer id);

@PostHttpInterface("/addUser")
BaseRsp<Add4DTO> addUser(@BodyJsonPar Add4DTO req);
}
  • @QueryPar indicates that the parameter value is placed in the HTTP request’s query parameters.

  • @HeaderPar indicates that the parameter value is placed in the HTTP request’s headers.

  • @BodyJsonPar indicates that the parameter value is placed in the HTTP request body, and the <span>content-type</span> is <span>application/json</span>

1. The final constructed HTTP request message for the getUser method is:
GET http://localhost:8080/getUser?name=param
Header:
    userId: id
2. The final constructed HTTP request message for addUser is:
POST:  http://localhost:8080/addUser 
Header: 
    Content-Type:   application/json
Body:
    {"id":1,"name":"jay"}

2.3. Declare the package scanning path for the defined HttpAPI

Use the <span>@UniAPIScan</span> annotation on the Spring configuration class to mark the package scanning path for the defined <span>@HttpAPI</span>, which will automatically generate proxy objects for the marked <span>@HttpApi</span> interfaces and inject them into the Spring container. After that, you can use them just like other Spring beans through dependency injection.

@UniAPIScan("com.xxx.demo.api")
@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

2.4. Use dependency injection

@Service
class UserAppService {
    
    @Autowired
    private UserHttpApi userHttpApi;
    
    public void doSomething() {
        userHttpApi.getUser("jay", 3);
    }
} 

3. Explanation and Introduction

3.1. @HttpApi Annotation

Used to mark the interface, and the methods on this interface will be proxied to the corresponding HTTP request interface. You can specify the request domain and also specify custom HTTP proxy logic, etc.

3.2. @HttpInterface Annotation

Used to configure an interface’s parameters, including request method, request path, request headers, request cookies, request query parameters, etc.

It also includes the following request methods:<span>@HttpInterface</span><span>, so you don't have to specify the request method manually each time.</span>

  • @PostHttpInterface
  • @PutHttpInterface
  • @DeleteHttpInterface
  • @GetHttpInterface
@PostHttpInterface(
// Request path
    path = "/getUser",
// Request headers
    headers = {"clientType:sys-app", "userId:99"},
// URL query parameters 
    params = {"name=周杰伦", "age=1"},
// URL query parameter concatenation string
    paramStr = "a=1&amp;b=2&amp;c=3&amp;d=哈哈&amp;e=%E7%89%9B%E9%80%BC",
// Cookie string
    cookie = "name=1;sessionId=999"
)
BaseRsp<String> getUser();

3.3. @Par Annotations

The various Par-suffixed annotations are mainly used on method parameters to specify where to place the parameter values in the HTTP request body when sending the request.

For convenience, the ordinary values described below refer to String, basic types, and their wrapper types.

Let’s briefly review the HTTP protocol message.

Is the HTTP Interface Integration Too Complicated? Try the UniHttp Framework!
@QueryPar Annotation

Marks the query parameters of the HTTP request URL.

Supports the following method parameter types: ordinary values, collections of ordinary values, objects, and Maps.

@PostHttpInterface
BaseRsp<String> getUser(@QueryPar("id")  String id,  //  ordinary value   
                        @QueryPar("ids") List<Integer> idsList, //  collection of ordinary values
                        @QueryPar User user,  // object
                        @QueryPar Map<String,Object> map); // Map

If the type is an ordinary value or a collection of ordinary values, you need to manually specify the parameter name, as it is treated as a single query parameter.

If the type is an object or a Map, it is treated as multiple query parameters, where the field name or the map key name is the parameter name, and the field value or the map value is the parameter value.

If it is an object, the parameter name defaults to the field name. Since fastjson is used for serialization, you can use the @JSONField annotation to specify an alias.

@PathPar Annotation

Marks the HTTP request path variable parameters, only supports marking ordinary value types.

@PostHttpInterface("/getUser/{userId}/detail")
BaseRsp<String> getUser(@PathPar("userId")  String id);  //  ordinary value
@HeaderPar Annotation

Marks the HTTP request header parameters.

Supports the following method parameter types: objects, Maps, ordinary values.

@PostHttpInterface
BaseRsp<String> getUser(@HeaderPar("id")  String id,  //  ordinary value   
                        @HeaderPar User user,  // object
                        @HeaderPar Map<String,Object> map); // Map

If the type is an ordinary value type, you need to manually specify the parameter name, which is treated as a single request header parameter. If it is an object or a Map, it is treated as multiple request header parameters.

@CookiePar Annotation

Used to mark the cookie request header of the HTTP request.

Supports the following method parameter types: Map, Cookie objects, strings.

@PostHttpInterface
BaseRsp<String> getUser(@CookiePar("id")  String cookiePar,  //   ordinary value (specifying name) treated as a single cookie key-value pair
                        @CookiePar String cookieString,  //  ordinary value (not specifying name), treated as a complete cookie string
                        @CookiePar com.burukeyou.uniapi.http.support.Cookie cookieObj,  // single Cookie object 
                        @CookiePar List<com.burukeyou.uniapi.http.support.Cookie> cookieList // list of Cookie objects
                        @CookiePar Map<String,Object> map); // Map

If the type is a string, when specifying the parameter name, it is treated as a single cookie key-value pair. If the parameter name is not specified, it is treated as a complete cookie string, such as <span>a=1;b=2;c=3</span>.

If it is a Map, it is treated as multiple cookie key-value pairs.

If the type is the built-in <span>com.burukeyou.uniapi.http.support.Cookie</span> object, it is treated as a single cookie key-value pair.

@BodyJsonPar Annotation

Used to mark the HTTP request body content as JSON format: corresponding to <span>content-type</span> as <span>application/json</span>

Supports the following method parameter types: objects, collections of objects, Maps, ordinary values, collections of ordinary values.

@PostHttpInterface
BaseRsp<String> getUser(@BodyJsonPar  String id,                //  ordinary value
                        @BodyJsonPar  String[] id               //  collection of ordinary values
                        @BodyJsonPar List<User> userList,       // collection of objects
                        @BodyJsonPar User user,                  // object
                        @BodyJsonPar Map<String,Object> map);    // Map

Serialization and deserialization use fastjson by default, so if you want to specify an alias, you can mark the field with the <span>@JSONField</span> annotation.

@BodyFormPar Annotation

Used to mark the HTTP request body content as ordinary form format: corresponding to <span>content-type</span> as <span>application/x-www-form-urlencoded</span>

Supports the following method parameter types: objects, Maps, ordinary values.

@PostHttpInterface
BaseRsp<String> getUser(@BodyFormPar("name") String value,         //  ordinary value
                        @BodyFormPar User user,                   // object
                        @BodyFormPar Map<String,Object> map);    // Map

If the type is an ordinary value type, you need to manually specify the parameter name, which is treated as a single request form key-value pair.

@BodyMultiPartPar Annotation

Used to mark the HTTP request body content as complex format: corresponding to <span>content-type</span> as <span>multipart/form-data</span>

Supports the following method parameter types: objects, Maps, ordinary values, File objects.

@PostHttpInterface
BaseRsp<String> getUser(@BodyMultiPartPar("name") String value,         //  single form text value
                        @BodyMultiPartPar User user,                   // object
                        @BodyMultiPartPar Map<String,Object> map,      // Map
                        @BodyMultiPartPar("userImg") File file);     // single form file value

If the parameter type is an ordinary value or File type, it is treated as a single form key-value pair, and you need to manually specify the parameter name.

If the parameter type is an object or Map, it is treated as multiple form key-value pairs. If the field value or map value parameter is of File type, it is automatically treated as a file form field.

@BodyBinaryPar Annotation

Used to mark the HTTP request body content as binary format: corresponding to <span>content-type</span> as <span>application/octet-stream</span>

Supports the following method parameter types: <span>InputStream</span>, <span>File</span>, <span>InputStreamSource</span>

@PostHttpInterface
BaseRsp<String> getUser(@BodyBinaryPar InputStream value,         
                        @BodyBinaryPar File user,                   
                        @BodyBinaryPar InputStreamSource map);    
@ComposePar Annotation

This annotation itself is not for configuring HTTP request content, but is used to mark an object, and then all fields in that object marked with other @Par annotations will be processed for nested parsing, aiming to reduce the number of method parameters and support passing them together.

Supports the following method parameter types: objects.

@PostHttpInterface
BaseRsp<String> getUser(@ComposePar UserReq req);    

For example, the fields in UserReq can be nested with other @Par annotations, and the specific supported marking types and processing logic are consistent with the previous ones.

class UserReq {

@QueryPar
private Long id;

@HeaderPar
private String name;

@BodyJsonPar
private Add4DTO req;

@CookiePar
private String cook;
}

3.4. Raw HttpResponse

<span>HttpResponse</span> represents the raw response object of the HTTP request. If the business needs to focus on obtaining the complete HTTP response, just wrap the return value in the method.

As shown below, at this time, <span>HttpResponse<Add4DTO></span> with the generic Add4DTO represents the actual response content returned by the interface, which can be directly obtained later.

@PostHttpInterface("/user-web/get")
HttpResponse<Add4DTO> get();

Through it, we can obtain the HTTP status code, response headers, response cookies, etc. Of course, we can also obtain the content of our response body through the <span>getBodyResult</span> method.

3.5. Handling File Download Interfaces

For interfaces that download files, you can define the return value of the method as <span>HttpBinaryResponse</span>, <span>HttpFileResponse</span>, or <span>HttpInputStreamResponse</span>, any of which will allow you to obtain the downloaded file.

  • HttpBinaryResponse: indicates that the downloaded file content is returned in binary form. Please handle it with caution for large files, as it will be stored in memory.
  • HttpFileResponse: indicates that the downloaded file content is returned as a File object, and the file has been downloaded to the local disk.
  • HttpInputStreamResponse: indicates that the downloaded file content is returned as an input stream. At this time, the file has not yet been downloaded to the client, and the caller can read this input stream to perform the file download.

3.6. HttpApiProcessor Lifecycle Hooks

<span>HttpApiProcessor</span> is a variety of lifecycle hooks for HTTP request interfaces, allowing developers to implement custom integration logic inside it. It can then be configured to the <span>@HttpApi</span> annotation or the <span>@HttpInterface</span> annotation, and the framework will default to obtain it from <span>SpringContext</span>. If it cannot be obtained, it will be manually created.

Typically, an HTTP request needs to go through building request parameters, sending the HTTP request, obtaining response content, and deserializing the HTTP response content into specific objects.

Currently, four hooks are provided, and the execution order is as follows:

postBeforeHttpMetadata  (Before sending the request) Post-processing of the HTTP request body before sending the request
           |
           V
postSendingHttpRequest  (During sending the request) Processing during the sending of the HTTP request
           |
           V
postAfterHttpResponseBodyString  (After receiving the response) Post-processing of the response body text string
           |
           V
postAfterHttpResponseBodyResult  (After receiving the response) Post-processing of the result after deserializing the response body
           |
           V
postAfterMethodReturnValue  (After receiving the response) Post-processing of the return value of the proxy method, similar to AOP post-processing
  • postBeforeHttpMetadata: allows for secondary processing of the request body before sending the HTTP request, such as signing.
  • postSendHttpRequest: will be called when the HTTP request is sent, allowing for custom sending logic or logging of the sending process.
  • postAfterHttpResponseBodyString: after the HTTP request response, post-process the response body string, for example, if it is encrypted data, it can be decrypted.
  • postAfterHttpResponseBodyResult: after the HTTP request response, post-process the object after deserializing the response body, such as filling in default return values.
  • postAfterMethodReturnValue: after the HTTP request response, post-process the return value of the proxy method, similar to AOP post-processing.
Callback Parameter Description:
  • HttpMetadata: represents the request body of this HTTP request, including request URL, request headers, request method, request cookies, request body, request parameters, etc.

  • HttpApiMethodInvocation: inherits from <span>MethodInvocation</span>, representing the context of the proxied method call, allowing access to the proxied class, the proxied method, the proxied HTTP API annotation, and the <span>HttpInterface</span> annotation, etc.

3.7. Configuring Custom HTTP Clients

The default client used is OkHttp. If you want to reconfigure the OkHttp client, just inject the Spring bean as follows:

@Configuration
public class CustomConfiguration {

@Bean
public OkHttpClient myOkHttpClient() {
return new OkHttpClient.Builder()
                .readTimeout(50, TimeUnit.SECONDS)
                .writeTimeout(50, TimeUnit.SECONDS)
                .connectTimeout(10, TimeUnit.SECONDS)
                .connectionPool(new ConnectionPool(20, 10, TimeUnit.MINUTES))
                .build();
    }
}

4. Enterprise-Level Channel Integration Practice

Case Background:

Assuming you need to integrate all interfaces of a certain weather service, you need to carry a token field and a sessionId field in the request cookie. The values of these two fields need to be manually obtained by calling a specific interface of the channel party before each interface call. The token value is returned in the response of that interface, and the sessionId is returned in the response header of that interface.

Then, you also need to carry a sign signature field in the request header. The generation rule for this sign signature field requires signing all request bodies and request parameters using the public key provided by the channel party.

Additionally, you need to carry a client appId assigned by the channel party in the query parameters of each interface.

4.1 Configure Channel Party Information in application.yml

channel:
  mtuan:
# Request domain name
url: http://127.0.0.1:8999
# Assigned channel appId
appId: UUU-asd-01
# Assigned public key
publicKey: fajdkf9492304jklfahqq

4.2 Custom HTTP API Annotation for the Channel Party

Assuming the integration is with a certain group, let’s call the custom annotation <span>@MTuanHttpApi</span>, and then it needs to be marked with the <span>@HttpApi</span> annotation, and the <span>processor</span> field needs to be configured. You need to implement a custom <span>HttpApiProcessor</span>, which will be discussed later.

With this annotation, you can customize various field configurations related to the integration channel party, although it is also possible not to define them.

Note that the URL field here uses <span>@AliasFor(annotation = HttpApi.class)</span>, so that the constructed <span>HttpMetadata</span> will automatically parse and fill in the request body. If not marked, it can also be handled manually.

@Inherited
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@HttpApi(processor = MTuanHttpApiProcessor.class)
public @interface MTuanHttpApi {

    /**
     * Channel party domain address
     */
    @AliasFor(annotation = HttpApi.class)
    String url() default "${channel.mtuan.url}";

    /**
     * Channel party assigned appId
     */
    String appId() default "${channel.mtuan.appId}";
}
@Slf4j
@Component
public class MTuanHttpApiProcessor implements HttpApiProcessor<MTuanHttpApi> {
    
}

Note that the generic of the implemented <span>HttpApiProcessor</span> must be specified as the previously defined annotation type <span>@MTuanHttpApi</span>, because this <span>HttpApiProcessor</span> is configured on it. If you need general processing, you can define it as the <span>Annotation</span> type.

4.3 Integrate Interfaces

With the <span>@MTuanHttpApi</span> annotation, you can start integrating interfaces. For example, assume there are two interfaces to integrate: one is the token acquisition interface mentioned earlier, and the other is the weather condition acquisition interface.

Why does the getToken method return <span>HttpResponse</span>? This is the built-in raw HTTP response object of UniHttp, which makes it convenient for us to obtain some content of the raw HTTP response (such as response status code, response cookies).

The generic BaseRsp represents the content of the HTTP response body after deserialization. The <span>getCityWeather</span> method does not use <span>HttpResponse</span> wrapping; BaseRsp is simply the content of the HTTP response body after deserialization. This is the difference between the two.

As mentioned earlier, <span>HttpResponse</span> is not a concern for most interfaces, so it can be omitted.

@MTuanHttpApi
public interface WeatherApi {

/**
     * Get weather conditions by city name
     */
@GetHttpInterface("/getCityByName")
BaseRsp<WeatherDTO> getCityWeather(@QueryPar("city") String cityName);

/**
     * Get token based on appId and public key
     */
@PostHttpInterface("/getToken")
HttpResponse<BaseRsp<TokenDTO>> getToken(@HeaderPar("appId") String appId, @HeaderPar("publicKey") String publicKey);
}

4.4. Custom HttpApiProcessor

Previously, we defined a <span>@MTuanHttpApi</span> annotation and specified a <span>MTuanHttpApiProcessor</span>. Next, we will implement its specific content to achieve the functionality described in our case background.

@Slf4j
@Component
public class MTuanHttpApiProcessor implements HttpApiProcessor<MTuanHttpApi> {

/**
     *  Public key assigned by the channel party
     */
@Value("${channel.mtuan.publicKey}")
private String publicKey;

@Value("${channel.mtuan.appId}")
private String appId;

@Autowired
private Environment environment;

@Autowired
private WeatherApi weatherApi;

/** Implement postBeforeHttpMetadata: This method will be called before sending the HTTP request, allowing for secondary processing of the request body content
     * @param httpMetadata              Original request body
     * @param methodInvocation          Proxied method
     * @return                          New request body
     */
@Override
public HttpMetadata postBeforeHttpMetadata(HttpMetadata httpMetadata, HttpApiMethodInvocation<MTuanHttpApi> methodInvocation) {
/**
         * Add the provided appId field to the query parameters
         */
// Get MTuanHttpApi annotation
        MTuanHttpApi apiAnnotation = methodInvocation.getProxyApiAnnotation();

// Get appId from MTuanHttpApi annotation, since this appId is an environment variable, we resolve it from the environment
        String appIdVar = apiAnnotation.appId();
        appIdVar = environment.resolvePlaceholders(appIdVar);

// Add to query parameters
        httpMetadata.putQueryParam("appId", appIdVar);

/**
         *  Generate the sign field
         */
// Get all query parameters
        Map<String, Object> queryParam = httpMetadata.getHttpUrl().getQueryParam();

// Get request body parameters
        HttpBody body = httpMetadata.getBody();

// Generate sign
        String signKey = createSignKey(queryParam, body);

// Add the sign to the request headers
        httpMetadata.putHeader("sign", signKey);

return httpMetadata;
    }

private String createSignKey(Map<String, Object> queryParam, HttpBody body) {
// todo Pseudo code
// 1. Concatenate query parameters into a string
        String queryParamString = queryParam.entrySet()
                .stream().map(e -> e.getKey() + "=" + e.getValue())
                .collect(Collectors.joining(";"));

// 2. Concatenate request body parameters into a string
        String bodyString = "";
if (body instanceof HttpBodyJSON) {
// application/json type request body
            bodyString = body.toStringBody();
        } else if (body instanceof HttpBodyFormData) {
// application/x-www-form-urlencoded type request body
            bodyString = body.toStringBody();
        } else if (body instanceof HttpBodyMultipart) {
// multipart/form-data type request body
            bodyString =  body.toStringBody();
        }

// Use the public key to encrypt the concatenated string
        String sign = publicKey + queryParamString + bodyString;
try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(sign.getBytes());
return new String(digest);
        } catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
        }
    }

/** Implement postSendHttpRequest: This method allows defining the behavior of sending the request or printing request and response logs.
     */
@Override
public HttpResponse<?> postSendHttpRequest(HttpSender httpSender, HttpMetadata httpMetadata) {
// Ignore the callback for weatherApi.getToken method, otherwise it will recursively call this method and cause a deadlock. Or specify a custom HttpApiProcessor for this interface to override postSendingHttpRequest.
        Method getTokenMethod = ReflectionUtils.findMethod(WeatherServiceApi.class, "getToken", String.class, String.class);
if (getTokenMethod == null || getTokenMethod.equals(methodInvocation.getMethod())) {
return httpSender.sendHttpRequest(httpMetadata);
        }

// 1. Dynamically obtain token and sessionId
        HttpResponse<String> httpResponse = weatherApi.getToken(appId, publicKey);

// Get the token from the response body
        String token = httpResponse.getBodyResult();
// Get sessionId from the response header
        String sessionId = httpResponse.getHeader("sessionId");

// Add these two values to the request cookies
        httpMetadata.addCookie(new Cookie("token", token));
        httpMetadata.addCookie(new Cookie("sessionId", sessionId));

        log.info("Starting to send HTTP request to interface: {} Request body: {}", httpMetadata.getHttpUrl().toUrl(), httpMetadata.toHttpProtocol());

// Use the framework's built-in tools to send the request
        HttpResponse<?> rsp =  httpSender.sendHttpRequest(httpMetadata);

        log.info("Starting to send HTTP request Response result: {}", rsp.toHttpProtocol());

return rsp;
    }

/** Implement postAfterHttpResponseBodyResult: This method will be called after the HTTP response body is deserialized, allowing for secondary processing of the result returned.
     * @param bodyResult                     Result after deserialization of the HTTP response body
     * @param rsp                            Original HTTP response object
     * @param method                         Proxied method
     * @param httpMetadata                   HTTP request body
     */
@Override
public Object postAfterHttpResponseBodyResult(Object bodyResult, HttpResponse<?> rsp, Method method, HttpMetadata httpMetadata) {
if (bodyResult instanceof BaseRsp) {
            BaseRsp baseRsp = (BaseRsp) bodyResult;
// Set
            baseRsp.setCode(999);
        }

return bodyResult;
    }
}

In the above, we have overridden the <span>postBeforeHttpMetadata</span>, <span>postSendHttpRequest</span>, and <span>postAfterHttpResponseBodyResult</span> lifecycle hook methods to complete our requirements: signing the request body before sending, dynamically obtaining the token and reconstructing the request body while sending, and setting the code of the response object to 999 after sending the request.

Finally

GitHub code address:

https://github.com/burukeYou/UniAPI

Specific usage examples can be found in the <span>uniapi-test-http</span> module.

<END>Recommended Reading: Side hustle earning recommendations: Let your time start to monetize! Free experience of AI image generation, just at Image Generator Hub!

Backend Exclusive Technical Group

Build a high-quality technical communication community, welcome those engaged in programming development, technical recruitment HR to join, and everyone is also welcome to share their company’s internal referral information, help each other, and improve together!

Speak civilized, focusing on exchanging technology, job referrals, and industry discussions.
Advertisers are not allowed to enter, and do not trust private messages to prevent being scammed.

⬇ Programmer tools!                                      I have read it.

Leave a Comment