001/*
002 * Copyright 2015-2024 Ping Identity Corporation
003 *
004 * This program is free software; you can redistribute it and/or modify
005 * it under the terms of the GNU General Public License (GPLv2 only)
006 * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
007 * as published by the Free Software Foundation.
008 *
009 * This program is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
012 * GNU General Public License for more details.
013 *
014 * You should have received a copy of the GNU General Public License
015 * along with this program; if not, see <http://www.gnu.org/licenses>.
016 */
017
018package com.unboundid.scim2.client.requests;
019
020import com.unboundid.scim2.client.ScimServiceException;
021import com.unboundid.scim2.common.ScimResource;
022import com.unboundid.scim2.common.annotations.NotNull;
023import com.unboundid.scim2.common.annotations.Nullable;
024import com.unboundid.scim2.common.exceptions.ScimException;
025import com.unboundid.scim2.common.messages.ErrorResponse;
026import com.unboundid.scim2.common.utils.StaticUtils;
027
028import jakarta.ws.rs.ProcessingException;
029import jakarta.ws.rs.client.Invocation;
030import jakarta.ws.rs.client.WebTarget;
031import jakarta.ws.rs.core.MediaType;
032import jakarta.ws.rs.core.MultivaluedHashMap;
033import jakarta.ws.rs.core.MultivaluedMap;
034import jakarta.ws.rs.core.Response;
035import java.util.ArrayList;
036import java.util.List;
037import java.util.Map;
038
039import static com.unboundid.scim2.common.utils.ApiConstants.MEDIA_TYPE_SCIM;
040
041/**
042 * Abstract SCIM request builder.
043 */
044public class RequestBuilder<T extends RequestBuilder>
045{
046  /**
047   * The web target to send the request.
048   */
049  @NotNull
050  private WebTarget target;
051
052  /**
053   * Arbitrary request headers.
054   */
055  @NotNull
056  protected final MultivaluedMap<String, Object> headers =
057      new MultivaluedHashMap<String, Object>();
058
059  /**
060   * Arbitrary query parameters.
061   */
062  @NotNull
063  protected final MultivaluedMap<String, Object> queryParams =
064      new MultivaluedHashMap<String, Object>();
065
066  @Nullable
067  private String contentType = MEDIA_TYPE_SCIM;
068
069  @NotNull
070  private List<String> accept = new ArrayList<String>();
071
072  /**
073   * Create a new SCIM request builder.
074   *
075   * @param target The WebTarget to send the request.
076   */
077  RequestBuilder(@NotNull final WebTarget target)
078  {
079    this.target = target;
080    accept(MEDIA_TYPE_SCIM, MediaType.APPLICATION_JSON);
081  }
082
083  /**
084   * Add an arbitrary HTTP header to the request.
085   *
086   * @param name The header name.
087   * @param value The header value(s).
088   * @return This builder.
089   */
090  @NotNull
091  @SuppressWarnings("unchecked")
092  public T header(@NotNull final String name, @NotNull final Object... value)
093  {
094    headers.addAll(name, value);
095    return (T) this;
096  }
097
098  /**
099   * Sets the media type for any content sent to the server.  The default
100   * value is ApiConstants.MEDIA_TYPE_SCIM ("application/scim+json").
101   *
102   * @param contentType a string describing the media type of content
103   *                    sent to the server.
104   * @return This builder.
105   */
106  @NotNull
107  public T contentType(@Nullable final String contentType)
108  {
109    this.contentType = contentType;
110    return (T) this;
111  }
112
113  /**
114   * Sets the media type(s) that are acceptable as a return from the server.
115   * The default accepted media types are
116   * ApiConstants.MEDIA_TYPE_SCIM ("application/scim+json") and
117   * MediaType.APPLICATION_JSON ("application/json")
118   *
119   * @param acceptStrings a string (or strings) describing the media type that
120   *                      will be accepted from the server.  This parameter may
121   *                      not be null.
122   * @return This builder.
123   */
124  @NotNull
125  public T accept(@NotNull final String... acceptStrings)
126  {
127    this.accept.clear();
128    if((acceptStrings == null) || (acceptStrings.length == 0))
129    {
130      throw new IllegalArgumentException(
131          "Accepted media types must not be null or empty");
132    }
133
134    for(String acceptString : acceptStrings)
135    {
136      accept.add(acceptString);
137    }
138
139    return (T) this;
140  }
141
142  /**
143   * Add an arbitrary query parameter to the request.
144   *
145   * @param name The query parameter name.
146   * @param value The query parameter value(s).
147   * @return This builder.
148   */
149  @NotNull
150  @SuppressWarnings("unchecked")
151  public T queryParam(@NotNull final String name,
152                      @NotNull final Object... value)
153  {
154    queryParams.addAll(name, value);
155    return (T) this;
156  }
157
158  /**
159   * Retrieve the meta.version attribute of the resource.
160   *
161   * @param resource The resource whose version to retrieve.
162   * @return The resource version.
163   * @throws IllegalArgumentException if the resource does not contain a the
164   * meta.version attribute.
165   */
166  @NotNull
167  static String getResourceVersion(@NotNull final ScimResource resource)
168      throws IllegalArgumentException
169  {
170    if(resource == null || resource.getMeta() == null ||
171        resource.getMeta().getVersion() == null)
172    {
173      throw new IllegalArgumentException(
174          "Resource version must be specified by meta.version");
175    }
176    return resource.getMeta().getVersion();
177  }
178
179  /**
180   * Convert a JAX-RS response to a ScimException.
181   *
182   * @param response The JAX-RS response.
183   * @return the converted ScimException.
184   */
185  @NotNull
186  static ScimException toScimException(@NotNull final Response response)
187  {
188    try
189    {
190      ErrorResponse errorResponse = response.readEntity(ErrorResponse.class);
191      // If are able to read an error response, use it to build the exception.
192      // If not, use the http status code to determine the exception.
193      ScimException exception = (errorResponse == null) ?
194        ScimException.createException(response.getStatus(), null) :
195        ScimException.createException(errorResponse, null);
196      response.close();
197
198      return exception;
199    }
200    catch(ProcessingException ex)
201    {
202      // The exception message likely contains unwanted details about why the
203      // server failed to process the response, instead of the actual SCIM
204      // issue. Replace it with a general reason phrase for the status code.
205      String genericDetails = response.getStatusInfo().getReasonPhrase();
206
207      return new ScimServiceException(
208          response.getStatus(), genericDetails, ex);
209    }
210  }
211
212  /**
213   * Returns the unbuilt WebTarget for the request. In most cases,
214   * {@link #buildTarget()} should be used instead.
215   *
216   * @return The WebTarget for the request.
217   */
218  @NotNull
219  protected WebTarget target()
220  {
221    return target;
222  }
223
224  /**
225   * Build the WebTarget for the request.
226   *
227   * @return The WebTarget for the request.
228   */
229  @NotNull
230  WebTarget buildTarget()
231  {
232    for(Map.Entry<String, List<Object>> queryParam : queryParams.entrySet())
233    {
234      target = target.queryParam(queryParam.getKey(),
235                                 queryParam.getValue().toArray());
236    }
237    return target;
238  }
239
240  /**
241   * Gets the media type for any content sent to the server.
242   *
243   * @return the media type for any content sent to the server.
244   */
245  @Nullable
246  protected String getContentType()
247  {
248    return contentType;
249  }
250
251  /**
252   * Gets the media type(s) that are acceptable as a return from the server.
253   *
254   * @return the media type(s) that are acceptable as a return from the server.
255   */
256  @NotNull
257  protected List<String> getAccept()
258  {
259    return accept;
260  }
261  /**
262   * Build the Invocation.Builder for the request.
263   *
264   * @return The Invocation.Builder for the request.
265   */
266  @NotNull
267  Invocation.Builder buildRequest()
268  {
269    Invocation.Builder builder =
270        buildTarget().request(accept.toArray(new String[accept.size()]));
271    for(Map.Entry<String, List<Object>> header : headers.entrySet())
272    {
273      builder = builder.header(header.getKey(),
274                               StaticUtils.listToString(header.getValue(),
275                                                        ", "));
276    }
277    return builder;
278  }
279}