Browse Source
- wrote openapi schema - re-implemented api endpoint following openapi schema - intensified and improved working with Result objects Signed-off-by: Stephan Richter <s.richter@srsoftware.de>main
28 changed files with 858 additions and 526 deletions
@ -1,6 +1,6 @@
@@ -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") |
||||
} |
||||
|
@ -1,23 +1,24 @@
@@ -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<Coords> 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); |
||||
} |
||||
} |
||||
} |
||||
|
@ -0,0 +1,22 @@
@@ -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<None> extends Error<None> { |
||||
public NotFound(String message, Map<String, Object> data, Collection<Exception> exceptions) { |
||||
super(message, data, exceptions); |
||||
} |
||||
|
||||
public static <T> NotFound<T> of(String message, Object... fills) { |
||||
return new NotFound<>(message.formatted(fills), null, null); |
||||
} |
||||
|
||||
|
||||
@Override |
||||
public <NewType> NotFound<NewType> transform() { |
||||
return new NotFound<>(message(), data(), exceptions()); |
||||
} |
||||
} |
@ -0,0 +1,32 @@
@@ -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<None> extends Error<None> { |
||||
public SqlError(String message, Map<String, Object> data, Collection<Exception> 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 <T> SqlError<T> of(SQLException e, String message, Object... fills) { |
||||
return new SqlError<>(message.formatted(fills), null, List.of(e)); |
||||
} |
||||
|
||||
@Override |
||||
public <NewType> SqlError<NewType> transform() { |
||||
return new SqlError<>(message(), data(), exceptions()); |
||||
} |
||||
} |
@ -0,0 +1,314 @@
@@ -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<List<String>> sortTags(Result<List<String>> listResult) { |
||||
if (listResult.optional().isEmpty()) return listResult; |
||||
List<String> 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<JSONObject> 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<JSONObject> 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<Appointment> 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<BaseAppointment> 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<Link> 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<String> toIcal(Result<List<Appointment>> 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<List<JSONObject>> toJsonList(Result<List<Appointment>> 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<JSONObject> toJson(Result<? extends Appointment> res) { |
||||
var opt = res.optional(); |
||||
if (opt.isEmpty()) return transform(res); |
||||
return Payload.of(opt.get().json()); |
||||
} |
||||
|
||||
private Result<List<Appointment>> 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<LocalDateTime> start = parseDate(param.get(START)); |
||||
if (start == null) start = parsePast(param.get(PAST)); |
||||
if (start instanceof de.srsoftware.tools.Error<LocalDateTime> 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<List<Appointment>> filterByTags(Result<List<Appointment>> res, List<String> 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<String> tags) { |
||||
return new HashSet<>(event.tags().stream().map(String::toLowerCase).toList()).containsAll(tags); |
||||
} |
||||
|
||||
public static Result<LocalDateTime> 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<LocalDateTime> 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<Long> 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<Long> deleteEvent(Result<Appointment> 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 <T> Result<T> httpError(Result<T> res) { |
||||
if (res instanceof NotFound<T> notFound) return HttpError.of(404, notFound.message()); |
||||
return res; |
||||
} |
||||
} |
@ -1,221 +0,0 @@
@@ -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<String,String> 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<Appointment> 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<BaseAppointment> 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<Link> 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<BaseAppointment> 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<String, String> 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<List<String>> sortTags(Result<List<String>> listResult) { |
||||
if (listResult.optional().isEmpty()) return listResult; |
||||
List<String> 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<String, String> 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<String, String> 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<JSONObject> toJson(Result<Appointment> 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; |
||||
} |
||||
} |
||||
} |
@ -1,3 +1,7 @@
@@ -1,3 +1,7 @@
|
||||
function create(type){ |
||||
return document.createElement(type); |
||||
} |
||||
|
||||
function element(id){ |
||||
return document.getElementById(id); |
||||
} |
@ -0,0 +1,40 @@
@@ -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)); |
||||
} |
||||
} |
@ -0,0 +1,37 @@
@@ -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()); |
||||
} |
||||
} |
Loading…
Reference in new issue