All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.nervousync.utils.ServiceUtils Maven / Gradle / Ivy

There is a newer version: 1.2.1
Show newest version
/*
 * Licensed to the Nervousync Studio (NSYC) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.nervousync.utils;

import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.*;
import jakarta.ws.rs.client.*;
import jakarta.ws.rs.core.*;
import jakarta.xml.ws.Service;
import jakarta.xml.ws.WebServiceClient;
import jakarta.xml.ws.handler.HandlerResolver;
import org.nervousync.annotations.restful.DataConverter;
import org.nervousync.beans.converter.Adapter;
import org.nervousync.commons.Globals;
import org.nervousync.enumerations.web.HttpMethodOption;

import javax.xml.namespace.QName;
import javax.xml.rpc.ServiceException;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.*;

/**
 * 

Service utilities

* * Current utilities implements features: *
    Generate SOAP Client instance
*
    Generate Restful Client and process request
*
*

网络服务工具集

* * 此工具集实现以下功能: *
    生成SOAP请求客户端
*
    生成Restful请求客户端并处理请求
*
* * @author Steven Wee [email protected] * @version $Revision: 1.2.0 $ $Date: Jan 13, 2020 15:52:33 $ */ public final class ServiceUtils { /** * Logger instance * 日志实例 */ private static final LoggerUtils.Logger LOGGER = LoggerUtils.getLogger(ServiceUtils.class); /** *

Private constructor for ServiceUtils

*

网络服务工具集的私有构造方法

*/ private ServiceUtils() { } /** *

Generate SOAP Client instance

*

生成SOAP请求客户端

* * @param End point interface * Web服务的接口 * @param serviceInterface End point interface * Web服务的接口 * @param handlerResolver Custom handler resolver instance * 自定义的处理器实例对象 * * @return Generated client instance * 生成的客户端实例对象 * * @throws MalformedURLException * if no protocol is specified, or an unknown protocol is found, or spec is null. * 如果没有指定协议,或者发现未知协议,或者spec为空。 */ public static T SOAPClient(final Class serviceInterface, final HandlerResolver handlerResolver) throws MalformedURLException { if (!serviceInterface.isAnnotationPresent(WebServiceClient.class)) { return null; } WebServiceClient serviceClient = serviceInterface.getAnnotation(WebServiceClient.class); String namespaceURI = serviceClient.targetNamespace(); String serviceName = serviceClient.name(); URL wsdlLocation = new URL(serviceClient.wsdlLocation()); if (namespaceURI.length() == 0) { String packageName = serviceInterface.getPackage().getName(); String[] packageNames = StringUtils.tokenizeToStringArray(packageName, "."); StringBuilder stringBuilder = new StringBuilder(wsdlLocation.getProtocol() + "://"); for (int i = packageNames.length - 1; i >= 0; i--) { stringBuilder.append(packageNames[i]).append("."); } namespaceURI = stringBuilder.substring(0, stringBuilder.length() - 1) + "/"; } if (StringUtils.isEmpty(serviceName)) { serviceName = serviceInterface.getSimpleName() + "Service"; } Service service = Service.create(wsdlLocation, new QName(namespaceURI, serviceName)); if (handlerResolver != null) { service.setHandlerResolver(handlerResolver); } return service.getPort(new QName(namespaceURI, serviceName), serviceInterface); } /** *

Generate Restful service client instance

*

生成Restful请求客户端

* * @param End point interface * Web服务的接口 * @param targetAddress the target address * 目标请求地址 * @param serviceInterface End point interface * Web服务的接口 * * @return Generated client instance * 生成的客户端实例对象 */ public static T RestfulClient(final String targetAddress, final Class serviceInterface) { return RestfulClient(targetAddress, serviceInterface, null); } /** *

Generate Restful service client instance

*

生成Restful请求客户端

* * @param End point interface * Web服务的接口 * @param targetAddress the target address * 目标请求地址 * @param serviceInterface End point interface * Web服务的接口 * @param headerMap Request header information map * 请求头部信息映射 * * @return Generated client instance * 生成的客户端实例对象 */ public static T RestfulClient(final String targetAddress, final Class serviceInterface, final Map headerMap) { if (StringUtils.isEmpty(targetAddress)) { return null; } String servicePath = targetAddress.toLowerCase().startsWith("http") ? targetAddress : Globals.HTTP_PROTOCOL + targetAddress; if (serviceInterface.isAnnotationPresent(Path.class)) { servicePath += serviceInterface.getAnnotation(Path.class).value(); } return ObjectUtils.newInstance(serviceInterface, new RestfulInterceptor(servicePath, headerMap)); } /** *

Find annotation and generate data convert adapter

*

寻找注解并生成数据转换适配器

* * @param annotations Annotation instance array * 注解实例对象数组 * * @return Generated data convert adapter * 生成的数据转换适配器实例对象 */ private static Adapter newConverter(final Annotation[] annotations) { Adapter adapter = null; for (Annotation annotation : annotations) { if (annotation.annotationType().equals(DataConverter.class)) { adapter = newConverter((DataConverter) annotation); } if (adapter != null) { break; } } return adapter; } /** *

Find annotation and generate data convert adapter

*

寻找注解并生成数据转换适配器

* * @param dataConverter DataConverter annotation instance * 数据转换器注解实例对象 * * @return Generated data convert adapter * 生成的数据转换适配器实例对象 */ @SuppressWarnings("unchecked") private static Adapter newConverter(final DataConverter dataConverter) { return Optional.ofNullable(dataConverter) .map(DataConverter::value) .filter(converterClass -> Adapter.class.isAssignableFrom(converterClass) && !Adapter.class.equals(converterClass)) .map(converterClass -> (Adapter) ObjectUtils.newInstance(converterClass)) .orElse(null); } /** *

Marshal data using given adapter

*

使用给定适配器编组数据

* * @param adapter Data convert adapter * 数据转换适配器实例对象 * @param value Data instance will convert * 将被转换的数据实例对象 * * @return Converted result or empty string if value is null or an error occurs when process marshal * 数据转换结果,如果输入数据为null或转换时出现异常,则返回长度为0的空字符串 */ private static String marshal(final Adapter adapter, final Object value) { if (value == null) { return Globals.DEFAULT_VALUE_STRING; } if (adapter == null) { return value.toString(); } try { return adapter.marshal(value); } catch (Exception e) { if (LOGGER.isDebugEnabled()) { LOGGER.error("Convert_Object_Error", e); } return Globals.DEFAULT_VALUE_STRING; } } /** *

Append parameter value to current array

*

追加参数值到当前数组

* * @param paramValues Current array * 当前数组 * @param appendValue Append parameter value * 追加的参数值 * @return Append parameter value array * 追加后的参数值数组 */ private static String[] appendValue(final String[] paramValues, final String appendValue) { String[] newValues = Arrays.copyOf(paramValues, paramValues.length + 1); newValues[paramValues.length] = Objects.requireNonNullElse(appendValue, Globals.DEFAULT_VALUE_STRING); return newValues; } /** *

Restful service interceptor invocation handler

*

Restful服务拦截器调用处理程序

* * @author Steven Wee [email protected] * @version $Revision: 1.0.0 $ $Date: Jan 13, 2020 16:28:15 $ */ private static final class RestfulInterceptor implements InvocationHandler { /** * Request path * 请求地址 */ private final String requestPath; /** * Request header information map * 请求头部信息映射 */ private final Map headerMap; /** *

Constructor for RestfulInterceptor

*

Restful服务拦截器的构造方法

* * @param requestPath Request path * 请求地址 * @param headerMap Request header information map * 请求头部信息映射 */ RestfulInterceptor(final String requestPath, final Map headerMap) { this.requestPath = requestPath; this.headerMap = new HashMap<>(); if (headerMap != null) { this.headerMap.putAll(headerMap); } } /** * (Non-Javadoc) * @see InvocationHandler#invoke(Object, Method, Object[]) */ @Override public Object invoke(final Object o, final Method method, final Object[] objects) throws Throwable { HttpMethodOption methodOption = RequestUtils.httpMethodOption(method); if (HttpMethodOption.UNKNOWN.equals(methodOption) || !method.isAnnotationPresent(Path.class)) { throw new Exception("Unknown method! "); } String methodName = method.getAnnotation(Path.class).value(); if (methodName.length() == 0) { methodName = method.getName(); } else if (methodName.startsWith("/")) { methodName = methodName.substring(1); } String servicePath = this.requestPath + "/" + methodName; Annotation[][] annotations = method.getParameterAnnotations(); Class[] parameterClasses = method.getParameterTypes(); if (objects.length != parameterClasses.length) { throw new Exception("Mismatch arguments"); } Map formParameters = new HashMap<>(); Map queryParameters = new HashMap<>(); Map matrixParameters = new HashMap<>(); String[] mediaTypes = method.isAnnotationPresent(Consumes.class) ? method.getAnnotation(Consumes.class).value() : new String[0]; for (int i = 0; i < objects.length; i++) { Object paramObj = objects[i]; if (paramObj == null) { continue; } Adapter dataConverter = newConverter(annotations[i]); if (Arrays.stream(annotations[i]) .anyMatch(annotation -> annotation.annotationType().equals(BeanParam.class))) { BeanParameter beanParameter = new BeanParameter(paramObj, mediaTypes, dataConverter); this.headerMap.putAll(beanParameter.getHeaders()); for (Map.Entry entry : beanParameter.getPaths().entrySet()) { if (StringUtils.isEmpty(entry.getKey()) || entry.getValue() == null) { throw new ServiceException("Unknown parameter name or path parameter value is null! "); } String pathKey = "{" + entry.getKey() + "}"; if (servicePath.indexOf(pathKey) > 0) { servicePath = StringUtils.replace(servicePath, pathKey, URLEncoder.encode(entry.getValue(), Globals.DEFAULT_ENCODING)); } } formParameters.putAll(beanParameter.getFormParameters()); queryParameters.putAll(beanParameter.getQueryParameters()); matrixParameters.putAll(beanParameter.getMatrixParameters()); } else if (Arrays.stream(annotations[i]) .anyMatch(annotation -> annotation.annotationType().equals(MatrixParam.class))) { Arrays.stream(annotations[i]) .filter(annotation -> annotation.annotationType().equals(MatrixParam.class)) .findFirst() .map(annotation -> ((MatrixParam) annotation).value()) .ifPresent(paramName -> { if (paramObj.getClass().isArray()) { Arrays.asList((Object[]) paramObj).forEach(itemValue -> { String paramValue = marshal(dataConverter, itemValue); matrixParameters.put(paramName, appendValue(matrixParameters.getOrDefault(paramName, new String[0]), paramValue)); }); } else if (List.class.isAssignableFrom(paramObj.getClass())) { ((List) paramObj).forEach(itemValue -> { String paramValue = marshal(dataConverter, itemValue); matrixParameters.put(paramName, appendValue(matrixParameters.getOrDefault(paramName, new String[0]), paramValue)); }); } else { String paramValue = marshal(dataConverter, paramObj); matrixParameters.put(paramName, appendValue(matrixParameters.getOrDefault(paramName, new String[0]), paramValue)); } }); } else { String paramValue = marshal(dataConverter, paramObj); if (Arrays.stream(annotations[i]) .anyMatch(annotation -> annotation.annotationType().equals(QueryParam.class))) { String paramName = Arrays.stream(annotations[i]).filter(annotation -> annotation.annotationType().equals(QueryParam.class)) .findFirst() .map(annotation -> ((QueryParam) annotation).value()) .orElse(Globals.DEFAULT_VALUE_STRING); if (StringUtils.notBlank(paramName)) { queryParameters.put(paramName, paramValue); } } if (Arrays.stream(annotations[i]) .anyMatch(annotation -> annotation.annotationType().equals(FormParam.class))) { String paramName = Arrays.stream(annotations[i]).filter(annotation -> annotation.annotationType().equals(FormParam.class)) .findFirst() .map(annotation -> ((FormParam) annotation).value()) .orElse(Globals.DEFAULT_VALUE_STRING); if (StringUtils.notBlank(paramName)) { queryParameters.put(paramName, paramValue); } } if (Arrays.stream(annotations[i]) .anyMatch(annotation -> annotation.annotationType().equals(PathParam.class))) { String paramName = Arrays.stream(annotations[i]).filter(annotation -> annotation.annotationType().equals(PathParam.class)) .findFirst() .map(annotation -> ((PathParam) annotation).value()) .orElse(Globals.DEFAULT_VALUE_STRING); if (StringUtils.notBlank(paramName)) { if (StringUtils.isEmpty(paramValue)) { throw new ServiceException("Unknown parameter name or path parameter value is null! "); } String pathKey = "{" + paramName + "}"; if (servicePath.indexOf(pathKey) > 0) { servicePath = StringUtils.replace(servicePath, pathKey, URLEncoder.encode(paramValue, Globals.DEFAULT_ENCODING)); } } } if (Arrays.stream(annotations[i]) .anyMatch(annotation -> annotation.annotationType().equals(HeaderParam.class))) { String paramName = Arrays.stream(annotations[i]).filter(annotation -> annotation.annotationType().equals(HeaderParam.class)) .findFirst() .map(annotation -> ((HeaderParam) annotation).value()) .orElse(Globals.DEFAULT_VALUE_STRING); if (StringUtils.notBlank(paramName)) { this.headerMap.put(paramName, paramValue); } } } } Form form = null; if (HttpMethodOption.POST.equals(methodOption) || HttpMethodOption.PUT.equals(methodOption) || HttpMethodOption.PATCH.equals(methodOption)) { form = new Form(); formParameters.forEach(form::param); } try (Client client = ClientBuilder.newClient()) { WebTarget webTarget = client.target(servicePath); queryParameters.forEach(webTarget::queryParam); matrixParameters.forEach(webTarget::matrixParam); String[] acceptTypes = method.isAnnotationPresent(Produces.class) ? method.getAnnotation(Produces.class).value() : new String[]{"*/*"}; if (LOGGER.isDebugEnabled()) { LOGGER.debug("Accept data types: {}", String.join(",", acceptTypes)); } Invocation.Builder builder = webTarget.request(acceptTypes); if (method.isAnnotationPresent(Consumes.class)) { builder.accept(method.getAnnotation(Consumes.class).value()); } this.headerMap.forEach(builder::header); if (LOGGER.isDebugEnabled()) { LOGGER.debug("Service request path: {}", servicePath); LOGGER.debug("Request headers: {}", StringUtils.objectToString(this.headerMap, StringUtils.StringType.JSON, Boolean.TRUE)); LOGGER.debug("Request parameters: {}", StringUtils.objectToString(queryParameters, StringUtils.StringType.JSON, Boolean.TRUE)); LOGGER.debug("Request matrix parameters: {}", StringUtils.objectToString(matrixParameters, StringUtils.StringType.JSON, Boolean.TRUE)); } return this.execute(methodOption, builder, form, method); } } /** *

Send request and initialize response instance

*

发送请求并初始化响应实例对象

* * @param methodOption HTTP method option Enumerations * HTTP请求方法枚举 * @param builder Request builder * 请求构建器 * @param form Form information instance * 表单信息实例对象 * * @return initialized response instance * 初始化的响应实例对象 * * @throws ServiceException * If http method not supported * 如果HTTP请求方法不支持 */ private Response initResponse(final HttpMethodOption methodOption, final Invocation.Builder builder, final Form form) throws ServiceException { switch (methodOption) { case GET: return builder.get(); case PATCH: return builder.method("PATCH", Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED_TYPE)); case PUT: return builder.put(Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED_TYPE)); case POST: return builder.post(Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED_TYPE)); case DELETE: return builder.delete(); case HEAD: return builder.head(); default: throw new ServiceException("Method not supported! "); } } /** *

Send request and parse response information

*

发送请求并解析响应信息

* * @param methodOption HTTP method option Enumerations * HTTP请求方法枚举 * @param builder Request builder * 请求构建器 * @param form Form information instance * 表单信息实例对象 * @param method Invoke method instance * 调用方法的实例对象 * * @return Parsed response information * 解析的响应信息 * * @throws ServiceException * If http method not supported, or an error occurs when send request or parse response information * 如果HTTP请求方法不支持,发送请求或解析响应信息时出现异常 */ private Object execute(final HttpMethodOption methodOption, final Invocation.Builder builder, final Form form, final Method method) throws ServiceException { try (Response response = this.initResponse(methodOption, builder, form)) { boolean operateResult; switch (methodOption) { case PUT: operateResult = (response.getStatus() == HttpServletResponse.SC_CREATED || response.getStatus() == HttpServletResponse.SC_NO_CONTENT || response.getStatus() == HttpServletResponse.SC_OK); break; case POST: operateResult = (response.getStatus() == HttpServletResponse.SC_CREATED || response.getStatus() == HttpServletResponse.SC_OK); break; case PATCH: case DELETE: operateResult = (response.getStatus() == HttpServletResponse.SC_NO_CONTENT); break; default: operateResult = (response.getStatus() == HttpServletResponse.SC_OK); break; } if (operateResult) { if (response.getStatus() == HttpServletResponse.SC_NO_CONTENT) { return null; } Class returnType = method.getReturnType(); if (void.class.equals(returnType)) { return null; } String contentType = response.getHeaderString("Content-Type"); String charsetEncoding = Arrays.stream(StringUtils.tokenizeToStringArray(contentType, ";")) .filter(string -> string.trim().toLowerCase().startsWith("charset=")) .findFirst() .map(string -> string.substring("charset=".length())) .orElse(Globals.DEFAULT_ENCODING); Class paramClass = ClassUtils.componentType(method.getReturnType()); String responseData = response.readEntity(String.class); if (responseData.endsWith(FileUtils.CRLF)) { responseData = responseData.substring(0, responseData.length() - FileUtils.CRLF.length()); } if (responseData.endsWith(Character.toString(FileUtils.CR))) { responseData = responseData.substring(0, responseData.length() - Character.toString(FileUtils.CR).length()); } if (responseData.endsWith(Character.toString(FileUtils.LF))) { responseData = responseData.substring(0, responseData.length() - Character.toString(FileUtils.LF).length()); } if (returnType.isArray()) { return Optional.ofNullable(StringUtils.stringToList(responseData, charsetEncoding, paramClass)) .map(List::toArray) .orElse(new ArrayList<>().toArray()); } else if (List.class.isAssignableFrom(returnType)) { return Optional.ofNullable(StringUtils.stringToList(responseData, charsetEncoding, paramClass)) .orElse(new ArrayList<>()); } switch (response.getHeaderString(HttpHeaders.CONTENT_TYPE)) { case FileUtils.MIME_TYPE_JSON: return StringUtils.stringToObject(responseData, StringUtils.StringType.JSON, returnType); case FileUtils.MIME_TYPE_TEXT_XML: case FileUtils.MIME_TYPE_XML: return StringUtils.stringToObject(responseData, StringUtils.StringType.XML, returnType); case FileUtils.MIME_TYPE_TEXT_YAML: case FileUtils.MIME_TYPE_YAML: return StringUtils.stringToObject(responseData, StringUtils.StringType.YAML, returnType); default: return responseData; } } else { String errorMsg = response.readEntity(String.class); if (LOGGER.isDebugEnabled()) { if (response.getStatus() == HttpServletResponse.SC_BAD_REQUEST) { errorMsg += "Send request data error!"; } else if (HttpMethodOption.GET.equals(methodOption) && response.getStatus() == HttpServletResponse.SC_NOT_FOUND) { errorMsg += "Not found data! "; } else if (response.getStatus() == HttpServletResponse.SC_UNAUTHORIZED) { errorMsg += "Unauthenticated error! "; } else if (response.getStatus() == HttpServletResponse.SC_FORBIDDEN) { errorMsg += "Request forbidden! "; } else if (response.getStatus() == HttpServletResponse.SC_BAD_GATEWAY || response.getStatus() == HttpServletResponse.SC_SERVICE_UNAVAILABLE || response.getStatus() == HttpServletResponse.SC_GATEWAY_TIMEOUT) { errorMsg += "Request forbidden! "; } else { errorMsg += Globals.DEFAULT_VALUE_STRING; } LOGGER.debug("Response_Message_Debug", response.getStatus(), errorMsg); } throw new ServiceException(errorMsg); } } catch (Exception e) { if (e instanceof ServiceException) { throw e; } throw new ServiceException(e); } } } /** *

JavaBean parameter define

*

JavaBean参数定义

* * @author Steven Wee [email protected] * @version $Revision: 1.0.0 $ $Date: Jan 13, 2020 16:33:27 $ */ private static final class BeanParameter { /** * Form parameter map * 表单信息映射 */ final Map formParameters = new HashMap<>(); /** * Query parameter map * 查询信息映射 */ final Map queryParameters = new HashMap<>(); /** * Matrix parameter map * 矩阵信息映射 */ final Map matrixParameters = new HashMap<>(); /** * Header parameter map * 请求头信息映射 */ final Map headers = new HashMap<>(); /** * Path parameter map * 请求路径信息映射 */ final Map paths = new HashMap<>(); /** *

Constructor for BeanParameter

*

BeanParameter的构造方法

* * @param beanObject JavaBean parameter instance * JavaBean参数信息实例对象 * @param mediaTypes Request media types array * 请求数据类型数组 * @param adapter Data convert adapter * 数据转换适配器实例对象 */ BeanParameter(final Object beanObject, final String[] mediaTypes, final Adapter adapter) { ReflectionUtils.getAllDeclaredFields(beanObject.getClass(), Boolean.TRUE).forEach(field -> { Object fieldValue = ReflectionUtils.getFieldValue(field, beanObject); if (field.isAnnotationPresent(BeanParam.class)) { BeanParameter beanParameter = new BeanParameter(fieldValue, mediaTypes, adapter); this.formParameters.putAll(beanParameter.getFormParameters()); this.queryParameters.putAll(beanParameter.getQueryParameters()); this.matrixParameters.putAll(beanParameter.getMatrixParameters()); this.headers.putAll(beanParameter.getHeaders()); this.paths.putAll(beanParameter.getPaths()); } else { String stringValue = marshal(adapter, fieldValue); if (field.isAnnotationPresent(QueryParam.class)) { this.queryParameters.put(field.getAnnotation(QueryParam.class).value(), stringValue); } else if (field.isAnnotationPresent(FormParam.class)) { this.formParameters.put(field.getAnnotation(FormParam.class).value(), stringValue); } else if (field.isAnnotationPresent(MatrixParam.class)) { String paramName = field.getAnnotation(MatrixParam.class).value(); String[] paramValues = this.matrixParameters.getOrDefault(paramName, new String[0]); this.matrixParameters.put(paramName, appendValue(paramValues, stringValue)); } else if (field.isAnnotationPresent(HeaderParam.class)) { this.headers.put(field.getAnnotation(HeaderParam.class).value(), stringValue); } else if (field.isAnnotationPresent(PathParam.class)) { this.paths.put(field.getAnnotation(HeaderParam.class).value(), stringValue); } } }); } /** *

Getter method for form parameter map

*

表单信息映射的Getter方法

* * @return Form parameter map * 表单信息映射 */ public Map getFormParameters() { return formParameters; } /** *

Getter method for query parameter map

*

查询信息映射的Getter方法

* * @return Query parameter map * 查询信息映射 */ public Map getQueryParameters() { return queryParameters; } /** *

Getter method for matrix parameter map

*

矩阵信息映射的Getter方法

* * @return Matrix parameter map * 矩阵信息映射 */ public Map getMatrixParameters() { return matrixParameters; } /** *

Getter method for header parameter map

*

请求头信息映射的Getter方法

* * @return Header parameter map * 请求头信息映射 */ public Map getHeaders() { return headers; } /** *

Getter method for path parameter map

*

请求路径信息映射的Getter方法

* * @return Path parameter map * 请求路径信息映射 */ public Map getPaths() { return paths; } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy