diff --git a/README.md b/README.md index 889dfdb56..06c641246 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,11 @@ Note that this docker-compose script is intended for developer use; for producti If you are using Podman (and podman-compose) rather than Docker, please use ```bash -podman-compose -f podman-compose.yaml -d +podman-compose -f podman-compose.yml up -d ``` +> :warning: **If postgres fails to start**: clear any cached data in the postgresl container mounted volume `podman volume inspect Horreum_horreum_pg12 | jq -r '.[0].Mountpoint'` + Due to subtleties in Podman's rootless network configuration it's not possible to use `docker-compose.yaml`. ## Getting Started @@ -29,6 +31,7 @@ cd webapp && npm install && cd .. `localhost:3000` to access the create-react-app live code server and `localhost:8080` to access the quarkus development server. +> :warning: *If npm install fails*: please try clearing the node module cache `npm cache clean` ## Creating jar ```bash ./mvnw clean package -Dui diff --git a/podman-compose.yml b/podman-compose.yml index 55cc8c241..7aaa4faba 100644 --- a/podman-compose.yml +++ b/podman-compose.yml @@ -51,6 +51,7 @@ services: - -Dkeycloak.migration.strategy=IGNORE_EXISTING - -Djboss.socket.binding.port-offset=100 - -Djboss.bind.address=127.0.0.1 + - -Djboss.bind.address.private=127.0.0.1 environment: - KEYCLOAK_USER=admin - KEYCLOAK_PASSWORD=secret diff --git a/pom.xml b/pom.xml index f67addbc5..c7e56c9b2 100644 --- a/pom.xml +++ b/pom.xml @@ -194,6 +194,10 @@ io.quarkus quarkus-container-image-jib + + io.quarkus + quarkus-smallrye-openapi + io.vertx vertx-core diff --git a/src/main/java/io/hyperfoil/tools/horreum/api/AlertingService.java b/src/main/java/io/hyperfoil/tools/horreum/api/AlertingService.java index c917c5662..9eccbe041 100644 --- a/src/main/java/io/hyperfoil/tools/horreum/api/AlertingService.java +++ b/src/main/java/io/hyperfoil/tools/horreum/api/AlertingService.java @@ -38,6 +38,7 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import io.hyperfoil.tools.yaup.StringUtil; import org.apache.commons.math3.stat.descriptive.SummaryStatistics; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.rest.client.inject.RestClient; @@ -188,7 +189,7 @@ private void onNewRun(Run run, boolean notify) { extractionQuery.append("$.*"); } // four colons to escape it for Hibernate - extractionQuery.append(jsonpath).append("'::::jsonpath)::::text as ").append(accessor); + extractionQuery.append(jsonpath).append("'::::jsonpath)::::text as ").append(StringUtil.quote(accessor, "\"")); var.accessors.add(accessor); } extractionQuery.append(" FROM current_run"); @@ -474,12 +475,12 @@ public void afterCompletion(int status) { @PermitAll @GET @Path("variables") - public Response variables(@QueryParam("test") Integer testId) { + public List variables(@QueryParam("test") Integer testId) { try (@SuppressWarnings("unused") CloseMe closeMe = sqlService.withRoles(em, identity)) { if (testId != null) { - return Response.ok(Variable.list("testid", testId)).build(); + return Variable.list("testid", testId); } else { - return Response.ok(Variable.listAll()).build(); + return Variable.listAll(); } } } diff --git a/src/main/java/io/hyperfoil/tools/horreum/api/HookService.java b/src/main/java/io/hyperfoil/tools/horreum/api/HookService.java index fa3e43864..673469b8d 100644 --- a/src/main/java/io/hyperfoil/tools/horreum/api/HookService.java +++ b/src/main/java/io/hyperfoil/tools/horreum/api/HookService.java @@ -1,6 +1,8 @@ package io.hyperfoil.tools.horreum.api; import io.hyperfoil.tools.horreum.JsonAdapter; +import io.hyperfoil.tools.horreum.entity.alerting.Change; +import io.hyperfoil.tools.horreum.entity.alerting.Variable; import io.hyperfoil.tools.horreum.entity.json.Hook; import io.hyperfoil.tools.horreum.entity.json.Run; import io.hyperfoil.tools.horreum.entity.json.Test; @@ -18,6 +20,7 @@ import org.jboss.logging.Logger; import javax.annotation.PostConstruct; +import javax.annotation.security.PermitAll; import javax.annotation.security.RolesAllowed; import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; @@ -141,6 +144,14 @@ public void newRun(Run run) { tellHooks(Run.EVENT_NEW, testId, run); } + @Transactional + @ConsumeEvent(value = Change.EVENT_NEW, blocking = true) + public void newChange(Change.Event changeEvent) { + Integer runId = changeEvent.change.runId; + Run run = Run.find("id", runId).firstResult(); + tellHooks(Change.EVENT_NEW, run.testid, changeEvent.change); + } + @RolesAllowed(Roles.ADMIN) @POST @Transactional @@ -216,4 +227,16 @@ public List list(@QueryParam("limit") Integer limit, } } + @RolesAllowed(Roles.ADMIN) + @GET + @Path("test/{id}") + public List variables(@PathParam("id") Integer testId) { + try (@SuppressWarnings("unused") CloseMe closeMe = sqlService.withRoles(em, identity)) { + if (testId != null) { + return Hook.list("target", testId); + } else { + return Variable.listAll(); + } + } + } } diff --git a/src/main/java/io/hyperfoil/tools/horreum/api/NotificationService.java b/src/main/java/io/hyperfoil/tools/horreum/api/NotificationService.java index d182df2fb..047c92248 100644 --- a/src/main/java/io/hyperfoil/tools/horreum/api/NotificationService.java +++ b/src/main/java/io/hyperfoil/tools/horreum/api/NotificationService.java @@ -103,15 +103,18 @@ public void onNewChange(Change.Event event) { log.infof("Received new change in test %d (%s), run %d, variable %d (%s)", variable.testId, testName, event.change.runId, variable.id, variable.name); StringBuilder sb = new StringBuilder(); - Json tagsObject = Json.fromString(String.valueOf(em.createNativeQuery("SELECT tags::::text FROM run_tags WHERE runid = ?") - .setParameter(1, event.change.runId) - .getSingleResult())); - tagsObject.forEach((key, value) -> { - if (sb.length() != 0) { - sb.append(';'); - } - sb.append(key).append(':').append(value); - }); + List tagsList = em.createNativeQuery("SELECT tags::::text FROM run_tags WHERE runid = ?") + .setParameter(1, event.change.runId) + .getResultList(); + if( tagsList.size() > 0) { + Json tagsObject = Json.fromString(String.valueOf(tagsList.stream().findFirst().get())); + tagsObject.forEach((key, value) -> { + if (sb.length() != 0) { + sb.append(';'); + } + sb.append(key).append(':').append(value); + }); + } String tags = sb.toString(); @SuppressWarnings("unchecked") diff --git a/src/main/java/io/hyperfoil/tools/horreum/api/TestService.java b/src/main/java/io/hyperfoil/tools/horreum/api/TestService.java index 175dc717c..c16736ed6 100644 --- a/src/main/java/io/hyperfoil/tools/horreum/api/TestService.java +++ b/src/main/java/io/hyperfoil/tools/horreum/api/TestService.java @@ -2,10 +2,7 @@ import io.agroal.api.AgroalDataSource; import io.hyperfoil.tools.horreum.entity.converter.JsonResultTransformer; -import io.hyperfoil.tools.horreum.entity.json.Access; -import io.hyperfoil.tools.horreum.entity.json.Test; -import io.hyperfoil.tools.horreum.entity.json.View; -import io.hyperfoil.tools.horreum.entity.json.ViewComponent; +import io.hyperfoil.tools.horreum.entity.json.*; import io.quarkus.panache.common.Page; import io.quarkus.panache.common.Sort; import io.quarkus.security.identity.SecurityIdentity; @@ -113,7 +110,7 @@ public Response add(Test test){ } Response addAuthenticated(Test test) { - Test existing = Test.find("name", test.name).firstResult(); + Test existing = Test.find("id", test.id).firstResult(); if (test.id != null && test.id <= 0) { test.id = null; } @@ -260,4 +257,33 @@ public Response updateView(@PathParam("testId") Integer testId, View view) { } return Response.noContent().build(); } + + @RolesAllowed("tester") + @POST + @Path("{testId}/hook") + public Response updateHook(@PathParam("testId") Integer testId, Hook hook) { + if (testId == null || testId <= 0) { + return Response.status(Response.Status.BAD_REQUEST).entity("Missing test id").build(); + } + try (@SuppressWarnings("unused") CloseMe closeMe = sqlService.withRoles(em, identity)) { + Test test = Test.findById(testId); + if (test == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + hook.target = testId; + + if (hook.id == null) { + em.persist(hook); + } else { + if (!hook.active) { + Hook toDelete = em.find(Hook.class, hook.id); + em.remove(toDelete); + } else { + em.merge(hook); + } + } + test.persist(); + } + return Response.noContent().build(); + } } diff --git a/src/main/java/io/hyperfoil/tools/horreum/entity/json/Hook.java b/src/main/java/io/hyperfoil/tools/horreum/entity/json/Hook.java index 2e745573e..faf64666a 100644 --- a/src/main/java/io/hyperfoil/tools/horreum/entity/json/Hook.java +++ b/src/main/java/io/hyperfoil/tools/horreum/entity/json/Hook.java @@ -3,14 +3,8 @@ import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import io.quarkus.runtime.annotations.RegisterForReflection; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.SequenceGenerator; -import javax.persistence.Table; -import javax.persistence.UniqueConstraint; +import javax.json.bind.annotation.JsonbTransient; +import javax.persistence.*; import javax.validation.constraints.NotNull; @Entity @@ -48,4 +42,5 @@ public class Hook extends PanacheEntityBase { @NotNull public boolean active; + } diff --git a/src/main/java/io/hyperfoil/tools/horreum/entity/json/ProtectedBaseEntity.java b/src/main/java/io/hyperfoil/tools/horreum/entity/json/ProtectedBaseEntity.java new file mode 100644 index 000000000..e063e5b0d --- /dev/null +++ b/src/main/java/io/hyperfoil/tools/horreum/entity/json/ProtectedBaseEntity.java @@ -0,0 +1,26 @@ +package io.hyperfoil.tools.horreum.entity.json; + +import io.hyperfoil.tools.horreum.entity.converter.AccessSerializer; +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import io.quarkus.runtime.annotations.RegisterForReflection; + +import javax.json.bind.annotation.JsonbTypeDeserializer; +import javax.json.bind.annotation.JsonbTypeSerializer; +import javax.persistence.MappedSuperclass; +import javax.validation.constraints.NotNull; + +@RegisterForReflection +@MappedSuperclass +public abstract class ProtectedBaseEntity extends PanacheEntityBase { + + @NotNull + public String owner; + + public String token; + + @NotNull + @JsonbTypeSerializer(AccessSerializer.class) + @JsonbTypeDeserializer(AccessSerializer.class) + public Access access = Access.PUBLIC; + +} diff --git a/src/main/java/io/hyperfoil/tools/horreum/entity/json/Run.java b/src/main/java/io/hyperfoil/tools/horreum/entity/json/Run.java index 1160f3181..e69673c02 100644 --- a/src/main/java/io/hyperfoil/tools/horreum/entity/json/Run.java +++ b/src/main/java/io/hyperfoil/tools/horreum/entity/json/Run.java @@ -1,21 +1,24 @@ package io.hyperfoil.tools.horreum.entity.json; -import io.hyperfoil.tools.horreum.entity.converter.AccessSerializer; import io.hyperfoil.tools.horreum.entity.converter.InstantSerializer; import io.hyperfoil.tools.yaup.json.Json; -import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import io.quarkus.runtime.annotations.RegisterForReflection; import org.hibernate.annotations.Type; import javax.json.bind.annotation.JsonbTypeDeserializer; import javax.json.bind.annotation.JsonbTypeSerializer; -import javax.persistence.*; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.SequenceGenerator; import javax.validation.constraints.NotNull; import java.time.Instant; @Entity(name = "run") @RegisterForReflection -public class Run extends PanacheEntityBase { +public class Run extends ProtectedBaseEntity { public static final String EVENT_NEW = "run/new"; public static final String EVENT_TRASHED = "run/trashed"; @@ -49,16 +52,6 @@ public class Run extends PanacheEntityBase { @Type(type = "io.hyperfoil.tools.horreum.entity.converter.JsonUserType") public Json data; - @NotNull - public String owner; - - public String token; - - @NotNull - @JsonbTypeSerializer(AccessSerializer.class) - @JsonbTypeDeserializer(AccessSerializer.class) - public Access access = Access.PUBLIC; - @NotNull @Column(columnDefinition = "boolean default false") public boolean trashed; diff --git a/src/main/java/io/hyperfoil/tools/horreum/entity/json/Schema.java b/src/main/java/io/hyperfoil/tools/horreum/entity/json/Schema.java index 50a59fd1a..b2b2ac8ad 100644 --- a/src/main/java/io/hyperfoil/tools/horreum/entity/json/Schema.java +++ b/src/main/java/io/hyperfoil/tools/horreum/entity/json/Schema.java @@ -1,13 +1,9 @@ package io.hyperfoil.tools.horreum.entity.json; -import io.hyperfoil.tools.horreum.entity.converter.AccessSerializer; import io.hyperfoil.tools.yaup.json.Json; -import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import io.quarkus.runtime.annotations.RegisterForReflection; import org.hibernate.annotations.Type; -import javax.json.bind.annotation.JsonbTypeDeserializer; -import javax.json.bind.annotation.JsonbTypeSerializer; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; @@ -24,7 +20,7 @@ name = "schema", uniqueConstraints = @UniqueConstraint(columnNames = {"owner", "uri"}) ) -public class Schema extends PanacheEntityBase { +public class Schema extends ProtectedBaseEntity { @Id @SequenceGenerator( @@ -66,13 +62,4 @@ public class Schema extends PanacheEntityBase { */ public String descriptionPath; - @NotNull - public String owner; - - public String token; - - @NotNull - @JsonbTypeSerializer(AccessSerializer.class) - @JsonbTypeDeserializer(AccessSerializer.class) - public Access access; } diff --git a/src/main/java/io/hyperfoil/tools/horreum/entity/json/Test.java b/src/main/java/io/hyperfoil/tools/horreum/entity/json/Test.java index 48df09a41..3aa5e3cf0 100644 --- a/src/main/java/io/hyperfoil/tools/horreum/entity/json/Test.java +++ b/src/main/java/io/hyperfoil/tools/horreum/entity/json/Test.java @@ -1,17 +1,20 @@ package io.hyperfoil.tools.horreum.entity.json; -import io.hyperfoil.tools.horreum.entity.converter.AccessSerializer; -import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import io.quarkus.runtime.annotations.RegisterForReflection; -import javax.json.bind.annotation.JsonbTypeDeserializer; -import javax.json.bind.annotation.JsonbTypeSerializer; -import javax.persistence.*; +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.OneToOne; +import javax.persistence.SequenceGenerator; import javax.validation.constraints.NotNull; @Entity(name="test") @RegisterForReflection -public class Test extends PanacheEntityBase { +public class Test extends ProtectedBaseEntity { public static final String EVENT_NEW = "test/new"; @Id @@ -36,16 +39,6 @@ public class Test extends PanacheEntityBase { @OneToOne(cascade = { CascadeType.REMOVE, CascadeType.MERGE }) public View defaultView; - @NotNull - public String owner; - - public String token; - - @NotNull - @JsonbTypeSerializer(AccessSerializer.class) - @JsonbTypeDeserializer(AccessSerializer.class) - public Access access = Access.PUBLIC; - public String compareUrl; public void ensureLinked() { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6f76cd45f..b9ccf19bf 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -34,7 +34,7 @@ quarkus.hibernate-orm.dialect=io.hyperfoil.tools.horreum.entity.converter.JsonPo #quarkus.hibernate-orm.database.generation=drop-and-create #quarkus.hibernate-orm.database.generation=update # import.sql is executed only in 'create' or 'drop-and-create' modes. -%insecure.quarkus.hibernate-orm.database.generation=drop-and-create +#%insecure.quarkus.hibernate-orm.database.generation=drop-and-create # By default (in production) the database is created using structure.sql - the default application user # does not have privileges to drop or alter the tables. quarkus.hibernate-orm.database.generation=validate @@ -74,4 +74,19 @@ quarkus.container-image.tag=latest quarkus.jib.base-jvm-image=quay.io/hyperfoil/horreum-base:latest quarkus.jib.jvm-entrypoint=/deployments/horreum.sh -quarkus.live-reload.password=secret \ No newline at end of file +quarkus.live-reload.password=secret + + +# openAPI definitions +mp.openapi.extensions.smallrye.info.title=Horreum API +%dev.mp.openapi.extensions.smallrye.info.title=Horreum API (development) +%test.mp.openapi.extensions.smallrye.info.title=Horreum API (test) +mp.openapi.extensions.smallrye.info.version=0.1-SNAPSHOT +mp.openapi.extensions.smallrye.info.description=Horreum data repository API +mp.openapi.extensions.smallrye.info.termsOfService= +mp.openapi.extensions.smallrye.info.contact.email= +mp.openapi.extensions.smallrye.info.contact.name= +mp.openapi.extensions.smallrye.info.contact.url= +mp.openapi.extensions.smallrye.info.license.name=Apache 2.0 +mp.openapi.extensions.smallrye.info.license.url=http://www.apache.org/licenses/LICENSE-2.0.html + diff --git a/src/main/resources/db/changeLog.xml b/src/main/resources/db/changeLog.xml index 65fc55819..8abea1999 100644 --- a/src/main/resources/db/changeLog.xml +++ b/src/main/resources/db/changeLog.xml @@ -1092,5 +1092,6 @@ + diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 652b60150..21ee00fe9 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -79,7 +79,7 @@ function Main() { { isAdmin && - WebHooks + Global WebHooks } diff --git a/webapp/src/domain/hooks/AddHookModal.tsx b/webapp/src/domain/hooks/AddHookModal.tsx index e8441fe7c..897ee0580 100644 --- a/webapp/src/domain/hooks/AddHookModal.tsx +++ b/webapp/src/domain/hooks/AddHookModal.tsx @@ -12,7 +12,7 @@ import { import { Hook } from './reducers'; import TestSelect, { SelectedTest } from '../../components/TestSelect' -const eventTypes = ["test/new","run/new"] +export const eventTypes = ["test/new","run/new","change/new"] const isValidUrl = (string: string) => { try { @@ -97,7 +97,7 @@ export default ({isOpen=false,onCancel=()=>{}, onSubmit=(validation: Hook)=>{}}) { - eventType === "run/new" && + ( eventType === "run/new" || eventType === "change/new" ) && { },[dispatch, isAdmin]) return ( + setOpen(false)} onSubmit={(v)=>{setOpen(false); dispatch(add(v)); }} /> diff --git a/webapp/src/domain/hooks/api.ts b/webapp/src/domain/hooks/api.ts index 8578607f9..80765d4b0 100644 --- a/webapp/src/domain/hooks/api.ts +++ b/webapp/src/domain/hooks/api.ts @@ -6,6 +6,7 @@ const endPoints = { base: ()=>`${base}`, crud: (id: number)=> `${base}/${id}/`, list: ()=> `${base}/list/`, + testHooks: (id: number)=> `${base}/test/${id}`, } export const all = () => { @@ -20,4 +21,8 @@ export const get = (id: number) => { } export const remove = (id: number) => { return fetchApi(endPoints.crud(id),null,'delete'); -} \ No newline at end of file +} + +export const fetchHooks = (testId: number) => { + return fetchApi(endPoints.testHooks(testId), null, 'get') +} diff --git a/webapp/src/domain/tests/General.tsx b/webapp/src/domain/tests/General.tsx index 0d7cc7007..06647a3fa 100644 --- a/webapp/src/domain/tests/General.tsx +++ b/webapp/src/domain/tests/General.tsx @@ -1,24 +1,24 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { useSelector, useDispatch } from 'react-redux' +import React, {useState, useRef, useEffect} from 'react'; +import {useSelector, useDispatch} from 'react-redux' import { Button, Form, - FormGroup, + FormGroup, Grid, GridItem, TextArea, TextInput, } from '@patternfly/react-core'; -import { sendTest } from './actions'; -import { alertAction, constraintValidationFormatter } from '../../alerts' +import {sendTest} from './actions'; +import {alertAction, constraintValidationFormatter} from '../../alerts' import AccessIcon from '../../components/AccessIcon' import AccessChoice from '../../components/AccessChoice' import Accessors from '../../components/Accessors' import OwnerSelect from '../../components/OwnerSelect' -import Editor, { ValueGetter } from '../../components/Editor/monaco/Editor' +import Editor, {ValueGetter} from '../../components/Editor/monaco/Editor' -import { Test, TestDispatch } from './reducers'; +import {Test, TestDispatch} from './reducers'; import { useTester, @@ -27,16 +27,16 @@ import { defaultRoleSelector } from '../../auth' -import { TabFunctionsRef } from './Test' +import {TabFunctionsRef} from './Test' type GeneralProps = { test?: Test, onTestIdChange(id: number): void, onModified(modified: boolean): void, funcsRef: TabFunctionsRef - } +} -export default ({ test, onTestIdChange, onModified, funcsRef }: GeneralProps) => { +export default ({test, onTestIdChange, onModified, funcsRef}: GeneralProps) => { const defaultRole = useSelector(defaultRoleSelector) const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -95,89 +95,111 @@ export default ({ test, onTestIdChange, onModified, funcsRef }: GeneralProps) => const isTester = useTester(owner) return (<> -
- - 0 ? "default" : "error"} - onChange={n => { - setName(n) - onModified(true) - }} - /> - - -