diff --git a/src/main/java/com/redhat/labs/lodestar/model/filter/EngagementFilterOptions.java b/src/main/java/com/redhat/labs/lodestar/model/filter/EngagementFilterOptions.java new file mode 100644 index 00000000..8ffef4f4 --- /dev/null +++ b/src/main/java/com/redhat/labs/lodestar/model/filter/EngagementFilterOptions.java @@ -0,0 +1,64 @@ +package com.redhat.labs.lodestar.model.filter; + +import lombok.*; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; + +import javax.ws.rs.DefaultValue; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.QueryParam; +import java.util.*; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EngagementFilterOptions { + + @Parameter(name = "Accept-version", description = "Valid Values are 'v1' or 'v2'. v2 pages results by default. v1 sets per page to 500.") + @HeaderParam(value = "Accept-version") + String apiVersion; + + @Parameter(name = "search", description = "Deprecated. search string used to query engagements. allows =, not like, like, not exists, exists") + @QueryParam("search") + @Deprecated + private String search; + + @Parameter(name = "q", description = "Free search string. Currently supports Engagement and Customer names.") + @QueryParam("q") + private String q; + + @Parameter(description = "sort value. Default Dir to ASC. Ex. field1|DESC,field2,field3|DESC. Always last sort by uuid") + @QueryParam("sortFields") + @DefaultValue("lastUpdate|desc") + private String sortFields; + + @Parameter(name = "page", description = "page to be returned. Starts at 1") + @QueryParam("page") + @DefaultValue("1") + private Integer page; + + @Parameter(name = "perPage", description = "number of results per page to return") + @QueryParam("perPage") + @DefaultValue("1000") + private Integer perPage; + + @Parameter(name = "regions", description = "include only these regions. All regions if empty") + @QueryParam("regions") + private Set regions; + + @Parameter(name = "types", description = "include only these types. All types if empty") + @QueryParam("types") + private Set types; + + @Parameter(name = "states", description = "include only these states. All states if empty") + @QueryParam("states") + private Set states; + + @Parameter(name = "category", description = "find by category") + @QueryParam("category") + private String category; + + //Relic + public Set getV2Regions() { + return regions; + } +} diff --git a/src/main/java/com/redhat/labs/lodestar/resource/EngagementResource.java b/src/main/java/com/redhat/labs/lodestar/resource/EngagementResource.java index fde014e9..ec209054 100644 --- a/src/main/java/com/redhat/labs/lodestar/resource/EngagementResource.java +++ b/src/main/java/com/redhat/labs/lodestar/resource/EngagementResource.java @@ -26,6 +26,7 @@ import javax.ws.rs.core.UriInfo; import com.redhat.labs.lodestar.model.EngagementUserSummary; +import com.redhat.labs.lodestar.model.filter.EngagementFilterOptions; import com.redhat.labs.lodestar.service.ParticipantService; import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; @@ -57,8 +58,6 @@ public class EngagementResource { private static final Logger LOGGER = LoggerFactory.getLogger(EngagementResource.class); - private static final String ACCEPT_VERSION_1 = "v1"; - public static final String ACCESS_CONTROL_EXPOSE_HEADER = "Access-Control-Expose-Headers"; public static final String LAST_UPDATE_HEADER = "last-update"; @@ -86,11 +85,9 @@ public class EngagementResource { @APIResponses(value = { @APIResponse(responseCode = "401", description = "Missing or Invalid JWT"), @APIResponse(responseCode = "200", description = "A list or empty list of engagement resources returned") }) @Operation(summary = "Returns all engagement resources from the database. Can be empty list if none found.") - public Response getAll(@Context UriInfo uriInfo, @BeanParam ListFilterOptions filterOptions) { - - // create one page with many results for v1 - setDefaultPagingFilterOptions(filterOptions); + public Response getAll(@Context UriInfo uriInfo, @BeanParam EngagementFilterOptions filterOptions) { + LOGGER.debug("sort fields {}", filterOptions.getSortFields()); return engagementService.getEngagementsPaged(filterOptions); } @@ -188,7 +185,7 @@ public Response get(@PathParam("id") String uuid, @BeanParam FilterOptions filte * GET - Queries */ - // Not sure if this one is being used currently + // Not sure if this one is being used currently - should be covered by regular get. no? @GET @Path("/state/{state}") @SecurityRequirement(name = "jwt") @@ -198,19 +195,7 @@ public Response get(@PathParam("id") String uuid, @BeanParam FilterOptions filte public Response getByState(@Context UriInfo uriInfo, @PathParam("state") String state, @Parameter(name = "start", required = true, description = "start date of range") @NotBlank @QueryParam("start") String start, @Parameter(name = "end", required = true, description = "end date of range") @NotBlank @QueryParam("end") String end, - @BeanParam ListFilterOptions filterOptions) { - - // set defaults for paging if not already set - setDefaultPagingFilterOptions(filterOptions); - - // set state parameter - filterOptions.addEqualsSearchCriteria("state", state); - - // set start parameter - filterOptions.addEqualsSearchCriteria("start", start); - - // set end parameter - filterOptions.addEqualsSearchCriteria("end", end); + @BeanParam EngagementFilterOptions filterOptions) { PagedEngagementResults page = new PagedEngagementResults(); //TODO engagementService.getEngagementsPaged(filterOptions); ResponseBuilder builder = Response.ok(page.getResults()).links(page.getLinks(uriInfo.getAbsolutePathBuilder())); @@ -233,9 +218,6 @@ public Response getUserSummary(@QueryParam("search") String search) { for (String param : params) { String[] keyValues = param.split("="); - if(keyValues.length == 0) { - Response.status(Response.Status.BAD_REQUEST).build(); - } if (keyValues[0].equals("engagement_region")) { String[] regionsArray = keyValues[1].split(","); regions = Arrays.asList(regionsArray); @@ -320,33 +302,6 @@ public Response post(@Valid Engagement engagement, @Context UriInfo uriInfo) { } - @PUT - @Deprecated - @SecurityRequirement(name = "jwt") - @Path("/customers/{customerName}/projects/{projectName}") - @APIResponses(value = { @APIResponse(responseCode = "401", description = "Missing or Invalid JWT"), - @APIResponse(responseCode = "403", description = "Not authorized for engagement type"), - @APIResponse(responseCode = "404", description = "Engagement resource not found to update"), - @APIResponse(responseCode = "200", description = "Engagement updated in the database") }) - @Operation(deprecated = true, summary = "Updates the engagement resource in the database.") - public Response put(@PathParam("customerName") String customerName, @PathParam("projectName") String projectName, - @Valid Engagement engagement) { - - LOGGER.warn("Deprecated put method used /customers/{}/projects/{}", customerName, projectName); - - boolean writer = jwtUtils.isAllowedToWriteEngagement(jwt, configService.getPermission(engagement.getType())); - if(!writer) { - return forbiddenResponse(engagement.getType()); - } - - // pull user info from token - engagement.setLastUpdateByName(jwtUtils.getUsernameFromToken(jwt)); - engagement.setLastUpdateByEmail(jwtUtils.getUserEmailFromToken(jwt)); - - return Response.ok(engagementService.update(engagement)).build(); - - } - @PUT @SecurityRequirement(name = "jwt") @Path("/{id}") @@ -420,7 +375,6 @@ public Response delete(@PathParam("id") String uuid) { engagementService.deleteEngagement(uuid); return Response.accepted().build(); - } private Response forbiddenResponse(String type) { @@ -428,13 +382,4 @@ private Response forbiddenResponse(String type) { return Response.status(403).entity(message).build(); } - private void setDefaultPagingFilterOptions(ListFilterOptions options) { - - boolean isV1 = null == options.getApiVersion() || ACCEPT_VERSION_1.equals(options.getApiVersion()); - - options.setPage(options.getPage().orElse(1)); - options.setPerPage(options.getPerPage().orElse(isV1 ? 500 : 20)); - - } - } \ No newline at end of file diff --git a/src/main/java/com/redhat/labs/lodestar/resource/RefreshResource.java b/src/main/java/com/redhat/labs/lodestar/resource/RefreshResource.java index d7e7429b..6665b3a8 100644 --- a/src/main/java/com/redhat/labs/lodestar/resource/RefreshResource.java +++ b/src/main/java/com/redhat/labs/lodestar/resource/RefreshResource.java @@ -17,6 +17,8 @@ import javax.ws.rs.*; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.List; import java.util.Set; @RequestScoped @@ -51,45 +53,45 @@ public Response refresh( @Parameter(description = "Refresh hosting environments") @QueryParam("hosting") boolean refreshHosting, @Parameter(description = "Refresh engagements") @QueryParam("engagements") boolean refreshEngagements) { - boolean didPickSomething = false; + List refreshed = new ArrayList<>(); if (refreshEngagements) { //Engagements done first since all others are predicated on this content. //Engagements will be synchronous engagementService.refresh(uuids); - didPickSomething = true; + refreshed.add("engagements"); } if(refreshHosting) { eventBus.publish(EventType.RELOAD_HOSTING_EVENT_ADDRESS, EventType.RELOAD_HOSTING_EVENT_ADDRESS); - didPickSomething = true; + refreshed.add("hosting"); } if (refreshActivity) { eventBus.publish(EventType.RELOAD_ACTIVITY_EVENT_ADDRESS, EventType.RELOAD_ACTIVITY_EVENT_ADDRESS); - didPickSomething = true; + refreshed.add("activity"); } if (refreshParticipants) { eventBus.publish(EventType.RELOAD_PARTICIPANTS_EVENT_ADDRESS, EventType.RELOAD_PARTICIPANTS_EVENT_ADDRESS); - didPickSomething = true; + refreshed.add("participants"); } if (refreshArtifacts) { eventBus.publish(EventType.RELOAD_ARTIFACTS_EVENT_ADDRESS, EventType.RELOAD_ARTIFACTS_EVENT_ADDRESS); - didPickSomething = true; + refreshed.add("artifacts"); } if (refreshStatus) { eventBus.publish(EventType.RELOAD_ENGAGEMENT_STATUS_EVENT_ADDRESS, EventType.RELOAD_ENGAGEMENT_STATUS_EVENT_ADDRESS); - didPickSomething = true; + refreshed.add("status"); } - if (didPickSomething) { - return Response.accepted().build(); + if (refreshed.isEmpty()) { + return Response.status(400).entity("{ \"message\" : \"No refresh source was selected\" }").build(); } - return Response.status(400).entity("{ \"message\" : \"No refresh source was selected\" }").build(); + return Response.accepted().entity(refreshed).build(); } diff --git a/src/main/java/com/redhat/labs/lodestar/rest/client/EngagementApiClient.java b/src/main/java/com/redhat/labs/lodestar/rest/client/EngagementApiClient.java index 36f33119..88c4f986 100644 --- a/src/main/java/com/redhat/labs/lodestar/rest/client/EngagementApiClient.java +++ b/src/main/java/com/redhat/labs/lodestar/rest/client/EngagementApiClient.java @@ -17,7 +17,9 @@ public interface EngagementApiClient { @GET - Response getEngagements(@QueryParam("page") int page, @QueryParam("pageSize") int pageSize, @QueryParam("region") Set region); + Response getEngagements(@QueryParam("page") int page, @QueryParam("pageSize") int pageSize, @QueryParam("region") Set region, + @QueryParam("types") Set types, @QueryParam("inStates") Set states, @QueryParam("q") String search, + @QueryParam("category") String category, @QueryParam("sort") String sort); //TODO support time shifting @GET @@ -26,7 +28,8 @@ public interface EngagementApiClient { @GET @Path("category/{category}") - List getEngagementsWithCategory(@PathParam("category") String category); + List getEngagementsWithCategory(@QueryParam("page") int page, @QueryParam("pageSize") int pageSize, @QueryParam("region") Set region, + @QueryParam("types") Set types, @QueryParam("inStates") Set states, @PathParam("category") String category, @QueryParam("sort") String sort); @GET @Path("{uuid}") diff --git a/src/main/java/com/redhat/labs/lodestar/service/ArtifactService.java b/src/main/java/com/redhat/labs/lodestar/service/ArtifactService.java index bee074c3..17e70858 100644 --- a/src/main/java/com/redhat/labs/lodestar/service/ArtifactService.java +++ b/src/main/java/com/redhat/labs/lodestar/service/ArtifactService.java @@ -1,9 +1,6 @@ package com.redhat.labs.lodestar.service; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; @@ -43,9 +40,15 @@ public List getArtifacts(String engagementUuid) { ArtifactOptions options = ArtifactOptions.builder().page(0).pageSize(1000) .engagementUuid(engagementUuid).build(); - Response response = artifactRestClient.getArtifacts(options); - - return response.readEntity(new GenericType<>(){}); + try { + return artifactRestClient.getArtifacts(options).readEntity(new GenericType<>(){}); + } catch (WebApplicationException wex) { + if(wex.getResponse().getStatus() >= 500) { + LOGGER.error("Artifact Server error ({}) from hosting env for euuid {}", wex.getResponse().getStatus(), engagementUuid); + return Collections.EMPTY_LIST; + } + throw wex; + } } public Response getArtifacts(ListFilterOptions filterOptions, String engagementUuid, String type, List region, boolean dashboardView) { diff --git a/src/main/java/com/redhat/labs/lodestar/service/EngagementService.java b/src/main/java/com/redhat/labs/lodestar/service/EngagementService.java index 7ed3c0bd..f23a6b52 100644 --- a/src/main/java/com/redhat/labs/lodestar/service/EngagementService.java +++ b/src/main/java/com/redhat/labs/lodestar/service/EngagementService.java @@ -2,6 +2,7 @@ import com.redhat.labs.lodestar.model.*; import com.redhat.labs.lodestar.model.Engagement.EngagementState; +import com.redhat.labs.lodestar.model.filter.EngagementFilterOptions; import com.redhat.labs.lodestar.model.filter.ListFilterOptions; import com.redhat.labs.lodestar.model.pagination.PagedEngagementResults; import com.redhat.labs.lodestar.rest.client.CategoryApiClient; @@ -19,6 +20,7 @@ import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; +import javax.ws.rs.ProcessingException; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.GenericType; import javax.ws.rs.core.Response; @@ -116,6 +118,8 @@ private Status getStatus(String uuid) { } else { LOGGER.error("Exception occurred retrieving status for engagement {}", uuid); } + } catch (ProcessingException pe) { + LOGGER.error("Cannot connect to lodestar-engagement-status for engagement {}", uuid, pe); } return null; @@ -321,51 +325,37 @@ public Map getEngagementCountByStatus(Instant currentT * Returns a {@link PagedEngagementResults} of {@link Engagement} that matches * the {@link ListFilterOptions}. * - * @param listFilterOptions + * @param filter * @return ? */ - public Response getEngagementsPaged(ListFilterOptions listFilterOptions) { + public Response getEngagementsPaged(EngagementFilterOptions filter) { //TODO hacking for v1 //TODO should probably better align last activity field on engagement object so that // we can just filter on engagement instead of hitting the activity service - String sort = listFilterOptions.getSortFields().orElse(""); - int pageSize = listFilterOptions.getPerPage().orElse(5); + String sort = filter.getSortFields(); + int pageSize = filter.getPerPage(); if(pageSize == 5 && sort.equals("last_update")) { - List activity = activityService.getLatestActivity(0,5, listFilterOptions.getV2Regions()); + List activity = activityService.getLatestActivity(0,5, filter.getV2Regions()); List engagements = activity.stream().map(this::getEngagement).collect(Collectors.toList()); return Response.ok(engagements).build(); } - int page = listFilterOptions.getPage().orElse(1) - 1; - pageSize = listFilterOptions.getPerPage().orElse(1000); + int page = filter.getPage() - 1; + pageSize = filter.getPerPage(); - Response response = engagementApiClient.getEngagements(page, pageSize, listFilterOptions.getV2Regions()); + Response response = engagementApiClient.getEngagements(page, pageSize, filter.getRegions(), filter.getTypes(), filter.getStates(), filter.getQ(), filter.getCategory(), sort); List engagements = response.readEntity(new GenericType<>(){}); + String total = response.getHeaderString("x-total-engagements"); Map engagementOptions = configService.getEngagementOptions(); //TODO this loop is to allow frontend to change after v2 deployment. // FE should use participant, artifact count field, and categories (string version) for(Engagement e : engagements) { - - for(int i=0; i engagementUuids, int page, in } public List getHostingEnvironments(String engagementUuid) { - return hostingEnvironmentApiClient.getHostingEnvironmentsByEngagementUuid(engagementUuid); + try { + return hostingEnvironmentApiClient.getHostingEnvironmentsByEngagementUuid(engagementUuid); + } catch (WebApplicationException wex) { + if(wex.getResponse().getStatus() >= 500) { + LOGGER.error("Hosting Server error ({}) from hosting env for euuid {}", wex.getResponse().getStatus(), engagementUuid); + return Collections.EMPTY_LIST; + } + throw wex; + } } public List updateAndReload(String engagementUuid, List hostingEnvironments, Author author) { diff --git a/src/main/java/com/redhat/labs/lodestar/service/ParticipantService.java b/src/main/java/com/redhat/labs/lodestar/service/ParticipantService.java index f7305d12..21e0644b 100644 --- a/src/main/java/com/redhat/labs/lodestar/service/ParticipantService.java +++ b/src/main/java/com/redhat/labs/lodestar/service/ParticipantService.java @@ -1,5 +1,6 @@ package com.redhat.labs.lodestar.service; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -33,7 +34,15 @@ public class ParticipantService { EngagementService engagementService; public List getParticipantsForEngagement(String engagementUuid) { - return participantRestClient.getParticipantsForEngagement(engagementUuid); + try { + return participantRestClient.getParticipantsForEngagement(engagementUuid); + } catch (WebApplicationException wex) { + if(wex.getResponse().getStatus() >= 500) { + LOGGER.error("Participant Server error ({}) from hosting env for euuid {}", wex.getResponse().getStatus(), engagementUuid); + return Collections.EMPTY_LIST; + } + throw wex; + } } public Response getParticipants(int page, int pageSize) { diff --git a/src/test/java/com/redhat/labs/lodestar/resource/EngagementResourceGetTest.java b/src/test/java/com/redhat/labs/lodestar/resource/EngagementResourceGetTest.java index 3dc93f7f..be2fa8ec 100644 --- a/src/test/java/com/redhat/labs/lodestar/resource/EngagementResourceGetTest.java +++ b/src/test/java/com/redhat/labs/lodestar/resource/EngagementResourceGetTest.java @@ -26,6 +26,7 @@ class EngagementResourceGetTest extends IntegrationTestHelper { static String validToken = TokenUtils.generateTokenString("/JwtClaimsWriter.json"); + static String defaultSort = "lastUpdate|desc"; @InjectMock @RestClient @@ -93,7 +94,8 @@ void testGetEngagementDoesNotExist() { @Test void testGetEngagementsSuccessNoEngagements() { - Mockito.when(engagementApiClient.getEngagements(0, 500, Collections.emptySet())).thenReturn(javax.ws.rs.core.Response.ok(Collections.emptyList()).build()); + Mockito.when(engagementApiClient.getEngagements(0, 1000, Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), null,null, defaultSort)) + .thenReturn(javax.ws.rs.core.Response.ok(Collections.emptyList()).build()); // GET engagement given() @@ -106,7 +108,7 @@ void testGetEngagementsSuccessNoEngagements() { .statusCode(200) .body("size()", equalTo(0)); - Mockito.verify(engagementApiClient).getEngagements(0, 500, Collections.emptySet()); + Mockito.verify(engagementApiClient).getEngagements(0, 1000, Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), null,null, defaultSort); } @Test @@ -117,7 +119,8 @@ void testGetEngagementsSuccess() { Engagement engagement2 = Engagement.builder().uuid("1235").type("Residency").customerName("Customer").projectName("Project2") .participantCount(10).build(); - Mockito.when(engagementApiClient.getEngagements(0, 500, Collections.emptySet())).thenReturn(Response.ok(List.of(engagement1, engagement2)).build()); + Mockito.when(engagementApiClient.getEngagements(0, 1000, Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), null, null, defaultSort)) + .thenReturn(Response.ok(List.of(engagement1, engagement2)).build()); // GET engagement given() @@ -129,10 +132,12 @@ void testGetEngagementsSuccess() { .then() .statusCode(200) .body("size()", equalTo(2)) - .body("[1].engagement_users.size()", equalTo(10)) - .body("[0].artifacts.size()", equalTo(4)); + .body("[0].participant_count", equalTo(0)) + .body("[1].participant_count", equalTo(10)) + .body("[0].artifact_count", equalTo(4)) + .body("[1].artifact_count", equalTo(0)); - Mockito.verify(engagementApiClient).getEngagements(0, 500, Collections.emptySet()); + Mockito.verify(engagementApiClient).getEngagements(0, 1000, Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), null,null, defaultSort); } // TODO Abandoning support for include / exclude in v2. @@ -144,7 +149,8 @@ void testGetEngagementsSuccess() { void testGetAllWithExcludeAndInclude() { String token = TokenUtils.generateTokenString("/JwtClaimsWriter.json"); - Mockito.when(engagementApiClient.getEngagements(0, 500, Collections.emptySet())).thenReturn(Response.ok(Collections.emptyList()).build()); + Mockito.when(engagementApiClient.getEngagements(0, 1000, Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), null,null, defaultSort)) + .thenReturn(Response.ok(Collections.emptyList()).build()); // get all given() @@ -163,7 +169,8 @@ void testGetAllWithExcludeAndInclude() { void testGetAllWithInclude() { String token = TokenUtils.generateTokenString("/JwtClaimsWriter.json"); - Mockito.when(engagementApiClient.getEngagements(0, 500, Collections.emptySet())).thenReturn(Response.ok(Collections.emptyList()).build()); + Mockito.when(engagementApiClient.getEngagements(0, 1000, Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), null,null, defaultSort)) + .thenReturn(Response.ok(Collections.emptyList()).build()); // get all given() @@ -290,5 +297,24 @@ void testGetEngagementUserSummaryRegion() { .body("other_users_count", equalTo(6)) .body("rh_users_count", equalTo(1000000000)); + enabled = new HashMap<>(); + enabled.put("Red Hat", 7L); + enabled.put("Others", 5L); + enabled.put("All", 12L); + + Mockito.when(participantApiClient.getEnabledParticipants(Collections.emptyList())) + .thenReturn(enabled); + + given() + .queryParam("search", "not_engagement_region=na") + .when() + .auth() + .oauth2(token) + .get("/engagements/users/summary") + .then() + .statusCode(200).body("all_users_count", equalTo(12)) + .body("other_users_count", equalTo(5)) + .body("rh_users_count", equalTo(7)); + } } \ No newline at end of file diff --git a/src/test/java/com/redhat/labs/lodestar/resource/EngagementResourceUpdateTest.java b/src/test/java/com/redhat/labs/lodestar/resource/EngagementResourceUpdateTest.java index 2f976db1..2f1dfedb 100644 --- a/src/test/java/com/redhat/labs/lodestar/resource/EngagementResourceUpdateTest.java +++ b/src/test/java/com/redhat/labs/lodestar/resource/EngagementResourceUpdateTest.java @@ -348,17 +348,5 @@ void testPutEngagementWithConflictingSubdomain() { .statusCode(409); } - - //TODO this method should be removed - only call via uuid - @Test - void testPutEngagementWithNameNoGroup() { - - Engagement engagement = Engagement.builder().uuid("1234").customerName("Customer").projectName("Project").type("DO500").build(); - Mockito.when(engagementApiClient.getEngagement("1234")).thenReturn(engagement); - - String body = quarkusJsonb.toJson(engagement); - given().when().auth().oauth2(validToken).body(body).contentType(ContentType.JSON).put("/engagements/customers/c1/projects/e2").then() - .statusCode(403); - } } \ No newline at end of file