Files
OpenCloudCal/de.srsoftware.cal.web/src/main/java/de/srsoftware/cal/ApiEndpoint.java
2026-01-04 21:22:20 +01:00

333 lines
13 KiB
Java

/* © SRSoftware 2026 */
package de.srsoftware.cal;
import static de.srsoftware.cal.Util.extractCoords;
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;
}
private Result<JSONObject> createEvent(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 HttpError.of(400,"Missing title header");
if (!title.get().equals(event.title())) return HttpError.of(400,"Title mismatch!");
String hostname = hostname(ex);
String urlTemplate = hostname+"/static/event?id={}";
return db.add(opt.get()).map(ev -> toJson(ev,urlTemplate));
} catch (IOException e) {
return error(e, "Failed to read event data from request body");
}
}
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(404, "%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());
}
@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 {
String hostname = hostname(ex);
String urlTemplate = hostname+"/static/event?id={}";
String prodId = "OpenCloudCal@"+hostname.split("://",2)[1];
ex.getResponseHeaders().add("Access-Control-Allow-Origin","*");
return switch (path) {
case "/event" -> sendContent(ex,getEvent(ex).map(event -> toJson(event,urlTemplate)).map(ApiEndpoint::httpError));
case "/event/ical"-> sendContent(ex,getEvent(ex).map(event -> toIcal(event, hostname, urlTemplate)).map(ical -> Util.wrapIcal(ical,prodId)).map(ApiEndpoint::httpError));
case "/events/ical"-> sendContent(ex,eventList(ex).map(list -> toIcalList(list, hostname, urlTemplate)).map(ical -> Util.wrapIcal(ical,prodId)).map(ApiEndpoint::httpError));
case "/events/json" -> sendContent(ex,eventList(ex).map(list -> toJsonList(list,urlTemplate)).map(ApiEndpoint::httpError));
case "/tags" -> listTags(ex);
default -> unknownPath(ex, path);
};
}
@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, createEvent(ex));
return unknownPath(ex, path);
}
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 HttpError.of(400,"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 HttpError.of(400,"Count (count=%s) is not a number!", o);
}
Result<LocalDateTime> end = parseDate(param.get(END));
var endDate = (end == null) ? null: end.optional().orElse(null);
return db.list(startDate, endDate, count, offset, tags).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 Result<Appointment> getEvent(HttpExchange ex) {
var params = queryParam(ex);
var o = params.get(ID);
if (o == null) return HttpError.of(400,"id parameter missing!");
try {
return db.loadEvent(Long.parseLong(o));
} catch (NumberFormatException e) {
return HttpError.of(400,e, "Illegal format for id parameter (%s)", o);
}
}
private static <T> Result<T> httpError(Result<T> res) {
if (res instanceof NotFound<T> notFound) return HttpError.of(404, notFound.message());
return res;
}
private boolean listTags(HttpExchange ex) throws IOException {
var params = queryParam(ex);
var infix = nullIfEmpty(params.get("infix"));
if (infix == null) return sendContent(ex, HttpError.of(400,"No infix set in method call parameters"));
var res = db.findTags(infix).map(ApiEndpoint::sortTags);
return sendContent(ex, res);
}
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);
}
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()) {
int num = nullable(matcher.group(1)).map(Integer::parseInt).orElse(0);
var g2 = nullable(matcher.group(2)).orElse("");
switch (g2) {
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);
}
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 static boolean tagsMatch(Appointment event, List<String> tags) {
return new HashSet<>(event.tags().stream().map(String::toLowerCase).toList()).containsAll(tags);
}
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(Util::url)
.map(Util::toAttachment)
.optional()
.ifPresent(event::add);
});
}
if (json.has(COORDS)) extractCoords(json.getString(COORDS)).optional().ifPresent(event::coords);
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 static Result<String> toIcal(Result<Appointment> res, String hostname, String urlTemplate) {
var opt = res.optional();
return opt.isEmpty() ? transform(res) : Payload.of(opt.get().ical(hostname, urlTemplate));
}
private static Result<String> toIcalList(Result<List<Appointment>> res, String hostname, String urlTemplate) {
var opt = res.optional();
if (opt.isEmpty()) return transform(res);
var list = opt.get().stream().map(event -> event.ical(hostname, urlTemplate)).collect(Collectors.joining("\n"));
return Payload.of(list);
}
private static Result<JSONObject> toJson(Result<? extends Appointment> res, String urlTemplate) {
var opt = res.optional();
return opt.isEmpty() ? transform(res) : Payload.of(opt.get().json(urlTemplate));
}
private static Result<List<JSONObject>> toJsonList(Result<List<Appointment>> res, String urlTemplate) {
var opt = res.optional();
if (opt.isEmpty()) return transform(res);
var list = opt.get().stream().map(event -> event.json(urlTemplate)).toList();
return Payload.of(list);
}
protected static Result<Link> toLink(JSONObject json) {
try {
var description = json.getString(DESCRIPTION);
return Payload.of(json.getString(URL))
.map(Util::url)
.map(url -> BaseImporter.link(url, description));
} catch (Exception e) {
return error(e, "Failed to create link from %s", json);
}
}
private boolean unknownPath(HttpExchange ex, String path) throws IOException {
return sendContent(ex, HttpError.of(404, "%s is not known to this API", path));
}
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 HttpError.of(400,"Missing title header");
if (!title.get().equals(event.title())) return HttpError.of(400,"Title mismatch!");
String hostname = hostname(ex);
String urlTemplate = hostname+"/static/event?id={}";
return db.update(opt.get()).map(ev -> toJson(ev,urlTemplate));
} catch (IOException e) {
return error(e, "Failed to read event data from request body");
}
}
}