From 4822320961ca811bd7a2bcd89b1fc93bab51cd69 Mon Sep 17 00:00:00 2001 From: Stephan Richter Date: Tue, 31 Dec 2024 00:02:14 +0100 Subject: [PATCH] overhauled API: - wrote openapi schema - re-implemented api endpoint following openapi schema - intensified and improved working with Result objects Signed-off-by: Stephan Richter --- build.gradle.kts | 2 +- de.srsoftware.cal.api/build.gradle.kts | 2 +- .../de/srsoftware/cal/api/Appointment.java | 20 +- de.srsoftware.cal.app/build.gradle.kts | 2 +- .../de/srsoftware/cal/app/Application.java | 12 +- de.srsoftware.cal.base/build.gradle.kts | 2 +- .../de/srsoftware/cal/BaseAppointment.java | 9 +- .../java/de/srsoftware/cal/BaseImporter.java | 205 ++++++------ .../src/main/java/de/srsoftware/cal/Util.java | 11 +- de.srsoftware.cal.db/build.gradle.kts | 2 +- .../java/de/srsoftware/cal/db/Database.java | 14 +- .../java/de/srsoftware/cal/db/MariaDB.java | 83 +++-- .../java/de/srsoftware/cal/db/NotFound.java | 22 ++ .../java/de/srsoftware/cal/db/SqlError.java | 32 ++ de.srsoftware.cal.importer/build.gradle.kts | 2 +- .../cal/importer/jena/Kassablanca.java | 14 +- .../cal/importer/jena/Rosenkeller.java | 16 +- de.srsoftware.cal.web/build.gradle.kts | 4 +- .../java/de/srsoftware/cal/ApiEndpoint.java | 314 ++++++++++++++++++ .../java/de/srsoftware/cal/ApiHandler.java | 221 ------------ .../src/main/resources/index.html | 1 + .../src/main/resources/script/common.js | 4 + .../src/main/resources/script/edit.js | 10 +- .../src/main/resources/script/event.js | 6 +- .../src/main/resources/script/index.js | 49 ++- .../src/test/java/ParseDateTest.java | 40 +++ .../src/test/java/ParsePastTest.java | 37 +++ doc/openapi3_0.yaml | 248 ++++++++------ 28 files changed, 858 insertions(+), 526 deletions(-) create mode 100644 de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/NotFound.java create mode 100644 de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/SqlError.java create mode 100644 de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiEndpoint.java delete mode 100644 de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiHandler.java create mode 100644 de.srsoftware.cal.web/src/test/java/ParseDateTest.java create mode 100644 de.srsoftware.cal.web/src/test/java/ParsePastTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 3dfb0e4..7738f92 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,4 +40,4 @@ subprojects { tasks.withType() { useJUnitPlatform() } -} \ No newline at end of file +} diff --git a/de.srsoftware.cal.api/build.gradle.kts b/de.srsoftware.cal.api/build.gradle.kts index d704516..e8ea51a 100644 --- a/de.srsoftware.cal.api/build.gradle.kts +++ b/de.srsoftware.cal.api/build.gradle.kts @@ -1,6 +1,6 @@ description = "OpenCloudCal : API" dependencies { - implementation("de.srsoftware:tools.util:1.2.3") + implementation("de.srsoftware:tools.util:1.3.0") implementation("org.json:json:20240303") } diff --git a/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Appointment.java b/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Appointment.java index 07e91a2..d31dd1f 100644 --- a/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Appointment.java +++ b/de.srsoftware.cal.api/src/main/java/de/srsoftware/cal/api/Appointment.java @@ -37,6 +37,14 @@ public interface Appointment { */ Optional end(); + + /** + * represent this event as ical entry + * @return the ical string + */ + String ical(); + + /** * ID of the appointment – unique within this system * @return the appointment`s id @@ -49,6 +57,12 @@ public interface Appointment { */ JSONObject json(); + /** + * set of Links that point to related information + * @return set of links + */ + Set links(); + /** * descriptive text of the location, e.g. address * @return location text @@ -74,10 +88,4 @@ public interface Appointment { * @return the title */ String title(); - - /** - * set of Links that point to related information - * @return set of links - */ - Set urls(); } diff --git a/de.srsoftware.cal.app/build.gradle.kts b/de.srsoftware.cal.app/build.gradle.kts index e56c4ea..ff8e827 100644 --- a/de.srsoftware.cal.app/build.gradle.kts +++ b/de.srsoftware.cal.app/build.gradle.kts @@ -7,7 +7,7 @@ dependencies { implementation("de.srsoftware:configuration.api:1.0.0") implementation("de.srsoftware:configuration.json:1.0.0") - implementation("de.srsoftware:tools.http:1.0.4") + implementation("de.srsoftware:tools.http:1.2.1") implementation("de.srsoftware:tools.logging:1.0.2") implementation("de.srsoftware:tools.web:1.3.8") implementation("com.mysql:mysql-connector-j:9.1.0") diff --git a/de.srsoftware.cal.app/src/main/java/de/srsoftware/cal/app/Application.java b/de.srsoftware.cal.app/src/main/java/de/srsoftware/cal/app/Application.java index b067c84..92abf54 100644 --- a/de.srsoftware.cal.app/src/main/java/de/srsoftware/cal/app/Application.java +++ b/de.srsoftware.cal.app/src/main/java/de/srsoftware/cal/app/Application.java @@ -4,7 +4,7 @@ package de.srsoftware.cal.app; import static java.lang.System.Logger.Level.*; import com.sun.net.httpserver.HttpServer; -import de.srsoftware.cal.ApiHandler; +import de.srsoftware.cal.ApiEndpoint; import de.srsoftware.cal.IndexHandler; import de.srsoftware.cal.StaticHandler; import de.srsoftware.cal.db.Database; @@ -56,7 +56,15 @@ public class Application { HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); var staticPages = new StaticHandler(staticPath).bindPath("/static").on(server); new IndexHandler(staticPages).bindPath("/").on(server); - new ApiHandler(db).bindPath(("/api")).on(server); + new ApiEndpoint(db).bindPath(("/api")).on(server); + server.start(); + // TODO: schedule imports + // TODO: update URL, when tags are selected + // TODO: allow list start time as URL param + // TODO: provide ICAL and WEBDAV links + } + + private static void scheduleImports() { } } diff --git a/de.srsoftware.cal.base/build.gradle.kts b/de.srsoftware.cal.base/build.gradle.kts index 652d279..e1964ea 100644 --- a/de.srsoftware.cal.base/build.gradle.kts +++ b/de.srsoftware.cal.base/build.gradle.kts @@ -4,7 +4,7 @@ dependencies { implementation(project(":de.srsoftware.cal.api")) implementation("de.srsoftware:tools.optionals:1.0.0") - implementation("de.srsoftware:tools.util:1.2.3") + implementation("de.srsoftware:tools.util:1.3.0") implementation("de.srsoftware:tools.web:1.3.8") implementation("org.json:json:20240303") } diff --git a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseAppointment.java b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseAppointment.java index 8805de2..d3cfa0d 100644 --- a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseAppointment.java +++ b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseAppointment.java @@ -123,6 +123,11 @@ public class BaseAppointment implements Appointment { return nullable(end); } + @Override + public String ical() { + return "converting event (%s) to ical not implemented".formatted(title); + } + @Override public long id() { return id; @@ -140,7 +145,7 @@ public class BaseAppointment implements Appointment { json.put("start", start().format(DATE_TIME)); json.put("tags", tags()); json.put("title", title()); - json.put("links", urls().stream().map(Link::json).toList()); + json.put("links", links().stream().map(Link::json).toList()); return json; } @@ -191,7 +196,7 @@ public class BaseAppointment implements Appointment { } @Override - public Set urls() { + public Set links() { return links; } } \ No newline at end of file diff --git a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseImporter.java b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseImporter.java index 1d372b4..89c8183 100644 --- a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseImporter.java +++ b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/BaseImporter.java @@ -1,7 +1,9 @@ /* © SRSoftware 2024 */ package de.srsoftware.cal; +import static de.srsoftware.tools.Error.error; import static de.srsoftware.tools.Result.transform; +import static java.lang.System.Logger.Level.WARNING; import de.srsoftware.cal.api.*; import de.srsoftware.tools.*; @@ -22,8 +24,9 @@ import java.util.Optional; import java.util.stream.Stream; public abstract class BaseImporter implements Importer { - private static final String SHA256 = "SHA-256"; - private final MessageDigest digest; + private static final System.Logger LOG = System.getLogger(BaseImporter.class.getSimpleName()); + private static final String SHA256 = "SHA-256"; + private final MessageDigest digest; protected BaseImporter() throws NoSuchAlgorithmException { digest = MessageDigest.getInstance(SHA256); @@ -61,13 +64,13 @@ public abstract class BaseImporter implements Importer { if (titleTag.optional().isEmpty()) return transform(titleTag); var inner = titleTag.optional().flatMap(tag -> tag.inner(2)); if (inner.isPresent()) return Payload.of(inner.get()); - return Error.of("No description found"); + return error("No description found"); } protected abstract Result extractDescriptionTag(Tag eventTag); protected Result extractCoords(Tag eventTag) { - return Error.of("not implemented"); + return error("not implemented"); } @@ -177,73 +180,75 @@ public abstract class BaseImporter implements Importer { Result titleTag = extractTitleTag(eventTag); if (titleTag.optional().isEmpty()) return transform(titleTag); var inner = titleTag.optional().flatMap(tag -> tag.inner(2)); - if (inner.isPresent()) return Payload.of(inner.get()); - return Error.of("No title found"); - } - - protected abstract Result extractTitleTag(Tag eventTag); - + return inner.isPresent() ? Payload.of(inner.get()) : + error("No title found"); + } - @Override - public Stream fetch() { - var url = Payload.of(programURL()); - Stream> stream = url(url) - .map(this::open) // - .map(this::preload) - .map(this::parseXML) - .map(this::extractEventUrls) - .stream(); - return stream // - .map(BaseImporter::url) - .map(this::loadEvent) - .peek(e -> { - if (e instanceof Error err) System.err.println(err); - }) - .flatMap(result -> result.optional().stream()); - } + protected abstract Result extractTitleTag(Tag eventTag); + + + @Override + public Stream fetch() { + var url = Payload.of(programURL()); + Stream> stream = url(url) + .map(this::open) // + .map(this::preload) + .map(this::parseXML) + .map(this::extractEventUrls) + .stream(); + return stream // + .map(BaseImporter::url) + .map(this::loadEvent) + .peek(e -> { + if (e instanceof Error err) System.err.println(err); + }) + .flatMap(result -> result.optional().stream()); + } - protected static Result invalidParameter(Result result) { - return Error.format("Invalid parameter: %s", result.getClass().getSimpleName()); - } + protected static Result invalidParameter(Result result) { + return error("Invalid parameter: %s", result.getClass().getSimpleName()); + } - protected static Result link(Result url, String text) { - var opt = url.optional(); - if (opt.isEmpty()) return transform(url); - return Payload.of(new Link(opt.get(),text)); - } + protected static Result link(Result url, String text) { + var opt = url.optional(); + if (opt.isEmpty()) return transform(url); + return Payload.of(new Link(opt.get(), text)); + } - protected Result loadEvent(Result urlResult) { - var link = urlResult // - .optional().map(url -> new Link(url, "Event-Seite")).orElse(null); - return urlResult // - .map(this::open) - .map(this::preload) - .map(this::parseXML) - .map(this::extractEventTag) - .map(tagResult -> extractEvent(tagResult, link)); - } + protected Result loadEvent(Result urlResult) { + var link = urlResult // + .optional() + .map(url -> new Link(url, "Event-Seite")) + .orElse(null); + return urlResult // + .map(this::open) + .map(this::preload) + .map(this::parseXML) + .map(this::extractEventTag) + .map(tagResult -> extractEvent(tagResult, link)); + } - protected Result open(Result url) { - switch (url) { - case Payload payload: - try { - return Payload.of(payload.get().openConnection().getInputStream()); - } catch (IOException e) { - return Error.of("Failed to open %s".formatted(payload), e); - } - case Error error: - return error.transform(); - default: - return invalidParameter(url); + protected Result open(Result url) { + switch (url) { + case Payload payload: + try { + return Payload.of(payload.get().openConnection().getInputStream()); + } catch (IOException e) { + return error(e, "Failed to open %s", payload, e); + } + case Error error: + return error.transform(); + default: + return invalidParameter(url); + } } - } - protected abstract Result parseEndDate(String string); + protected abstract Result parseEndDate(String string); - protected abstract Result parseStartDate(String string); + protected abstract Result parseStartDate(String string); - protected Result parseXML(Result inputStream) { - return switch (inputStream) { + protected Result parseXML(Result inputStream) { + return switch (inputStream) { case Payload payload -> XMLParser.parse(payload.get()); case Error error -> error.transform(); default -> invalidParameter(inputStream); @@ -256,55 +261,55 @@ public abstract class BaseImporter implements Importer { try { return Payload.of(XMLParser.preload(payload.get())); } catch (IOException e) { - return Error.of("Failed to buffer data from %s".formatted(payload), e); + return error(e, "Failed to buffer data from %s", payload); } case Error error: return error.transform(); default: return invalidParameter(inputStream); + } } - } - protected abstract String programURL(); - - protected static Result toAttachment(Result urlResult) { - var opt = urlResult.optional(); - if (opt.isEmpty()) return transform(urlResult); - try { - var mime = opt.get().openConnection().getContentType(); - return Payload.of(new Attachment(opt.get(), mime)); - } catch (Exception e) { - e.printStackTrace(); - return Error.format("Failed to read mime type of %s", opt.get()); + protected abstract String programURL(); + + protected static Result toAttachment(Result urlResult) { + var opt = urlResult.optional(); + if (opt.isEmpty()) return transform(urlResult); + try { + var mime = opt.get().openConnection().getContentType(); + return Payload.of(new Attachment(opt.get(), mime)); + } catch (Exception e) { + LOG.log(WARNING, "Failed to read mime type of {0}", opt.get()); + return error("Failed to read mime type of %s", opt.get()); + } } - } - protected static Result toNumericMonth(String month) { - month = month.toLowerCase(); - if (month.startsWith("ja")) return Payload.of(1); - if (month.startsWith("f")) return Payload.of(2); - if ("may".equals(month) || "mai".equals(month)) return Payload.of(5); - if (month.startsWith("m")) return Payload.of(3); - if (month.startsWith("ap")) return Payload.of(4); - if (month.startsWith("jun")) return Payload.of(6); - if (month.startsWith("jul")) return Payload.of(7); - if (month.startsWith("au")) return Payload.of(8); - if (month.startsWith("s")) return Payload.of(9); - if (month.startsWith("o")) return Payload.of(10); - if (month.startsWith("n")) return Payload.of(11); - if (month.startsWith("d")) return Payload.of(12); - return Error.format("Failed to recognize \"%s\" as a month!", month); - } + protected static Result toNumericMonth(String month) { + month = month.toLowerCase(); + if (month.startsWith("ja")) return Payload.of(1); + if (month.startsWith("f")) return Payload.of(2); + if ("may".equals(month) || "mai".equals(month)) return Payload.of(5); + if (month.startsWith("m")) return Payload.of(3); + if (month.startsWith("ap")) return Payload.of(4); + if (month.startsWith("jun")) return Payload.of(6); + if (month.startsWith("jul")) return Payload.of(7); + if (month.startsWith("au")) return Payload.of(8); + if (month.startsWith("s")) return Payload.of(9); + if (month.startsWith("o")) return Payload.of(10); + if (month.startsWith("n")) return Payload.of(11); + if (month.startsWith("d")) return Payload.of(12); + return error("Failed to recognize \"%s\" as a month!", month); + } - protected static Result url(Result urlResult) { - if (urlResult.optional().isEmpty()) return transform(urlResult); - var url = urlResult.optional().get(); - try { - return Payload.of(new URI(url).toURL()); - } catch (MalformedURLException | URISyntaxException e) { - return Error.of("Failed to create URL of %s".formatted(url), e); + protected static Result url(Result urlResult) { + if (urlResult.optional().isEmpty()) return transform(urlResult); + var url = urlResult.optional().get(); + try { + return Payload.of(new URI(url).toURL()); + } catch (MalformedURLException | URISyntaxException e) { + return error(e, "Failed to create URL of %s", url); + } } } -} diff --git a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/Util.java b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/Util.java index 8b2e0f3..abd7aea 100644 --- a/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/Util.java +++ b/de.srsoftware.cal.base/src/main/java/de/srsoftware/cal/Util.java @@ -1,23 +1,24 @@ /* © SRSoftware 2024 */ package de.srsoftware.cal; +import static de.srsoftware.tools.Error.error; + import de.srsoftware.cal.api.Coords; -import de.srsoftware.tools.Error; import de.srsoftware.tools.Payload; import de.srsoftware.tools.Result; public class Util { public static Result extractCoords(String coords) { - if (coords == null) return de.srsoftware.tools.Error.of("Argument is null"); - if (coords.isBlank()) return de.srsoftware.tools.Error.of("Argument is blank"); + if (coords == null) return error("Argument is null"); + if (coords.isBlank()) return error("Argument is blank"); var parts = coords.split(","); - if (parts.length != 2) return de.srsoftware.tools.Error.format("Argument has invalid format: %s", coords); + if (parts.length != 2) return error("Argument has invalid format: %s", coords); try { var lat = Double.parseDouble(parts[0].trim()); var lon = Double.parseDouble(parts[1].trim()); return Payload.of(new Coords(lon, lat)); } catch (NumberFormatException nfe) { - return Error.of("Failed to parse coords from %s".formatted(coords), nfe); + return error(nfe, "Failed to parse coords from %s", coords); } } } diff --git a/de.srsoftware.cal.db/build.gradle.kts b/de.srsoftware.cal.db/build.gradle.kts index 6b4e0ce..306eb8d 100644 --- a/de.srsoftware.cal.db/build.gradle.kts +++ b/de.srsoftware.cal.db/build.gradle.kts @@ -6,5 +6,5 @@ dependencies { implementation("de.srsoftware:tools.jdbc:1.1.3") implementation("de.srsoftware:tools.optionals:1.0.0") - implementation("de.srsoftware:tools.util:1.2.3") + implementation("de.srsoftware:tools.util:1.3.0") } diff --git a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/Database.java b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/Database.java index f2c779c..b15a462 100644 --- a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/Database.java +++ b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/Database.java @@ -2,13 +2,9 @@ package de.srsoftware.cal.db; import de.srsoftware.cal.api.Appointment; -import de.srsoftware.tools.Error; import de.srsoftware.tools.Result; -import java.sql.SQLException; import java.time.LocalDateTime; import java.util.List; -import java.util.Objects; -import java.util.Optional; import java.util.Set; /** @@ -28,19 +24,11 @@ public interface Database { * list appointments unfiltered * @param count the maximum number of appointments to return * @param offset the number of appointments to skip - * @return the list of appointments fetched from the db - * @throws SQLException if the appointments cannot be fetched from the DB - */ - public List list(Integer count, Integer offset) throws SQLException; - - /** - * list appointments unfiltered * @param from restrict appointments to times after this date time * @param till restrict appointments to times before this date time * @return list of appointments in this time span - * @throws SQLException if the appointments cannot be fetched from the DB */ - public List list(LocalDateTime from, LocalDateTime till) throws SQLException; + public Result> list(LocalDateTime from, LocalDateTime till, Integer count, Integer offset); /** * list appointments diff --git a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java index 7b5f407..74de756 100644 --- a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java +++ b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java @@ -3,6 +3,7 @@ package de.srsoftware.cal.db; import static de.srsoftware.cal.db.Fields.*; import static de.srsoftware.cal.db.Fields.ALL; +import static de.srsoftware.tools.Error.error; import static de.srsoftware.tools.Optionals.*; import static de.srsoftware.tools.Result.transform; import static de.srsoftware.tools.jdbc.Condition.*; @@ -14,7 +15,6 @@ import de.srsoftware.cal.Util; import de.srsoftware.cal.api.Appointment; import de.srsoftware.cal.api.Attachment; import de.srsoftware.cal.api.Link; -import de.srsoftware.tools.Error; import de.srsoftware.tools.Payload; import de.srsoftware.tools.Result; import de.srsoftware.tools.jdbc.Query; @@ -69,7 +69,7 @@ public class MariaDB implements Database { Appointment saved = null; if (keys.next()) saved = appointment.clone(keys.getLong(1)); keys.close(); - if (saved == null) return Error.of("Insert query did not return appointment id!"); + if (saved == null) return error("Insert query did not return appointment id!"); { // link to attachments var attachments = saved.attachments(); @@ -83,7 +83,7 @@ public class MariaDB implements Database { } { // link to links - var links = saved.urls(); + var links = saved.links(); InsertQuery assignQuery = null; for (var link : links) { var urlId = getOrCreateUrl(link.url()); @@ -106,7 +106,7 @@ public class MariaDB implements Database { return Payload.of(saved); } catch (SQLException e) { LOG.log(ERROR, "Failed to store appointment", e); - return Error.of("Failed to store appointment", e); + return error(e, "Failed to store appointment"); } } @@ -149,28 +149,43 @@ public class MariaDB implements Database { rs.close(); return Payload.of(results); } catch (SQLException e) { - return Error.format("failed to gather tags from DB.", e); + return error(e, "failed to gather tags from DB."); } } @Override - public List list(Integer count, Integer offset) throws SQLException { - var list = new ArrayList(); - var results = select(ALL).from(APPOINTMENTS).sort("start").exec(connection); - while (results.next()) createAppointmentOf(results).optional().ifPresent(list::add); - results.close(); - return list; + public Result> list(LocalDateTime from, LocalDateTime till, Integer count, Integer offset) { + var query = Query // + .select("appointments.*", "GROUP_CONCAT(keyword) AS tags") + .from(APPOINTMENTS) + .leftJoin(AID, "appointment_tags", AID) + .leftJoin("tid", "tags", "tid") + .groupBy(AID) + .sort("start DESC"); + if (from != null) query.where(START, moreThan(Timestamp.valueOf(from))); + if (till != null) query.where(END, lessThan(Timestamp.valueOf(till))); + if (count != null) query.limit(count); + if (offset != null) query.skip(offset); + try { + var results = query.exec(connection); + var list = new ArrayList(); + while (results.next()) createAppointmentOf(results).optional().ifPresent(list::add); + results.close(); + return Payload.of(list); + } catch (SQLException e) { + return SqlError.of(e, "Failed to fetch appointments from database!"); + } } @Override public Result loadEvent(long id) { try { var rs = select(ALL).from(APPOINTMENTS).where(AID, equal(id)).exec(connection); - Result result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : Error.format("Failed to find appointment with id %s", id); + Result result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : NotFound.of("Failed to find appointment with id %s", id); rs.close(); return result; } catch (SQLException e) { - return Error.of("Failed to load appointment with id = %s".formatted(id), e); + return SqlError.of(e, "Failed to load appointment with id = %s", id); } } @@ -178,11 +193,11 @@ public class MariaDB implements Database { public Result loadEvent(String location, LocalDateTime start) { try { var rs = select(ALL).from(APPOINTMENTS).where(LOCATION, equal(location)).where(START, equal(Timestamp.valueOf(start))).exec(connection); - Result result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : Error.format("Failed to find appointment starting %s @ %s".formatted(start, location)); + Result result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : error("Failed to find appointment starting %s @ %s", start, location); rs.close(); return result; } catch (SQLException e) { - return Error.of("Failed to load appointment starting %s @ %s".formatted(start, location), e); + return error(e, "Failed to load appointment starting %s @ %s", start, location); } } @@ -200,7 +215,7 @@ public class MariaDB implements Database { rs.close(); return Payload.of(event); } catch (SQLException e) { - return Error.of("Failed to load tags for appointment %s".formatted(id), e); + return error(e, "Failed to load tags for appointment %s", id); } } @@ -223,7 +238,7 @@ public class MariaDB implements Database { rs.close(); return Payload.of(event); } catch (SQLException e) { - return Error.of("Failed to load tags for appointment %s".formatted(id), e); + return error(e, "Failed to load tags for appointment %s", id); } } @@ -246,7 +261,7 @@ public class MariaDB implements Database { rs.close(); return Payload.of(event); } catch (SQLException e) { - return Error.of("Failed to load tags for appointment %s".formatted(id), e); + return error(e, "Failed to load tags for appointment %s".formatted(id)); } } @@ -254,7 +269,7 @@ public class MariaDB implements Database { var id = results.getInt(AID); var title = results.getString(TITLE); var description = results.getString(DESCRIPTION); - if (allEmpty(title, description)) return Error.format("Title and Description of appointment %s are empty", id); + if (allEmpty(title, description)) return error("Title and Description of appointment %s are empty", id); var start = results.getTimestamp(START).toLocalDateTime(); var end = nullable(results.getTimestamp(END)).map(Timestamp::toLocalDateTime).orElse(null); var location = results.getString(LOCATION); @@ -275,24 +290,6 @@ public class MariaDB implements Database { return Payload.of(appointment); } - @Override - public List list(LocalDateTime from, LocalDateTime till) throws SQLException { - var list = new ArrayList(); - var query = Query // - .select("appointments.*", "GROUP_CONCAT(keyword) AS tags") - .from(APPOINTMENTS) - .leftJoin(AID, "appointment_tags", AID) - .leftJoin("tid", "tags", "tid") - .groupBy(AID) - .sort("start DESC"); - nullable(from).ifPresent(start -> query.where("start", moreThan(start))); - nullable(till).ifPresent(end -> query.where("end", lessThan(end))); - - var results = query.exec(connection); - while (results.next()) createAppointmentOf(results).optional().ifPresent(list::add); - results.close(); - return list; - } @Override public List listByTags(Set tags, Integer count, Integer offset) { @@ -302,13 +299,13 @@ public class MariaDB implements Database { @Override public Result removeAppointment(long id) { try { - Query.delete().from(APPOINTMENTS).where(AID,equal(id)).execute(connection); - Query.delete().from(APPOINTMENT_TAGS).where(AID,equal(id)).execute(connection); - Query.delete().from(APPOINTMENT_ATTACHMENTS).where(AID,equal(id)).execute(connection); - Query.delete().from(APPOINTMENT_URLS).where(AID,equal(id)).execute(connection); + Query.delete().from(APPOINTMENTS).where(AID, equal(id)).execute(connection); + Query.delete().from(APPOINTMENT_TAGS).where(AID, equal(id)).execute(connection); + Query.delete().from(APPOINTMENT_ATTACHMENTS).where(AID, equal(id)).execute(connection); + Query.delete().from(APPOINTMENT_URLS).where(AID, equal(id)).execute(connection); return Payload.of(id); } catch (SQLException e) { - return Error.of("Failed to delete event %s".formatted(id),e); + return SqlError.of(e, "Failed to delete event %s", id); } } @@ -327,7 +324,7 @@ public class MariaDB implements Database { return Payload.of(event); } catch (SQLException sqle) { - return Error.of("Failed to update database entry", sqle); + return error(sqle, "Failed to update database entry"); } } } diff --git a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/NotFound.java b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/NotFound.java new file mode 100644 index 0000000..25646cf --- /dev/null +++ b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/NotFound.java @@ -0,0 +1,22 @@ +/* © SRSoftware 2024 */ +package de.srsoftware.cal.db; + +import de.srsoftware.tools.Error; +import java.util.Collection; +import java.util.Map; + +public class NotFound extends Error { + public NotFound(String message, Map data, Collection exceptions) { + super(message, data, exceptions); + } + + public static NotFound of(String message, Object... fills) { + return new NotFound<>(message.formatted(fills), null, null); + } + + + @Override + public NotFound transform() { + return new NotFound<>(message(), data(), exceptions()); + } +} diff --git a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/SqlError.java b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/SqlError.java new file mode 100644 index 0000000..5df05ce --- /dev/null +++ b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/SqlError.java @@ -0,0 +1,32 @@ +/* © SRSoftware 2024 */ +package de.srsoftware.cal.db; + +import de.srsoftware.tools.Error; +import java.sql.SQLException; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class SqlError extends Error { + public SqlError(String message, Map data, Collection exceptions) { + super(message, null, exceptions); + } + + public SQLException exception() { + return exceptions() // + .stream() + .filter(ex -> ex instanceof SQLException) + .map(SQLException.class ::cast) + .findAny() + .orElse(null); + } + + public static SqlError of(SQLException e, String message, Object... fills) { + return new SqlError<>(message.formatted(fills), null, List.of(e)); + } + + @Override + public SqlError transform() { + return new SqlError<>(message(), data(), exceptions()); + } +} diff --git a/de.srsoftware.cal.importer/build.gradle.kts b/de.srsoftware.cal.importer/build.gradle.kts index 53fda52..3590967 100644 --- a/de.srsoftware.cal.importer/build.gradle.kts +++ b/de.srsoftware.cal.importer/build.gradle.kts @@ -4,6 +4,6 @@ dependencies { implementation(project(":de.srsoftware.cal.api")) implementation(project(":de.srsoftware.cal.base")) implementation("de.srsoftware:tools.optionals:1.0.0") - implementation("de.srsoftware:tools.util:1.2.3") + implementation("de.srsoftware:tools.util:1.3.0") implementation("de.srsoftware:tools.web:1.3.8") } diff --git a/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Kassablanca.java b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Kassablanca.java index d7d209a..265e20b 100644 --- a/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Kassablanca.java +++ b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Kassablanca.java @@ -1,12 +1,12 @@ /* © SRSoftware 2024 */ package de.srsoftware.cal.importer.jena; +import static de.srsoftware.tools.Error.error; import static de.srsoftware.tools.Result.transform; import static de.srsoftware.tools.TagFilter.*; import de.srsoftware.cal.BaseImporter; import de.srsoftware.tools.*; -import de.srsoftware.tools.Error; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.List; @@ -31,12 +31,12 @@ public class Kassablanca extends BaseImporter { protected Result extractDescriptionTag(Tag eventTag) { var list = eventTag.find(attributeHas("class", "se-content")); if (list.size() == 1) return Payload.of(list.getFirst()); - return Error.of("Failed to find description tag"); + return error("Failed to find description tag"); } @Override protected Result extractEndTag(Tag eventTag) { - return Error.format("end date not supported"); + return error("end date not supported"); } @Override @@ -44,7 +44,7 @@ public class Kassablanca extends BaseImporter { if (pageResult.optional().isEmpty()) return transform(pageResult); var list = pageResult.optional().get().find(attributeEquals("class", APPOINTMENT_TAG_ID)); if (list.size() == 1) return Payload.of(list.getFirst()); - return Error.format("Could not find tag with id \"%s\"", APPOINTMENT_TAG_ID); + return error("Could not find tag with id \"%s\"", APPOINTMENT_TAG_ID); } @Override @@ -85,7 +85,7 @@ public class Kassablanca extends BaseImporter { protected Result extractStartTag(Tag eventTag) { List tags = eventTag.find(attributeEquals("class", "se-header")); if (tags.size() == 1) return Payload.of(tags.getFirst()); - return Error.of("Failed to find event time information"); + return error("Failed to find event time information"); } @Override @@ -97,7 +97,7 @@ public class Kassablanca extends BaseImporter { protected Result extractTitleTag(Tag eventTag) { var list = eventTag.find(ofType("h1")); if (list.size() == 1) return Payload.of(list.getFirst()); - return Error.of("Failed to find title tag"); + return error("Failed to find title tag"); } @Override @@ -117,7 +117,7 @@ public class Kassablanca extends BaseImporter { var date = LocalDateTime.of(year, month, day, hour, minute); return Payload.of(date); } - return Error.of("Could not recognize start date/time"); + return error("Could not recognize start date/time"); } @Override diff --git a/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Rosenkeller.java b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Rosenkeller.java index 758189d..f8927b7 100644 --- a/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Rosenkeller.java +++ b/de.srsoftware.cal.importer/src/main/java/de/srsoftware/cal/importer/jena/Rosenkeller.java @@ -1,12 +1,12 @@ /* © SRSoftware 2024 */ package de.srsoftware.cal.importer.jena; +import static de.srsoftware.tools.Error.error; import static de.srsoftware.tools.Optionals.nullable; import static de.srsoftware.tools.Result.transform; import static de.srsoftware.tools.TagFilter.*; import de.srsoftware.cal.BaseImporter; -import de.srsoftware.tools.Error; import de.srsoftware.tools.Payload; import de.srsoftware.tools.Result; import de.srsoftware.tools.Tag; @@ -42,12 +42,12 @@ public class Rosenkeller extends BaseImporter { .stream() .findAny(); if (opt.isPresent()) return Payload.of(opt.get()); - return Error.of("Failed to find description tag"); + return error("Failed to find description tag"); } @Override protected Result extractEndTag(Tag eventTag) { - return Error.of("extractEndTag(…) not supported"); + return error("extractEndTag(…) not supported"); } @Override @@ -55,7 +55,7 @@ public class Rosenkeller extends BaseImporter { if (pageResult.optional().isEmpty()) return transform(pageResult); var list = pageResult.optional().get().find(attributeEquals("id", APPOINTMENT_TAG_ID)); if (list.size() == 1) return Payload.of(list.getFirst()); - return Error.format("Could not find tag with id \"%s\"", APPOINTMENT_TAG_ID); + return error("Could not find tag with id \"%s\"", APPOINTMENT_TAG_ID); } @Override @@ -88,7 +88,7 @@ public class Rosenkeller extends BaseImporter { protected Result extractStartTag(Tag eventTag) { List list = eventTag.find(attributeEquals("class", "tribe-event-date-start")); if (list.size() == 1) return Payload.of(list.getFirst()); - return Error.of("Failed to locate start tag"); + return error("Failed to locate start tag"); } @Override @@ -100,12 +100,12 @@ public class Rosenkeller extends BaseImporter { protected Result extractTitleTag(Tag eventTag) { var list = eventTag.find(attributeEndsWith("class", "single-event-title")); if (list.size() == 1) return Payload.of(list.getFirst()); - return Error.of("Failed to find title tag"); + return error("Failed to find title tag"); } @Override protected Result parseEndDate(String text) { - return Error.of("parseEndDate(…) not supported"); + return error("parseEndDate(…) not supported"); } @Override @@ -123,7 +123,7 @@ public class Rosenkeller extends BaseImporter { if (date.isBefore(now)) date = date.plusYears(1); return Payload.of(date); } - return Error.format("Failed to recognize a date in \"%s\"", text); + return error("Failed to recognize a date in \"%s\"", text); } @Override diff --git a/de.srsoftware.cal.web/build.gradle.kts b/de.srsoftware.cal.web/build.gradle.kts index eb23205..87c7ed6 100644 --- a/de.srsoftware.cal.web/build.gradle.kts +++ b/de.srsoftware.cal.web/build.gradle.kts @@ -5,8 +5,8 @@ dependencies { implementation(project(":de.srsoftware.cal.base")) implementation(project(":de.srsoftware.cal.db")) - implementation("de.srsoftware:tools.http:1.0.4") + implementation("de.srsoftware:tools.http:1.2.1") implementation("de.srsoftware:tools.optionals:1.0.0") - implementation("de.srsoftware:tools.util:1.2.3") + implementation("de.srsoftware:tools.util:1.3.0") implementation("org.json:json:20240303") } diff --git a/de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiEndpoint.java b/de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiEndpoint.java new file mode 100644 index 0000000..c74fa2b --- /dev/null +++ b/de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiEndpoint.java @@ -0,0 +1,314 @@ +/* © SRSoftware 2024 */ +package de.srsoftware.cal; + +import static de.srsoftware.cal.db.Fields.*; +import static de.srsoftware.cal.db.Fields.AID; +import static de.srsoftware.tools.Error.error; +import static de.srsoftware.tools.Optionals.nullIfEmpty; +import static de.srsoftware.tools.Optionals.nullable; +import static de.srsoftware.tools.Result.transform; +import static java.time.format.DateTimeFormatter.ISO_DATE_TIME; + +import com.sun.net.httpserver.HttpExchange; +import de.srsoftware.cal.api.Appointment; +import de.srsoftware.cal.api.Link; +import de.srsoftware.cal.db.Database; +import de.srsoftware.cal.db.NotFound; +import de.srsoftware.tools.HttpError; +import de.srsoftware.tools.PathHandler; +import de.srsoftware.tools.Payload; +import de.srsoftware.tools.Result; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.json.JSONObject; + +public class ApiEndpoint extends PathHandler { + private static final String ATTACHMENTS = "attachments"; + private static final String LINKS = "links"; + private static final String ID = "id"; + private static final String TAGS = "tags"; + private static final String PAST = "past"; + private static final Pattern DATE_TIME = Pattern.compile("(\\d{4})-(\\d\\d?)(-(\\d\\d?)([ T](\\d\\d?):(\\d\\d?)(:(\\d\\d?))?)?)?"); + private static final Pattern PAST_PATTERN = Pattern.compile("(\\d+)([m|y])|(all)"); + private static final String OFFSET = "offset"; + private static final String COUNT = "count"; + + private final Database db; + + public ApiEndpoint(Database db) { + this.db = db; + } + + @Override + public boolean doDelete(String path, HttpExchange ex) throws IOException { + if ("/event".equals(path)) return sendContent(ex, delete(path, ex)); + return unknownPath(ex, path); + } + + @Override + public boolean doGet(String path, HttpExchange ex) throws IOException { + return switch (path) { + case "/event" -> sendContent(ex,getEvent(ex).map(ApiEndpoint::toJson).map(ApiEndpoint::httpError)); + case "/events/json" -> sendContent(ex,eventList(ex).map(ApiEndpoint::toJsonList).map(ApiEndpoint::httpError)); + case "/events/ical"-> sendContent(ex,eventList(ex).map(ApiEndpoint::toIcal).map(ApiEndpoint::httpError)); + case "/tags" -> listTags(ex); + default -> unknownPath(ex,path); + }; + } + + private boolean listTags(HttpExchange ex) throws IOException { + var params = queryParam(ex); + var infix = params.get("infix"); + if (infix == null) return sendContent(ex, error("No infix set in method call parameters")); + var res = db.findTags(infix).map(ApiEndpoint::sortTags); + return sendContent(ex, res); + } + + private static Result> sortTags(Result> listResult) { + if (listResult.optional().isEmpty()) return listResult; + List list = listResult.optional().get(); + while (list.size() > 15) { + int longest = list.stream().map(String::length).reduce(0, Integer::max); + var subset = list.stream().filter(s -> s.length() < longest).toList(); + if (subset.size() < 3) return Payload.of(list); + list = subset; + } + return Payload.of(list); + } + + @Override + public boolean doPatch(String path, HttpExchange ex) throws IOException { + if ("/event".equals(path)) return sendContent(ex, updateEvent(ex)); + return unknownPath(ex, path); + } + + @Override + public boolean doPost(String path, HttpExchange ex) throws IOException { + if ("/event".equals(path)) return sendContent(ex, newEvent(ex)); + return unknownPath(ex, path); + } + + private Result newEvent(HttpExchange ex) { + try { + var res = toEvent(json(ex)); + var opt = res.optional(); + if (opt.isEmpty()) return transform(res); + var event = opt.get(); + var title = getHeader(ex,TITLE); + if (title.isEmpty()) return error("Missing title header"); + if (!title.get().equals(event.title())) return error("Title mismatch!"); + return db.add(opt.get()).map(ApiEndpoint::toJson); + } catch (IOException e) { + return error(e,"Failed to read event data from request body"); + } + } + + private Result updateEvent(HttpExchange ex) { + try { + var res = toEvent(json(ex)); + var opt = res.optional(); + if (opt.isEmpty()) return transform(res); + var event = opt.get(); + var title = getHeader(ex,TITLE); + if (title.isEmpty()) return error("Missing title header"); + if (!title.get().equals(event.title())) return error("Title mismatch!"); + return db.update(opt.get()).map(ApiEndpoint::toJson); + } catch (IOException e) { + return error(e,"Failed to read event data from request body"); + } + } + + private Result getEvent(HttpExchange ex) { + var params = queryParam(ex); + var o = params.get(ID); + if (o == null) return error("id parameter missing!"); + try { + long id = Long.parseLong(o); + return db.loadEvent(id); + } catch (NumberFormatException e){ + return error(e,"Illegal format for id parameter (%s)",o); + } + } + + private Result toEvent(JSONObject json) { + var description = json.has(DESCRIPTION) ? nullIfEmpty(json.getString(DESCRIPTION)) : + null; + var title = json.has(TITLE) ? nullIfEmpty(json.getString(TITLE)) : null; + if (title == null) return error("title missing"); + var start = json.has(START) ? nullIfEmpty(json.getString(START)) : null; + if (start == null) return error("start missing"); + var startDate = nullable(start).map(dt -> LocalDateTime.parse(dt, ISO_DATE_TIME)).orElse(null); + var end = json.has(END) ? nullIfEmpty(json.getString(END)) : null; + var endDate = nullable(end).map(dt -> LocalDateTime.parse(dt, ISO_DATE_TIME)).orElse(null); + var location = json.has(LOCATION) ? json.getString(LOCATION) : null; + if (location == null) return error("location missing"); + var aid = json.has(AID) ? json.getLong(AID) : 0; + var event = new BaseAppointment(aid, title, description, startDate, endDate, location); + if (json.has(ATTACHMENTS)) { + json.getJSONArray(ATTACHMENTS).forEach(att -> { + Payload // + .of(att.toString()) + .map(BaseImporter::url) + .map(BaseImporter::toAttachment) + .optional() + .ifPresent(event::add); + }); + } + if (json.has(LINKS)) { + json.getJSONArray(LINKS).forEach(o -> { + if (o instanceof JSONObject j) toLink(j).optional().ifPresent(event::addLinks); + }); + } + if (json.has(TAGS)) json.getJSONArray(TAGS).forEach(o -> event.tags(o.toString())); + return Payload.of(event); + } + + protected static Result toLink(JSONObject json) { + try { + var description = json.getString(DESCRIPTION); + return Payload.of(json.getString(URL)).map(BaseImporter::url).map(url -> BaseImporter.link(url, description)); + + } catch (Exception e) { + return error(e, "Failed to create link from %s", json); + } + } + + private static Result toIcal(Result> res) { + var opt = res.optional(); + if (opt.isEmpty()) return transform(res); + var list = opt.get().stream().map(Appointment::ical).collect(Collectors.joining("\n")); + return Payload.of(list); + } + + private static Result> toJsonList(Result> res) { + var opt = res.optional(); + if (opt.isEmpty()) return transform(res); + var list = opt.get().stream().map(Appointment::json).toList(); + return Payload.of(list); + } + + private static Result toJson(Result res) { + var opt = res.optional(); + if (opt.isEmpty()) return transform(res); + return Payload.of(opt.get().json()); + } + + private Result> eventList(HttpExchange ex) { + var param = queryParam(ex); + var tags = nullable(param.get(TAGS)) // + .stream() + .flatMap(s -> Arrays.stream(s.split(","))) + .map(s -> s.trim().toLowerCase()) + .toList(); + + Result start = parseDate(param.get(START)); + if (start == null) start = parsePast(param.get(PAST)); + if (start instanceof de.srsoftware.tools.Error err) return err.transform(); + var startDate = start == null ? null : start.optional().orElse(null); + Integer offset = null; + var o = param.get(OFFSET); + if (o != null) try { + offset = Integer.parseInt(o); + } catch (NumberFormatException e) { + return error("Offset (offset=%s) is not a number!", o); + } + + Integer count = null; + o = param.get(COUNT); + if (o != null) try { + count = Integer.parseInt(o); + } catch (NumberFormatException e) { + return error("Count (count=%s) is not a number!", o); + } + + return db.list(startDate, null, count, offset).map(res -> filterByTags(res, tags)); + } + + private Result> filterByTags(Result> res, List tags) { + if (tags == null || tags.isEmpty()) return res; + var opt = res.optional(); + if (opt.isEmpty()) return transform(res); + var list = opt.get().stream().filter(event -> tagsMatch(event, tags)).toList(); + return Payload.of(list); + } + + private static boolean tagsMatch(Appointment event, List tags) { + return new HashSet<>(event.tags().stream().map(String::toLowerCase).toList()).containsAll(tags); + } + + public static Result parsePast(String s) { + var start = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + if (s == null) return Payload.of(start); + var matcher = PAST_PATTERN.matcher(s); + if (matcher.find()) { + for (int i = 0; i <= matcher.groupCount(); i++) System.out.printf("%s: %s\n", i, matcher.group(i)); + + int num = nullable(matcher.group(1)).map(Integer::parseInt).orElse(0); + switch (nullable(matcher.group(2)).orElse("")) { + case "m": + start = start.minusMonths(num); + break; + case "y": + start = start.minusYears(num); + break; + } + var all = matcher.group(3); + if ("all".equals(all)) return null; + return Payload.of(start); + } + return error("invalid past format: %s", s); + } + + public static Result parseDate(String s) { + if (s == null) return null; + var matcher = DATE_TIME.matcher(s); + if (matcher.find()) { + int year = Integer.parseInt(matcher.group(1)); + int month = Integer.parseInt(matcher.group(2)); + int day = nullable(matcher.group(4)).map(Integer::parseInt).orElse(1); + int hour = nullable(matcher.group(6)).map(Integer::parseInt).orElse(0); + int minute = nullable(matcher.group(7)).map(Integer::parseInt).orElse(0); + int second = nullable(matcher.group(9)).map(Integer::parseInt).orElse(0); + return Payload.of(LocalDateTime.of(year, month, day, hour, minute, second)); + } + return error("invalid date time format: %s", s); + } + + private Result delete(String path, HttpExchange ex) { + var params = queryParam(ex); + var id = params.get(ID); + if (id == null) return HttpError.of(400, "Id missing"); + long aid = 0; + try { + aid = Long.parseLong(id); + } catch (Exception e) { + return HttpError.of(400, "%s is not a valid appointment id!", id); + } + var opt = getHeader(ex, "title"); + if (opt.isEmpty()) return HttpError.of(412, "title missing"); + var title = opt.get(); + return db.loadEvent(aid).map(event -> deleteEvent(event, title)).map(ApiEndpoint::httpError); + } + + private Result deleteEvent(Result eventResult, String title) { + var opt = eventResult.optional(); + if (opt.isEmpty()) return transform(eventResult); + var event = opt.get(); + if (!title.equals(event.title())) return HttpError.of(412, "title mismatch"); + return db.removeAppointment(event.id()); + } + + private boolean unknownPath(HttpExchange ex, String path) throws IOException { + return sendContent(ex, HttpError.of(404, "%s is not known to this API", path)); + } + + private static Result httpError(Result res) { + if (res instanceof NotFound notFound) return HttpError.of(404, notFound.message()); + return res; + } + } diff --git a/de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiHandler.java b/de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiHandler.java deleted file mode 100644 index f809b94..0000000 --- a/de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiHandler.java +++ /dev/null @@ -1,221 +0,0 @@ -/* © SRSoftware 2024 */ -package de.srsoftware.cal; - -import static de.srsoftware.cal.db.Fields.*; -import static de.srsoftware.tools.Optionals.*; -import static java.lang.System.*; -import static java.lang.System.Logger.Level.WARNING; -import static java.time.format.DateTimeFormatter.ISO_DATE_TIME; - -import com.sun.net.httpserver.HttpExchange; -import de.srsoftware.cal.api.Appointment; -import de.srsoftware.cal.api.Link; -import de.srsoftware.cal.db.Database; -import de.srsoftware.tools.*; -import de.srsoftware.tools.Error; -import java.io.IOException; -import java.sql.SQLException; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.json.JSONObject; - -public class ApiHandler extends PathHandler { - private static final Logger LOG = getLogger(ApiHandler.class.getSimpleName()); - private static final String ATTACHMENTS = "attachments"; - private static final String LINKS = "links"; - private static final String TAGS = "tags"; - private final Database db; - - public ApiHandler(Database db) { - this.db = db; - } - - @Override - public boolean doGet(String path, HttpExchange ex) throws IOException { - var params = queryParam(ex); - return switch (path) { - case "/event" -> loadEvent(ex,params); - case "/events/list" -> listEvents(ex,params); - case "/tags" -> listTags(ex,params); - default -> PathHandler.notFound(ex); - }; - } - - @Override - public boolean doDelete(String path, HttpExchange ex) throws IOException { - var params = queryParam(ex); - return switch (path) { - case "/event" -> delete(ex,params); - default -> PathHandler.notFound(ex); - }; - } - - private boolean delete(HttpExchange ex, Map params) throws IOException { - var aid = params.get(AID); - if (aid == null) return sendContent(ex,Error.of("Missing appointment id")); - long id = 0; - try { - id = Long.parseLong(aid); - } catch (Exception e){ - return sendContent(ex,Error.format("%s is not a valid id!",aid)); - } - var json = json(ex); - var title = json.has(TITLE) ? nullIfEmpty(json.getString(TITLE)) : - null; - if (title == null) return sendContent(ex, Error.of("Missing appointment title")); - var res = db.loadEvent(id); - if (res.optional().isEmpty()) return sendContent(ex, res); - var event = res.optional().get(); - if (!title.equals(event.title())) return sendContent(ex, Error.of("Title mismatch!")); - var del = db.removeAppointment(id); - if (del.optional().isEmpty()) return sendContent(ex,del); - return sendContent(ex,Map.of("deleted",del.optional().get())); - } - - @Override - public boolean doPost(String path, HttpExchange ex) throws IOException { - return switch (path) { - case "/event/edit" -> editEvent(ex); - default -> PathHandler.notFound(ex); - }; - } - - // spotless:off - private boolean editEvent(HttpExchange ex) throws IOException { - var json = json(ex); - Optional existingAppointment = Optional.empty(); - Long aid = json.has(AID) ? json.getLong(AID) : null; - if (aid != null) { // load appointment by aid - existingAppointment = db.loadEvent(aid).optional(); - } else { // try to load appointment by location @ time - var location = json.has(LOCATION) ? json.getString(LOCATION) : null; - var start = json.has(START) ? LocalDateTime.parse(json.getString(START)) : null; - if (allSet(location, start)) { - existingAppointment = db.loadEvent(location, start).optional(); - } - } - if (existingAppointment.isPresent()) { - var event = existingAppointment.get(); - json.put(AID,event.id()); - return update(ex,toEvent(json)); - } - return createEvent(ex, json); - } - - - // spotless:on - - private Result toEvent(JSONObject json) { - var description = json.has(DESCRIPTION) ? nullIfEmpty(json.getString(DESCRIPTION)) : null; - var title = json.has(TITLE) ? nullIfEmpty(json.getString(TITLE)) : null; - if (title == null) return Error.of("title missing"); - var start = json.has(START) ? nullIfEmpty(json.getString(START)) : null; - if (start == null) return Error.of("start missing"); - var startDate = nullable(start).map(dt -> LocalDateTime.parse(dt, ISO_DATE_TIME)).orElse(null); - var end = json.has(END) ? nullIfEmpty(json.getString(END)) : null; - var endDate = nullable(end).map(dt -> LocalDateTime.parse(dt, ISO_DATE_TIME)).orElse(null); - var location = json.has(LOCATION) ? json.getString(LOCATION) : null; - if (location == null) return Error.of("location missing"); - var aid = json.has(AID) ? json.getLong(AID) : 0; - var event = new BaseAppointment(aid, title, description, startDate, endDate, location); - if (json.has(ATTACHMENTS)) { - json.getJSONArray(ATTACHMENTS).forEach(att -> { - Payload // - .of(att.toString()) - .map(BaseImporter::url) - .map(BaseImporter::toAttachment) - .optional() - .ifPresent(event::add); - }); - } - if (json.has(LINKS)) { - json.getJSONArray(LINKS).forEach(o -> { - if (o instanceof JSONObject j) toLink(j).optional().ifPresent(event::addLinks); - }); - } - if (json.has(TAGS)) json.getJSONArray(TAGS).forEach(o -> event.tags(o.toString())); - return Payload.of(event); - } - - private boolean createEvent(HttpExchange ex, JSONObject json) throws IOException { - var eventRes = toEvent(json); - if (eventRes.optional().isPresent()) { - return sendContent(ex, db.add(eventRes.optional().get()).map(ApiHandler::toJson)); - } - return sendContent(ex, eventRes); - } - - protected static Result toLink(JSONObject json) { - try { - var description = json.getString(DESCRIPTION); - return Payload.of(json.getString(URL)).map(BaseImporter::url).map(url -> BaseImporter.link(url, description)); - - } catch (Exception e) { - return Error.of("Failed to create link from %s".formatted(json), e); - } - } - - private boolean update(HttpExchange ex, Result event) throws IOException { - if (event.optional().isPresent()) return sendContent(ex, db.update(event.optional().get()).map(ApiHandler::toJson)); - return sendContent(ex, event); - } - - private boolean listTags(HttpExchange ex, Map params) throws IOException { - var infix = params.get("infix"); - if (infix == null) return sendContent(ex, Error.of("No infix set in method call parameters")); - var res = db.findTags(infix).map(ApiHandler::sortTags); - return sendContent(ex, res); - } - - private static Result> sortTags(Result> listResult) { - if (listResult.optional().isEmpty()) return listResult; - List list = listResult.optional().get(); - while (list.size() > 15) { - int longest = list.stream().map(String::length).reduce(0, Integer::max); - var subset = list.stream().filter(s -> s.length() < longest).toList(); - if (subset.size() < 3) return Payload.of(list); - list = subset; - } - return Payload.of(list); - } - - private boolean listEvents(HttpExchange ex, Map params) throws IOException { - var start = nullable(params.get("start")).map(ApiHandler::toLocalDateTime).orElse(null); - var end = nullable(params.get("end")).map(ApiHandler::toLocalDateTime).orElse(null); - try { - return PathHandler.sendContent(ex, db.list(start, end).stream().map(Appointment::json).toList()); - } catch (SQLException e) { - LOG.log(WARNING, "Failed to fetch events (start = {0}, end = {1}!", start, end, e); - } - return PathHandler.notFound(ex); - } - - private boolean loadEvent(HttpExchange ex, Map params) throws IOException { - var id = params.get("id"); - if (id != null) try { - return PathHandler.sendContent(ex, db.loadEvent(Long.parseLong(id)).map(ApiHandler::toJson)); - } catch (NumberFormatException | IOException nfe) { - return PathHandler.sendContent(ex, Error.format("%s is not a numeric event id!", id)); - } - return PathHandler.sendContent(ex, Error.of("ID missing")); - } - - private static Result toJson(Result res) { - var opt = res.optional(); - if (opt.isEmpty()) return Result.transform(res); - return Payload.of(opt.get().json()); - } - - - private static LocalDateTime toLocalDateTime(String dateString) { - try { - return LocalDate.parse(dateString + "-01", DateTimeFormatter.ISO_LOCAL_DATE).atTime(0, 0); - } catch (Exception e) { - return null; - } - } - } diff --git a/de.srsoftware.cal.web/src/main/resources/index.html b/de.srsoftware.cal.web/src/main/resources/index.html index b8d5fe6..bdc788f 100644 --- a/de.srsoftware.cal.web/src/main/resources/index.html +++ b/de.srsoftware.cal.web/src/main/resources/index.html @@ -16,6 +16,7 @@ + diff --git a/de.srsoftware.cal.web/src/main/resources/script/common.js b/de.srsoftware.cal.web/src/main/resources/script/common.js index f4c2cc9..1b6ab03 100644 --- a/de.srsoftware.cal.web/src/main/resources/script/common.js +++ b/de.srsoftware.cal.web/src/main/resources/script/common.js @@ -1,3 +1,7 @@ +function create(type){ + return document.createElement(type); +} + function element(id){ return document.getElementById(id); } \ No newline at end of file diff --git a/de.srsoftware.cal.web/src/main/resources/script/edit.js b/de.srsoftware.cal.web/src/main/resources/script/edit.js index dba326e..c1a5d2a 100644 --- a/de.srsoftware.cal.web/src/main/resources/script/edit.js +++ b/de.srsoftware.cal.web/src/main/resources/script/edit.js @@ -170,8 +170,9 @@ async function saveEvent(){ var location = element('location').value; var start = element('start').value; + var aid = element('aid').value; var event = { - aid : element('aid').value, + aid : aid ? aid : 0, title : element('title').value, description : element('description').value, location : element('location').value, @@ -182,11 +183,12 @@ async function saveEvent(){ coords: element('coords').value, attachments: getAttachments() }; - fetch('/api/event/edit',{ - method: 'POST', + fetch('/api/event',{ + method: aid ? 'PATCH' : 'POST', // if aid is set, we do an update. otherwise save new body: JSON.stringify(event), headers: { - 'Content-Type' : 'appication/json' + 'Content-Type' : 'appication/json', + 'title': event.title } }).then(handleSave); } diff --git a/de.srsoftware.cal.web/src/main/resources/script/event.js b/de.srsoftware.cal.web/src/main/resources/script/event.js index 6fb83bc..accd70d 100644 --- a/de.srsoftware.cal.web/src/main/resources/script/event.js +++ b/de.srsoftware.cal.web/src/main/resources/script/event.js @@ -71,11 +71,11 @@ function confirmDelete(){ const urlParams = new URLSearchParams(window.location.search); var id = urlParams.get('id'); if (confirm(`Do you really want to delete "${title}"?`)){ - fetch('/api/event?aid='+id,{ + fetch('/api/event?id='+id,{ method: 'DELETE', - body: JSON.stringify({ + headers: { title: title - }) + } }).then(handleDeleted); } } \ No newline at end of file diff --git a/de.srsoftware.cal.web/src/main/resources/script/index.js b/de.srsoftware.cal.web/src/main/resources/script/index.js index 423b2c0..ecc8f16 100644 --- a/de.srsoftware.cal.web/src/main/resources/script/index.js +++ b/de.srsoftware.cal.web/src/main/resources/script/index.js @@ -3,6 +3,7 @@ var end = null; var tags = new Set(); var highlight = null; +// add a cell to the given row, put link to event(id) with given content function addCell(row,content,id){ var a = document.createElement('a'); if (content){ @@ -12,8 +13,9 @@ function addCell(row,content,id){ row.insertCell().appendChild(a); } +// add a row to the table, fill with event data from json function addRow(json){ - var table = document.getElementById('eventlist'); + var table = element('eventlist'); var row = table.insertRow(1); row.id = json.id; if (json.tags){ @@ -28,6 +30,7 @@ function addRow(json){ row.appendChild(createTags(json.tags)); } + function createTags(tagList){ var td = document.createElement('td'); tagList.forEach(val => { @@ -40,8 +43,9 @@ function createTags(tagList){ return td; } +// fetch events in the time range, then call handleEvents function fetchEvents(start, end){ - var path = '/api/events/list'; + var path = '/api/events/json'; if (start) { path += '?start='+start; if (end) path+= '&end='+end; @@ -73,6 +77,7 @@ function fetchLastYear(){ fetchEvents(start,end); } +// add the events fetched with the latest request to the table async function handleEvents(response){ if (response.ok){ var json = await response.json(); @@ -88,9 +93,16 @@ async function handleEvents(response){ } } +// called when page is loaded function loadCurrentEvents(){ let params = new URLSearchParams(location.search); highlight = params.get('id'); + var tagString = params.get('tags'); + if (tagString){ + tagString.split(',').forEach(t => { + tags.add(t.trim()); + }); + } if (start == null){ var now = new Date(); var year = now.getFullYear(); @@ -100,6 +112,20 @@ function loadCurrentEvents(){ } } +function openIcal(){ + var url = location.href; + var query = location.search; + var pos = url.indexOf('?'); + if (pos>1) url = url.substring(0,pos); + if (!url.endsWith('/')) url += '/'; + url += 'api/ical'+query; + var elem = create('a'); + elem.setAttribute('href',url); + elem.setAttribute('download','calendar.ical'); + elem.click(); +} + +// shows the given url in an overlayed iframe function showOverlay(url){ var div = document.getElementById('overlay'); div.innerHTML = ''; @@ -115,19 +141,22 @@ function showOverlay(url){ div.appendChild(closeBtn); } +// adds or removes tag from set, updates shown tags function toggleTag(tag){ if (tags.has(tag)){ tags.delete(tag); } else { tags.add(tag); } + updateUrl(); updateTagVisibility(); } +// creates a button for each tag in tags set function updateTagVisibility(){ var selection = document.getElementById('tag_selection'); selection.innerHTML = 'Selected Tags: '; - selection.style.display = tags.size > 0 ? 'inline' : 'none'; + selection.style.display = tags.size > 0 ? 'block' : 'none'; tags.forEach(tag => { var btn = document.createElement('button'); btn.onclick = e => toggleTag(tag); @@ -145,4 +174,18 @@ function updateTagVisibility(){ }); tr.style.display = all ? 'table-row' : 'none'; } +} + +function updateUrl(){ + var url = location.href; + var pos = url.indexOf('?'); + if (pos>0) url = url.substring(0,pos); + var params = []; + if (highlight) params.push('id='+highlight); + if (start) params.push('start='+start); + var tagString = Array.from(tags).join(','); + if (tagString) params.push('tags='+tagString); + var query = params.join('&'); + if (query) url += '?'+query; + window.history.pushState(null,"",url); } \ No newline at end of file diff --git a/de.srsoftware.cal.web/src/test/java/ParseDateTest.java b/de.srsoftware.cal.web/src/test/java/ParseDateTest.java new file mode 100644 index 0000000..2c0b188 --- /dev/null +++ b/de.srsoftware.cal.web/src/test/java/ParseDateTest.java @@ -0,0 +1,40 @@ +/* © SRSoftware 2024 */ +import static de.srsoftware.cal.ApiEndpoint.parseDate; +import static java.time.LocalDateTime.of; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class ParseDateTest { + @Test + public void testFull() { + assertEquals(of(2024, 12, 11, 10, 9, 8), parseDate("2024-12-11T10:09:08").optional().orElse(null)); + assertEquals(of(2024, 12, 11, 10, 9, 8), parseDate("2024-12-11 10:09:08").optional().orElse(null)); + assertEquals(of(2024, 12, 11, 10, 9, 8), parseDate("2024-12-11T10:9:8").optional().orElse(null)); + assertEquals(of(2024, 12, 11, 10, 9, 8), parseDate("2024-12-11 10:9:8").optional().orElse(null)); + } + + @Test + public void testNoSecond() { + assertEquals(of(2024, 12, 11, 10, 9), parseDate("2024-12-11T10:09").optional().orElse(null)); + assertEquals(of(2024, 12, 11, 10, 9), parseDate("2024-12-11 10:09").optional().orElse(null)); + assertEquals(of(2024, 12, 11, 10, 9), parseDate("2024-12-11T10:9").optional().orElse(null)); + assertEquals(of(2024, 12, 11, 8, 9), parseDate("2024-12-11 8:9").optional().orElse(null)); + } + + @Test + public void testNoTime() { + assertEquals(of(2024, 12, 11, 0, 0), parseDate("2024-12-11").optional().orElse(null)); + assertEquals(of(2024, 12, 11, 0, 0), parseDate("2024-12-11").optional().orElse(null)); + assertEquals(of(2024, 12, 5, 0, 0), parseDate("2024-12-5").optional().orElse(null)); + assertEquals(of(2024, 12, 5, 0, 0), parseDate("2024-12-5").optional().orElse(null)); + assertEquals(of(2024, 6, 5, 0, 0), parseDate("2024-6-5").optional().orElse(null)); + assertEquals(of(2024, 6, 5, 0, 0), parseDate("2024-6-5").optional().orElse(null)); + } + + @Test + public void testNoDay() { + assertEquals(of(2024, 12, 1, 0, 0), parseDate("2024-12").optional().orElse(null)); + assertEquals(of(2024, 12, 1, 0, 0), parseDate("2024-12").optional().orElse(null)); + } +} diff --git a/de.srsoftware.cal.web/src/test/java/ParsePastTest.java b/de.srsoftware.cal.web/src/test/java/ParsePastTest.java new file mode 100644 index 0000000..d911c21 --- /dev/null +++ b/de.srsoftware.cal.web/src/test/java/ParsePastTest.java @@ -0,0 +1,37 @@ +/* © SRSoftware 2024 */ +import static de.srsoftware.cal.ApiEndpoint.parsePast; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +public class ParsePastTest { + @Test + public void testMonth() { + var expected = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + assertEquals(expected, parsePast(null).optional().orElse(null)); + assertEquals(expected.minusMonths(1), parsePast("1m").optional().orElse(null)); + assertEquals(expected.minusMonths(2), parsePast("2m").optional().orElse(null)); + assertEquals(expected.minusMonths(12), parsePast("12m").optional().orElse(null)); + } + + @Test + public void testYear() { + var expected = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + assertEquals(expected, parsePast(null).optional().orElse(null)); + assertEquals(expected.minusYears(1), parsePast("1y").optional().orElse(null)); + assertEquals(expected.minusYears(2), parsePast("2y").optional().orElse(null)); + assertEquals(expected.minusYears(12), parsePast("12y").optional().orElse(null)); + } + + @Test + public void testAll() { + assertEquals(null, parsePast("all")); + } + + @Test + public void testDefault() { + var expected = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0); + assertEquals(expected, parsePast(null).optional().get()); + } +} diff --git a/doc/openapi3_0.yaml b/doc/openapi3_0.yaml index bd611d4..8f4125f 100644 --- a/doc/openapi3_0.yaml +++ b/doc/openapi3_0.yaml @@ -13,40 +13,99 @@ servers: - url: https://cal.srsoftware.de - url: http://localhost:8080 paths: - /api/events/json: - get: - description: |- - Get a list of events from the server in JSON format. - - Filters may be applied by using request parameters. + /api/event: + delete: + description: Deletes the specified element from the list of events. parameters: - - description: Filter keywords. Only events having all provided tags are listed - example: magrathea,heartofgold + - description: the appointment id in: query - name: tags - required: false + name: id + required: true schema: - format: comma-separated values - type: string - - description: start time. Only events after this date time are returned - example: 2024-12-30T10:32 + example: 42 + format: int64 + type: number + - description: title of the event as kind of confirmation + in: header + name: title + required: true + schema: + example: this will not confirm + format: string + responses: + '200': + description: successfull operation + '400': + description: id missing + '404': + description: invalid id (no such event) + '412': + description: mismatching title (no confirmation) + '500': + description: server fault + summary: remove an event + get: + parameters: + - description: the appointment id in: query - name: start - required: false + name: id + required: true schema: - format: date-time + example: 42 + format: int64 + type: number + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/AppointmentList' + '400': + description: invalid input + '500': + description: server fault + summary: get an event with all details + patch: + parameters: + - description: copy of the title + in: header + name: title + schema: + example: Demolition of the earth type: string - - description: |- - Return past events, if set. Allowed values: - * all → return all events - * [n]m – return events including the last n months - * [n]y - return events including the last n years - example: 2m - in: query - name: past - required: false + requestBody: + description: update an appointment. + content: + application/json: + schema: + $ref: '#/components/schemas/AppointmentList' + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/AppointmentList' + '400': + description: invalid input + '500': + description: server fault + summary: update the specified event + post: + parameters: + - description: copy of the title + in: header + name: title schema: + example: Demolition of the earth type: string + requestBody: + description: Create a new appointment. Id must be set to 0 (number zero). + content: + application/json: + schema: + $ref: '#/components/schemas/AppointmentList' responses: '200': description: successful operation @@ -58,7 +117,8 @@ paths: description: invalid input '500': description: server fault - summary: Fetch event list + summary: + store a new event in the database /api/events/ical: get: description: |- @@ -117,71 +177,56 @@ paths: '500': description: server fault summary: Fetch event list - /api/event: - delete: - description: Deletes the specified element from the list of events. + /api/events/json: + get: + description: |- + Get a list of events from the server in JSON format. + + Filters may be applied by using request parameters. parameters: - - description: the appointment id + - description: Maximum number of appointments to return + example: 100 in: query - name: id - required: true + name: count + required: false schema: - example: 42 - format: int64 + format: int32 type: number - - description: title of the event as kind of confirmation - in: header - name: title - required: true - schema: - example: this will not confirm - format: string - responses: - '200': - description: successfull operation - '404': - description: invalid id (no such event) - '412': - description: mismatching title (no confirmation) - '500': - description: server fault - summary: remove an event - get: - parameters: - - description: the appointment id + - description: skip number of appointments when listing + example: 50 in: query - name: id - required: true + name: offset + required: false schema: - example: 42 - format: int64 + format: int32 type: number - responses: - '200': - description: successful operation - content: - application/json: - schema: - $ref: '#/components/schemas/AppointmentList' - '400': - description: invalid input - '500': - description: server fault - summary: get an event with all details - patch: - parameters: - - description: copy of the title - in: header - name: title + - description: |- + Return past events, if set. Allowed values: + * all → return all events + * [n]m – return events including the last n months + * [n]y - return events including the last n years + example: 2m + in: query + name: past + required: false schema: - example: Demolition of the earth type: string - requestBody: - description: update an appointment. - content: - application/json: - schema: - $ref: '#/components/schemas/AppointmentList' + - description: start time. Only events after this date time are returned + example: 2024-12-30T10:32 + in: query + name: start + required: false + schema: + format: date-time + type: string + - description: Filter keywords. Only events having all provided tags are listed + example: magrathea,heartofgold + in: query + name: tags + required: false + schema: + format: comma-separated values + type: string responses: '200': description: successful operation @@ -193,33 +238,34 @@ paths: description: invalid input '500': description: server fault - summary: update the specified event - post: + summary: + Fetch event list + /api/tags: + get: + description: list tags parameters: - - description: copy of the title - in: header - name: title + - description: substring to search in tags + example: rock + in: query + name: infix + required: true schema: - example: Demolition of the earth type: string - requestBody: - description: Create a new appointment. Id must be set to 0 (number zero). - content: - application/json: - schema: - $ref: '#/components/schemas/AppointmentList' responses: '200': description: successful operation content: application/json: schema: - $ref: '#/components/schemas/AppointmentList' - '400': - description: invalid input - '500': - description: server fault - summary: store a new event in the database + description: keywords that describe the event. may be used to filter events + example: + - GlamRock + - HardRock + - Rockabilly + - RockNRoll + items: + type: string + type: array components: schemas: Appointment: