PageRenderTime 49ms CodeModel.GetById 20ms RepoModel.GetById 0ms app.codeStats 0ms

/src/main/java/com/atlassian/jconnect/rest/resources/IssueResource.java

https://bitbucket.org/atlassian/jiraconnect-jiraplugin/
Java | 411 lines | 318 code | 52 blank | 41 comment | 49 complexity | bf17ab224e3240d60e8591eb645bd91d MD5 | raw file
  1. package com.atlassian.jconnect.rest.resources;
  2. import com.atlassian.crowd.embedded.api.User;
  3. import com.atlassian.jconnect.jira.IssueActivityService;
  4. import com.atlassian.jconnect.jira.IssueHelper;
  5. import com.atlassian.jconnect.jira.JMCProjectService;
  6. import com.atlassian.jconnect.jira.UserHelper;
  7. import com.atlassian.jconnect.rest.entities.*;
  8. import com.atlassian.jconnect.util.Either;
  9. import com.atlassian.jira.exception.CreateException;
  10. import com.atlassian.jira.issue.CustomFieldManager;
  11. import com.atlassian.jira.issue.Issue;
  12. import com.atlassian.jira.issue.MutableIssue;
  13. import com.atlassian.jira.issue.fields.CustomField;
  14. import com.atlassian.jira.project.Project;
  15. import com.atlassian.jira.util.ErrorCollection;
  16. import com.atlassian.jira.util.IOUtil;
  17. import com.atlassian.jira.util.json.JSONException;
  18. import com.atlassian.jira.util.json.JSONObject;
  19. import com.atlassian.jira.web.util.AttachmentException;
  20. import com.atlassian.plugins.rest.common.security.AnonymousAllowed;
  21. import com.opensymphony.workflow.InvalidInputException;
  22. import org.apache.commons.fileupload.FileItemIterator;
  23. import org.apache.commons.fileupload.FileItemStream;
  24. import org.apache.commons.fileupload.FileUploadException;
  25. import org.apache.commons.fileupload.servlet.ServletFileUpload;
  26. import org.apache.commons.io.IOUtils;
  27. import org.ofbiz.core.entity.GenericEntityException;
  28. import org.slf4j.Logger;
  29. import org.slf4j.LoggerFactory;
  30. import javax.servlet.http.HttpServletRequest;
  31. import javax.ws.rs.*;
  32. import javax.ws.rs.core.Context;
  33. import javax.ws.rs.core.MediaType;
  34. import javax.ws.rs.core.Response;
  35. import java.io.ByteArrayInputStream;
  36. import java.io.IOException;
  37. import java.io.InputStream;
  38. import java.util.*;
  39. import static com.google.common.base.Preconditions.checkNotNull;
  40. /**
  41. * The issue resource, provides 3 end-points for:
  42. * <ul>
  43. * <li> /issue/create creating issues with attachments </li>
  44. * <li> /issue/updates retrieving updates for a specific uuid </li>
  45. * <li> /issue/comment/${issue-key} UUID, and commenting on an issue </li>
  46. * </ul>
  47. */
  48. @Path("/issue")
  49. public class IssueResource {
  50. private static final Logger log = LoggerFactory.getLogger(IssueResource.class);
  51. private final IssueHelper issueHelper;
  52. private final UserHelper userHelper;
  53. private final IssueActivityService issueUpdateService;
  54. private final CustomFieldManager customFieldManager;
  55. private final JMCProjectService connectProjectService;
  56. /**
  57. * This is required to map a mobile user, to a JIRA issue.
  58. */
  59. private static final String CF_NAME_UUID = "uuid";
  60. public IssueResource(final IssueHelper issueHandler,
  61. final UserHelper userHelper,
  62. final IssueActivityService issueUpdateService,
  63. final CustomFieldManager customFieldManager,
  64. JMCProjectService connectProjectService) {
  65. this.issueHelper = issueHandler;
  66. this.userHelper = userHelper;
  67. this.issueUpdateService = issueUpdateService;
  68. this.customFieldManager = customFieldManager;
  69. this.connectProjectService = connectProjectService;
  70. }
  71. @POST
  72. @AnonymousAllowed
  73. @Consumes(MediaType.MULTIPART_FORM_DATA)
  74. @Path("create")
  75. @Produces(MediaType.APPLICATION_JSON)
  76. public Response createIssue(@QueryParam("project") String project,
  77. @QueryParam("apikey") String apikey,
  78. @Context HttpServletRequest request) {
  79. try {
  80. final Either<Project, Response.ResponseBuilder> result = lookupProjectByNameOrKey(project, apikey);
  81. if (result.getRight() != null) {
  82. return result.getRight().build();
  83. }
  84. final Map<String, UploadData> data = parseUploadData(request);
  85. // resolve user and create issue
  86. final UploadData issueData = data.get("issue");
  87. final IssueEntity issueEntity = parseIssueEntity(issueData);
  88. if (issueEntity.isCrash() && !connectProjectService.isCrashesEnabledFor(result.getLeft()))
  89. {
  90. final IssueWithCommentsEntity issueWithCommentsEntity =
  91. new IssueWithCommentsEntity("CRASHES-DISABLED",
  92. "crash-reporting-disabled",
  93. issueEntity.getSummary(),
  94. issueEntity.getDescription(),
  95. new Date(),
  96. new Date(),
  97. Collections.<CommentEntity>emptyList(),
  98. false);
  99. return Response.ok(issueWithCommentsEntity).build();
  100. }
  101. final User user = userHelper.getOrCreateJMCSystemUser();
  102. // ensure uuid is set, and there is a uuid customfield configured in JIRA.
  103. final CustomField uuid = customFieldManager.getCustomFieldObjectByName(CF_NAME_UUID);
  104. // add any attachments
  105. final UploadData customfields = data.get("customfields");
  106. final List<CustomField> customFields = new ArrayList<CustomField>();
  107. final List<Object> values = new ArrayList<Object>();
  108. if (customfields != null) {
  109. extractCustomFields(customfields, customFields, values);
  110. }
  111. // create issue object
  112. final Issue issue = issueHelper.createIssue(issueEntity, uuid, result.getLeft(), user, customFields, values);
  113. addAnyAttachments(data, user, issue);
  114. // the response should be JSON for the issue we just created.
  115. final IssueWithCommentsEntity issueWithCommentsEntity =
  116. new IssueWithCommentsEntity(issue.getKey(),
  117. issue.getStatusObject().getName(),
  118. issue.getSummary(),
  119. issue.getDescription(),
  120. issue.getCreated(),
  121. issue.getUpdated(),
  122. Collections.<CommentEntity>emptyList(),
  123. false);
  124. return Response.ok(issueWithCommentsEntity).build();
  125. } catch (IOException e) {
  126. return handleException(e);
  127. } catch (AttachmentException e) {
  128. return handleException(e);
  129. } catch (GenericEntityException e) {
  130. return handleException(e);
  131. } catch (JSONException e) {
  132. return handleException(e);
  133. } catch (CreateException e) {
  134. if (e.getCause() instanceof InvalidInputException) {
  135. // if the jiraconnectuser is not authorised to create issues in this project -> 401
  136. return Response.status(Response.Status.UNAUTHORIZED).entity(e.getMessage()).build();
  137. }
  138. return handleException(e);
  139. } catch (FileUploadException e) {
  140. return handleException(e);
  141. }
  142. }
  143. private Response handleException(Exception e) {
  144. log.error(e.getMessage(), e);
  145. return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build();
  146. }
  147. @POST
  148. @AnonymousAllowed
  149. @Consumes(MediaType.MULTIPART_FORM_DATA)
  150. @Produces(MediaType.APPLICATION_JSON)
  151. @Path("/comment/{issueKey}")
  152. public Response addComment(@PathParam("issueKey") final String issueKey,
  153. @QueryParam("apikey") String apikey,
  154. @Context HttpServletRequest request) {
  155. try {
  156. // retrieve existing user & issue
  157. final User user = userHelper.getOrCreateJMCSystemUser();
  158. final MutableIssue issue = issueHelper.getIssue(checkNotNull(issueKey, "issueKey"));
  159. if (issue == null) {
  160. return Response.status(Response.Status.NOT_FOUND)
  161. .entity(String.format("The specified issue %s does not exist", issueKey))
  162. .build();
  163. }
  164. // Once a project is disabled for JMC, then replies are also turned OFF.
  165. final Either<Project,Response.ResponseBuilder> result = lookupProjectByNameOrKey(issue.getProjectObject().getKey(), apikey);
  166. if (result.getRight() != null) {
  167. return result.getRight().build();
  168. }
  169. // users should never get here, unless there is a uuid custom field for this project.
  170. final Map<String, UploadData> data = parseUploadData(request);
  171. final UploadData issueData = data.get("issue");
  172. // nb. at the moment we only use the uuid & description fields from the json blob
  173. final IssueEntity issueEntity = parseIssueEntity(issueData);
  174. final Response.ResponseBuilder errorResponse = checkUUIDIsGood(issueEntity, issue);
  175. if (errorResponse != null) {
  176. return errorResponse.entity("You are unauthorized to comment on this issue.").build();
  177. }
  178. // add comment & any attachments
  179. final String description = issueEntity.getDescription();
  180. final ErrorCollection errors = issueHelper.addComment(issue, description, user);
  181. final Response.ResponseBuilder commentError = checkForCommentErrors(errors, user, issue);
  182. if (commentError != null) {
  183. return commentError.build();
  184. }
  185. addAnyAttachments(data, user, issue);
  186. try {
  187. issueHelper.updateIssue(issue, issueEntity, user);
  188. } catch (Throwable e) {
  189. log.warn("Could not update issue. Comment and attachments still added though.", e);
  190. }
  191. final CommentEntity commentEntity = new CommentEntity(user.getName(), true, description, new Date(), issue.getKey());
  192. return Response.ok(commentEntity).build();
  193. } catch (GenericEntityException e) {
  194. return handleException(e);
  195. } catch (AttachmentException e) {
  196. return handleException(e);
  197. } catch (FileUploadException e) {
  198. return handleException(e);
  199. } catch (IOException e) {
  200. return handleException(e);
  201. } catch (JSONException e) {
  202. return handleException(e);
  203. }
  204. }
  205. /**
  206. * JIRA only GZIP encodes the following mime-types by default:
  207. * text/.*,application/x-javascript,application/javascript,application/xml,application/xhtml\+xml
  208. *
  209. * Returns all issues and comments created by the given uuid.
  210. * if there were no updates, then an empty JSON array is returned.
  211. *
  212. */
  213. @GET
  214. @AnonymousAllowed
  215. @Produces(MediaType.APPLICATION_JSON)
  216. @Path("/updates")
  217. public Response getIssuesAndCommentsFor(@QueryParam("project") final String project,
  218. @QueryParam(CF_NAME_UUID) final String uuid,
  219. @QueryParam("sinceMillis") final long sinceMillis,
  220. @QueryParam("apikey") String apikey,
  221. @Context HttpServletRequest request) {
  222. final Either<Project, Response.ResponseBuilder> result = lookupProjectByNameOrKey(project, apikey);
  223. if (result.getRight() != null) {
  224. return result.getRight().build();
  225. }
  226. // this ensures application/json is gzipped! see UrlRewriteGzipCompatibilitySelector
  227. // a typical response here is 3x smaller with compression enabled.
  228. request.setAttribute("gzipMimeTypes", MediaType.APPLICATION_JSON);
  229. final IssuesWithCommentsEntity issuesWithComments = issueUpdateService.getIssuesWithCommentsIfUpdatesExists(result.getLeft(), uuid, sinceMillis);
  230. return Response.ok(issuesWithComments).lastModified(new Date()).build();
  231. }
  232. private Either<Project, Response.ResponseBuilder> lookupProjectByNameOrKey(String projectNameOrKey, String apiKey) {
  233. if (projectNameOrKey == null) {
  234. return Either.right(Response.status(Response.Status.BAD_REQUEST).entity("project request parameter must be specified."));
  235. }
  236. Project project = issueHelper.lookupProjectByKey(projectNameOrKey);
  237. project = (project == null) ? issueHelper.lookupProjectByName(projectNameOrKey) : project;
  238. if (project == null) {
  239. return Either.right(Response.status(Response.Status.FORBIDDEN).entity(
  240. "Project: " + projectNameOrKey + " does not exist in this JIRA instance.\n" +
  241. "Please add a project called " + projectNameOrKey + " before continuing.\n" +
  242. "Alternatively, configure the name of an existing project in your <JCOCustomDataSource> protocol."));
  243. }
  244. if (!connectProjectService.isJiraConnectProject(project)) {
  245. return Either.right(Response.status(Response.Status.UNAUTHORIZED).entity(
  246. "JIRA Mobile Connect is not enabled for project: " + project.getKey() +
  247. ". Please enable JIRA Mobile Connect in the Project Settings in JIRA for this project."));
  248. }
  249. final boolean apiKeyEnabled = connectProjectService.isApiKeyEnabledFor(project);
  250. if (apiKeyEnabled) {
  251. final String projectsApiKey = connectProjectService.lookupApiKeyFor(project);
  252. if (projectsApiKey == null) {
  253. return Either.right(Response.status(Response.Status.FORBIDDEN).entity("Project is missing API Key."));
  254. }
  255. if (apiKey == null) {
  256. return Either.right(Response.status(Response.Status.FORBIDDEN).entity(
  257. "This request is missing the apikey parameter. Please upgrade the JIRA Mobile Connect SDK."));
  258. }
  259. if (!projectsApiKey.equalsIgnoreCase(apiKey)) {
  260. return Either.right(Response.status(Response.Status.FORBIDDEN).entity(
  261. "Invalid API Key. '" + apiKey + "' Please ensure it is correctly configured."));
  262. }
  263. }
  264. return Either.left(project);
  265. }
  266. private Response.ResponseBuilder checkForCommentErrors(ErrorCollection errors, User user, Issue issue) {
  267. if (errors.hasAnyErrors()) {
  268. log.warn(String.format("Errors encountered when %s commented on %s:", user.getName(), issue.getKey()));
  269. final StringBuilder respsonseStr = new StringBuilder();
  270. for (String msg : errors.getErrorMessages()) {
  271. respsonseStr.append(msg).append('\n');
  272. }
  273. return Response.status(Response.Status.BAD_REQUEST).entity(respsonseStr.toString());
  274. } else {
  275. log.debug(String.format("User %s commented on %s", user.getName(), issue.getKey()));
  276. }
  277. return null;
  278. }
  279. /**
  280. * Ensures that issue has a custom uuid field, and that it matches the uuid in IssueEntity
  281. *
  282. * @param issueEntity the issue entity sent from the device
  283. * @param issue the issue retrieved from the database
  284. * @return a ResponseBuilder if there is an error, or null if the uuid is good
  285. */
  286. private Response.ResponseBuilder checkUUIDIsGood(IssueEntity issueEntity, Issue issue) {
  287. final CustomField uuid = customFieldManager.getCustomFieldObjectByName(CF_NAME_UUID);
  288. if (uuid == null) {
  289. return handleMissingUUID();
  290. }
  291. final Object fieldValue = issue.getCustomFieldValue(uuid);
  292. if (fieldValue == null) {
  293. return Response.status(Response.Status.UNAUTHORIZED);
  294. }
  295. final String uuidFiedlValue = (String) fieldValue;
  296. if (!uuidFiedlValue.equals(issueEntity.getUuid())) {
  297. return Response.status(Response.Status.UNAUTHORIZED);
  298. }
  299. return null;
  300. }
  301. private Response.ResponseBuilder handleMissingUUID() {
  302. // handle missing uuid custom uuid. create on the fly?
  303. return Response.status(Response.Status.FORBIDDEN).entity("Missing uuid.");
  304. }
  305. private void extractCustomFields(UploadData customfields, List<CustomField> fields, List<Object> values) throws JSONException, IOException {
  306. final JSONObject json = new JSONObject(IOUtils.toString(customfields.getInputStream(), "UTF-8"));
  307. // so we can do a case-insensitive match, get all custom fields, and then look up in the json
  308. final List<CustomField> allCustomFields = customFieldManager.getCustomFieldObjects();
  309. for (CustomField field : allCustomFields) {
  310. final String fieldNameLower = field.getName().toLowerCase();
  311. if (json.has(fieldNameLower) || json.has(field.getName())) {
  312. final String fieldValue = json.has(fieldNameLower) ? json.getString(fieldNameLower) : json.getString(field.getName());
  313. if (fieldValue != null) {
  314. fields.add(field);
  315. values.add(field.getCustomFieldType().getSingularObjectFromString(fieldValue));
  316. }
  317. }
  318. }
  319. }
  320. private void addAnyAttachments(final Map<String, UploadData> data, final User user, final Issue issue) throws IOException, AttachmentException, GenericEntityException {
  321. for (Iterator<Map.Entry<String, UploadData>> iterator = data.entrySet().iterator(); iterator.hasNext();) {
  322. addAttachment(user, issue, iterator.next().getValue());
  323. }
  324. }
  325. private void addAttachment(User user, Issue issue, UploadData payload) throws IOException, AttachmentException, GenericEntityException {
  326. if (isValidAttachment(payload)) {
  327. issueHelper.addAttachment(issue, payload, user);
  328. }
  329. }
  330. private Map<String, UploadData> parseUploadData(final HttpServletRequest request) throws FileUploadException, IOException {
  331. ServletFileUpload upload = new ServletFileUpload();
  332. FileItemIterator iterator = upload.getItemIterator(request);
  333. Map<String, UploadData> data = new HashMap<String, UploadData>();
  334. while (iterator.hasNext()) {
  335. try {
  336. FileItemStream item = iterator.next();
  337. byte[] bytes = IOUtils.toByteArray(item.openStream());
  338. InputStream stream = new ByteArrayInputStream(bytes);
  339. UploadData uploadData = new UploadData(stream, item.getFieldName(), item.getName(), item.getContentType());
  340. data.put(item.getFieldName(), uploadData);
  341. } catch (FileItemStream.ItemSkippedException e) {
  342. log.warn("skipped upload content", e);
  343. }
  344. }
  345. return data;
  346. }
  347. private IssueEntity parseIssueEntity(UploadData issueData) throws JSONException, IOException {
  348. final InputStream inputStream = issueData.getInputStream();
  349. JSONObject obj = new JSONObject(IOUtils.toString(inputStream, "UTF-8"));
  350. return IssueEntity.fromJSONObj(obj);
  351. }
  352. private boolean isValidAttachment(final UploadData data) {
  353. return data != null && data.getName() != null && !isSystemAttachment(data);
  354. }
  355. private boolean isSystemAttachment(UploadData data) {
  356. return data.getName().equals("issue") ||
  357. data.getName().equals("customfields");
  358. }
  359. }