diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/Address.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/Address.java new file mode 100644 index 00000000000..6d5ecb4b387 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/Address.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.advice; + +import com.codename1.backend.validation.NotBlank; + +/** A nested object the validation example descends into. */ +public class Address { + @NotBlank public String city; +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/ApiErrors.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/ApiErrors.java new file mode 100644 index 00000000000..6d95d0c0c44 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/ApiErrors.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.advice; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.ExceptionHandler; +import com.codename1.backend.annotations.RestControllerAdvice; + +// tag::backend-web-controller-advice[] +@RestControllerAdvice +public class ApiErrors { + @ExceptionHandler(IllegalArgumentException.class) + public HttpServer.Response badInput(IllegalArgumentException e, HttpServer.Request request) { + return HttpServer.Response.text(400, e.getMessage()).header("X-Error", "input"); + } +} +// end::backend-web-controller-advice[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/Audit.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/Audit.java new file mode 100644 index 00000000000..88b4924c7b1 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/Audit.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.advice; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Order; +import com.codename1.backend.web.HandlerInterceptor; +import com.codename1.backend.web.HandlerMethod; + +// tag::backend-web-interceptor[] +@Component +@Order(1) +public class Audit implements HandlerInterceptor { + private final AuditTrail audit = new AuditTrail(); + + public HttpServer.Response preHandle(HttpServer.Request request, HandlerMethod handler) { + return null; // null proceeds; a Response answers instead + } + + public void afterCompletion(HttpServer.Request request, HandlerMethod handler, + HttpServer.Response response, Throwable failure) { + audit.record(handler.getPattern(), failure == null ? "ok" : failure.toString()); + } +} +// end::backend-web-interceptor[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/AuditTrail.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/AuditTrail.java new file mode 100644 index 00000000000..dcd4bc5a190 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/AuditTrail.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.advice; + +import java.util.ArrayList; +import java.util.List; + +/** Where the interceptor example records what happened. */ +public class AuditTrail { + private final List entries = new ArrayList(); + + public synchronized void record(String route, String outcome) { + entries.add(route + " " + outcome); + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NewNote.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NewNote.java new file mode 100644 index 00000000000..f2e7f6186ed --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NewNote.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.advice; + +import com.codename1.backend.validation.Max; +import com.codename1.backend.validation.Min; +import com.codename1.backend.validation.NotBlank; +import com.codename1.backend.validation.Size; +import com.codename1.backend.validation.Valid; +import java.util.List; + +// tag::backend-web-validation-model[] +public class NewNote { + @NotBlank @Size(max = 200) public String title; + @Min(1) @Max(5) public int priority; + @Valid public Address address; + @Valid public List tags; +} +// end::backend-web-validation-model[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NoteMissing.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NoteMissing.java new file mode 100644 index 00000000000..9e97d3251ae --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NoteMissing.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.advice; + +/** Thrown when a note does not exist. */ +public class NoteMissing extends RuntimeException { + public NoteMissing(long id) { + super("No note " + id); + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NoteView.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NoteView.java new file mode 100644 index 00000000000..2854d51b5bd --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NoteView.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.advice; + +/** A note as the advice examples return it. */ +public class NoteView { + public long id; + public String title; +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NotesWithHandler.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NotesWithHandler.java new file mode 100644 index 00000000000..f14ad24a560 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NotesWithHandler.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.advice; + +import com.codename1.backend.annotations.ExceptionHandler; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PathVariable; +import com.codename1.backend.annotations.ResponseStatus; +import com.codename1.backend.annotations.RestController; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +// tag::backend-web-exception-handler[] +@RestController +public class NotesWithHandler { + private final Map notes = new HashMap(); + + @GetMapping("/notes/{id}") + public NoteView note(@PathVariable("id") long id) { + NoteView note = notes.get(id); + if (note == null) { + throw new NoteMissing(id); + } + return note; + } + + @ExceptionHandler(NoteMissing.class) + @ResponseStatus(404) + public Map missing(NoteMissing e) { + return Collections.singletonMap("error", e.getMessage()); + } +} +// end::backend-web-exception-handler[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/Tag.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/Tag.java new file mode 100644 index 00000000000..95cf2369a88 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/Tag.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.advice; + +import com.codename1.backend.validation.Size; + +/** A collection element the validation example checks one by one. */ +public class Tag { + @Size(min = 1, max = 20) public String name; +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/ValidatedNotes.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/ValidatedNotes.java new file mode 100644 index 00000000000..994b86724a0 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/ValidatedNotes.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.advice; + +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.RequestBody; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; +import com.codename1.backend.validation.Min; +import com.codename1.backend.validation.Valid; +import java.util.ArrayList; +import java.util.List; + +/** The controller half of the validation example. */ +@RestController +public class ValidatedNotes { + private final List notes = new ArrayList(); + + // tag::backend-web-validation-controller[] + @PostMapping("/notes") + public NoteView create(@Valid @RequestBody NewNote note) { + NoteView saved = new NoteView(); + saved.id = notes.size() + 1; + saved.title = note.title; + notes.add(saved); + return saved; + } + + @GetMapping("/notes") + public List page(@RequestParam("page") @Min(1) int page) { + int from = Math.min((page - 1) * 20, notes.size()); + return new ArrayList(notes.subList(from, Math.min(from + 20, notes.size()))); + } + // end::backend-web-validation-controller[] +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/backend-web.txt b/docs/demos/common/src/main/snippets/developer-guide/backend-web.txt index 6c3a280e910..c792069b197 100644 --- a/docs/demos/common/src/main/snippets/developer-guide/backend-web.txt +++ b/docs/demos/common/src/main/snippets/developer-guide/backend-web.txt @@ -8,3 +8,9 @@ index.html ... // end::backend-web-hosting-layout[] + +// tag::backend-web-validation-errors[] +{"status":400,"error":"Bad Request","errors":[ + {"field":"title","code":"NotBlank","message":"must not be blank"}, + {"field":"tags[1].name","code":"Size","message":"size must be between 1 and 20"}]} +// end::backend-web-validation-errors[] diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index 545465636bc..aecf6c60eb2 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -377,6 +377,157 @@ Conditional requests and ranges are honoured, and where the platform supports it the file is sent from the page cache to the socket without passing through the process. +[[backend-web-errors]] +==== When a method throws + +A method that throws answers 500 with a body that reveals nothing about the +failure, unless something handles the exception first. A +`ResponseStatusException` chooses its own status. For anything else, write a +handler: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NotesWithHandler.java[tag=backend-web-exception-handler,indent=0] +---- + +A handler on a controller answers for that controller's routes. A +`@ControllerAdvice` (or `@RestControllerAdvice`) bean holds handlers for every +controller, and `basePackages` narrows it to some: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/ApiErrors.java[tag=backend-web-controller-advice,indent=0] +---- + +A handler takes, in any order, the exception, the `HttpServer.Request` and the +`HandlerMethod` that failed. It returns an `HttpServer.Response` for full +control of status, headers and body; or a `String`, a `Map`, a `List`, a +`Json.Writable` or nothing, sent with its `@ResponseStatus`, or 200 without +one, as Spring does. A handler returning a null `Response` declines, and the +next one is asked. + +The build decides which handler answers, in this order: + +. The controller's own handlers. Among those that apply, the one naming the + closest superclass of the thrown exception. +. Each advice, in `@Order` order and then by bean name, choosing its own + closest match the same way. +. A `ValidationException` that nothing handled answers 400 with its field + errors, as the next section shows. +. Anything else answers as before: the status of a + `ResponseStatusException`, otherwise a plain 500. + +Two kinds of failure skip every handler. An `Error` is never handled. A +failure that belongs to security, `AccessDeniedException` or +`AuthenticationException` and their subclasses (CSRF failures among them), +goes straight back to the security chain, so a handler for `Exception` can't +turn a 403 into a 200 by accident. + +Two handlers in one class for the same exception, a handler that takes an +argument it can't be given, or one returning a type it can't send all fail the +build, naming the method. + +[[backend-web-validation]] +==== Validating input + +Constraints from `com.codename1.backend.validation` declare what valid input +looks like. Put `@Valid` on a `@RequestBody` or `@ModelAttribute` parameter to +check the object's fields, and put a constraint directly on a `@RequestParam`, +`@PathVariable` or `@RequestHeader`: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/NewNote.java[tag=backend-web-validation-model,indent=0] +---- + +The controller asks for the checks on its parameters: + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/ValidatedNotes.java[tag=backend-web-validation-controller,indent=0] +---- + +The constraints are `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Size`, `@Min` and +`@Max`. `@Valid` on a field descends into its value, or into each element of a +collection or array. The build writes every check as code, so a constraint on a +type that can't carry it, such as `@Min` on a `String`, fails the build. + +The checks run after binding and before the method, so invalid input never +reaches it. They throw a `ValidationException`, which a handler from the +previous section may answer. Unhandled, it answers 400: + +[source,json] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-web.txt[tag=backend-web-validation-errors,indent=0] +---- + +Field paths are stable: they use the JSON property names, `[n]` for an +element, and the annotation's name for a parameter. A `@Valid @ModelAttribute` +form followed by a `BindingResult` parameter records the errors there instead, +and the method decides what to render. + +There is no `@Pattern`: the backend's class library has no regular +expressions. Check such a value in the method and throw a `ValidationException` +of your own; it's answered the same way. + +[[backend-web-interceptors]] +==== Around every request + +A bean implementing `HandlerInterceptor` runs around every controller method, +for work that's not security: auditing, metrics, cleaning up after a request. + +[source,java,role=cn1-no-try] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/advice/Audit.java[tag=backend-web-interceptor,indent=0] +---- + +A request passes through these stages in order: + +. The security filter chain: authentication, CSRF and the URL rules. A request + it refuses never reaches an interceptor. +. Route selection. A request that matches no route isn't intercepted. +. Each interceptor's `preHandle`, in `@Order` order. One that returns a + response answers with it, and the controller method doesn't run. +. Binding, validation, and the method with its method security. +. The exception handlers, when the stage before threw. +. Each `postHandle`, in reverse order, when the method returned normally. +. Each `afterCompletion`, in reverse order, for every interceptor whose + `preHandle` let the request through. It runs on success, after a later + interceptor refused, after a binding or validation failure, and after a + failure nothing handled, which it receives as `failure`. + +Because interceptors run inside the security chain and never see the failures +security owns, an interceptor can't let through a request security refused. + +[[backend-web-servlet-equivalents]] +==== Coming from Servlets and Spring MVC + +The backend has no Servlet container. The usual extension points map as +follows: + +[cols="1,2",options="header"] +|=== +|Servlet or Spring MVC |Backend + +|`Filter` doing authentication, CSRF or access rules +|A `SecurityFilterChain` bean, or a `SecurityFilter` added to it + +|`Filter` or `HandlerInterceptor` doing anything else +|A `HandlerInterceptor` bean + +|`@ExceptionHandler`, `@ControllerAdvice`, `@RestControllerAdvice` +|The same annotations, resolved at build time + +|`HandlerExceptionResolver` +|An `ExceptionResolver` bean + +|`@Valid` with Bean Validation +|`@Valid` and the constraints in `com.codename1.backend.validation` + +|`HttpServletResponse` headers and status +|Return an `HttpServer.Response` +|=== + [[backend-hosting-the-app]] ==== Hosting the app in the browser diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java index 6ecb06cfdd9..6d4e1cc1f83 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -125,10 +125,14 @@ final class BackendBeans { POST_CONSTRUCT, PRE_DESTROY, TRANSACTIONAL, ASYNC, SCHEDULED, MANAGED_RESOURCE, MANAGED_ATTRIBUTE, MANAGED_OPERATION, TIMED, COUNTED, MCP_TOOL, MVC_CONTROLLER, REST_CONTROLLER, WEBSOCKET_MAPPING, PRE_AUTHORIZE, SECURED, - ROLES_ALLOWED, PERMIT_ALL, DENY_ALL))); + ROLES_ALLOWED, PERMIT_ALL, DENY_ALL, + BackendExceptionHandlers.CONTROLLER_ADVICE, + BackendExceptionHandlers.REST_CONTROLLER_ADVICE))); private static final String[] STEREOTYPES = {COMPONENT, CONFIGURATION, - REST_CONTROLLER, MVC_CONTROLLER, WEBSOCKET_MAPPING}; + REST_CONTROLLER, MVC_CONTROLLER, WEBSOCKET_MAPPING, + BackendExceptionHandlers.CONTROLLER_ADVICE, + BackendExceptionHandlers.REST_CONTROLLER_ADVICE}; static final String CONFIG_TYPE = "com/codename1/backend/Config"; static final String DATASOURCE_TYPE = "com/codename1/backend/DataSource"; diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendExceptionHandlers.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendExceptionHandlers.java new file mode 100644 index 00000000000..4bc2b547df7 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendExceptionHandlers.java @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.MethodInfo; +import com.codename1.maven.annotations.ProcessorContext; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.objectweb.asm.Type; + +/// `@ExceptionHandler` methods, on a controller or a `@ControllerAdvice` bean, +/// compiled to an `instanceof` chain. +/// +/// The chain is ordered at build time, deepest declared type first, so the first +/// test that passes is the handler for the closest superclass of what was thrown: +/// every declared type that matches lies on the thrown type's one superclass +/// chain, and the deeper of two is the closer. That is the precedence Spring's +/// `ExceptionHandlerMethodResolver` computes at run time, decided here instead. +final class BackendExceptionHandlers { + static final String PKG = "Lcom/codename1/backend/annotations/"; + static final String EXCEPTION_HANDLER = PKG + "ExceptionHandler;"; + static final String CONTROLLER_ADVICE = PKG + "ControllerAdvice;"; + static final String REST_CONTROLLER_ADVICE = PKG + "RestControllerAdvice;"; + private static final String RESPONSE_STATUS = PKG + "ResponseStatus;"; + private static final String REQUEST = "com.codename1.backend.HttpServer$Request"; + private static final String RESPONSE = "com.codename1.backend.HttpServer$Response"; + private static final String HANDLER_METHOD = "com.codename1.backend.web.HandlerMethod"; + private static final Set JSON_RETURNS = new HashSet(java.util.Arrays.asList( + "java.util.Map", "java.util.HashMap", "java.util.LinkedHashMap", "java.util.TreeMap", + "java.util.List", "java.util.ArrayList", "java.util.LinkedList", + "java.util.Collection", "java.util.Set", "java.util.HashSet", + "java.util.LinkedHashSet", "com.codename1.backend.Json$Writable")); + + /// One handled type of one method. + static final class Handler { + String exception; + int depth; + int declared; + String call; + } + + private BackendExceptionHandlers() { + } + + /// Whether `cls` is a `@ControllerAdvice` or `@RestControllerAdvice`. + static AnnotationValues advice(AnnotatedClass cls) { + AnnotationValues a = cls.getClassAnnotation(CONTROLLER_ADVICE); + return a != null ? a : cls.getClassAnnotation(REST_CONTROLLER_ADVICE); + } + + /// The generated adapter of an advice class. + static String adapterName(String binary) { + String pkg = RestClientAnnotationProcessor.packageOf(binary); + String simple = binary.substring(pkg.length() == 0 ? 0 : pkg.length() + 1) + .replace('$', '_') + "Cn1Advice"; + return pkg.length() == 0 ? simple : pkg + "." + simple; + } + + /// The handlers of `cls`, deepest declared type first; null after an error. + static List collect(ProcessorContext ctx, AnnotatedClass cls) { + List out = new ArrayList(); + Set seen = new HashSet(); + int declared = 0; + for (MethodInfo m : cls.getMethods()) { + AnnotationValues a = m.getAnnotation(EXCEPTION_HANDLER); + if (a == null) { + continue; + } + String where = cls.getBinaryName() + "." + m.getName(); + if (!m.isPublic() || m.isStatic()) { + ctx.error(cls, "@ExceptionHandler " + where + " must be a public instance " + + "method: the generated code calls it from another class"); + return null; + } + Type[] params = Type.getArgumentTypes(m.getDescriptor()); + StringBuilder args = new StringBuilder(); + String exceptionParam = null; + for (int i = 0; i < params.length; i++) { + String t = params[i].getClassName(); + if (i > 0) { + args.append(", "); + } + if (REQUEST.equals(t)) { + args.append("request"); + } else if (HANDLER_METHOD.equals(t)) { + args.append("handler"); + } else if (exceptionParam == null && params[i].getSort() == Type.OBJECT) { + exceptionParam = t; + args.append("(").append(t.replace('$', '.')).append(") failure"); + } else { + ctx.error(cls, "@ExceptionHandler " + where + " takes a " + t + ". A handler " + + "takes the exception, the HttpServer.Request and the " + + "HandlerMethod, in any order, and nothing else"); + return null; + } + } + List types = new ArrayList(); + Object value = a.get("value"); + if (value instanceof List) { + for (Object o : (List) value) { + if (o instanceof Type) { + types.add(((Type) o).getClassName()); + } + } + } + if (types.isEmpty()) { + if (exceptionParam == null) { + ctx.error(cls, "@ExceptionHandler " + where + " names no exception and " + + "takes none; say which it handles"); + return null; + } + types.add(exceptionParam); + } + String status = String.valueOf(statusOf(m)); + String body = invocation(ctx, cls, m, args.toString(), status, where); + if (body == null) { + return null; + } + for (String type : types) { + List chain = superChain(ctx, type); + if (chain == null || !chain.contains("java.lang.Throwable")) { + ctx.error(cls, "@ExceptionHandler " + where + " handles " + type + + ", which the build cannot resolve as an exception"); + return null; + } + if (exceptionParam != null && !chain.contains(exceptionParam)) { + ctx.error(cls, "@ExceptionHandler " + where + " handles " + type + + " but its parameter is a " + exceptionParam + ", which it is not"); + return null; + } + if (!seen.add(type)) { + ctx.error(cls, "Two @ExceptionHandler methods of " + cls.getBinaryName() + + " handle " + type + "; which one answers would be a guess"); + return null; + } + Handler h = new Handler(); + h.exception = type; + h.depth = chain.size(); + h.declared = declared++; + h.call = body; + out.add(h); + } + } + Collections.sort(out, new Comparator() { + @Override + public int compare(Handler x, Handler y) { + return x.depth != y.depth ? y.depth - x.depth : x.declared - y.declared; + } + }); + return out; + } + + private static int statusOf(MethodInfo m) { + AnnotationValues s = m.getAnnotation(RESPONSE_STATUS); + return s == null ? 200 : s.getIntOrDefault("value", 200); + } + + /// The statements that call the handler and answer with what it returns. + private static String invocation(ProcessorContext ctx, AnnotatedClass cls, MethodInfo m, + String args, String status, String where) { + String ret = Type.getReturnType(m.getDescriptor()).getClassName(); + String call = "impl." + m.getName() + "(" + args + ")"; + if (RESPONSE.equals(ret)) { + // Null declines, and the next resolver is asked. + return "return " + call + ";\n"; + } + if ("void".equals(ret)) { + return call + ";\nreturn request.respond(" + status + ", \"text/plain\", new byte[0]);\n"; + } + if ("java.lang.String".equals(ret)) { + return "String cn1Text = " + call + ";\n" + + "return request.respond(" + status + ", \"text/plain; charset=utf-8\",\n" + + " cn1Text == null ? new byte[0] : cn1Text.getBytes(\"UTF-8\"));\n"; + } + if (JSON_RETURNS.contains(ret)) { + return "Object cn1Value = " + call + ";\n" + + "return cn1Value == null ? request.respond(" + status + + ", \"text/plain\", new byte[0])\n" + + " : request.respondJson(" + status + ", cn1Value);\n"; + } + ctx.error(cls, "@ExceptionHandler " + where + " returns a " + ret + ". Return an " + + "HttpServer.Response, a String, a Map, a List, a Json.Writable, or nothing"); + return null; + } + + /// The `instanceof` chain answering for `failure`, ending in `return null`. + static String dispatch(List handlers, String pad) { + StringBuilder sb = new StringBuilder(); + for (Handler h : handlers) { + sb.append(pad).append("if (failure instanceof ").append(h.exception.replace('$', '.')) + .append(") {\n"); + for (String line : h.call.split("\n")) { + sb.append(pad).append(" ").append(line).append('\n'); + } + sb.append(pad).append("}\n"); + } + sb.append(pad).append("return null;\n"); + return sb.toString(); + } + + /// The source of the resolver for an advice class. + static String adapter(AnnotatedClass cls, List handlers, AnnotationValues advice) { + String binary = cls.getBinaryName(); + String name = adapterName(binary); + String pkg = RestClientAnnotationProcessor.packageOf(name); + String simple = name.substring(pkg.length() == 0 ? 0 : pkg.length() + 1); + String source = cls.getSourceName(); + StringBuilder sb = new StringBuilder(); + if (pkg.length() > 0) { + sb.append("package ").append(pkg).append(";\n\n"); + } + sb.append("// Generated from @ControllerAdvice on ").append(binary).append(". Do not edit.\n"); + sb.append("@com.codename1.backend.annotations.Generated\n"); + sb.append("public final class ").append(simple) + .append(" implements com.codename1.backend.web.ExceptionResolver {\n"); + sb.append(" private final ").append(source).append(" impl;\n\n"); + sb.append(" public ").append(simple).append('(').append(source) + .append(" impl) {\n this.impl = impl;\n }\n\n"); + sb.append(" public com.codename1.backend.HttpServer.Response resolve(\n") + .append(" com.codename1.backend.HttpServer.Request request,\n") + .append(" com.codename1.backend.web.HandlerMethod handler,\n") + .append(" Exception failure) throws Exception {\n"); + List packages = new ArrayList(); + Object bp = advice.get("basePackages"); + if (bp instanceof List) { + for (Object o : (List) bp) { + if (o instanceof String && ((String) o).length() > 0) { + packages.add((String) o); + } + } + } + if (!packages.isEmpty()) { + sb.append(" if ("); + for (int i = 0; i < packages.size(); i++) { + sb.append(i == 0 ? "" : " && ").append("!handler.isInPackage(") + .append(RestControllerAnnotationProcessor.quote(packages.get(i))).append(')'); + } + sb.append(") {\n return null;\n }\n"); + } + sb.append(dispatch(handlers, " ")); + sb.append(" }\n}\n"); + return sb.toString(); + } + + /// The binary names from `binary` up to `java.lang.Object`, or null when a + /// link cannot be resolved. JDK classes come from the JDK running the build, + /// which is not on the processor's class index. + static List superChain(ProcessorContext ctx, String binary) { + List out = new ArrayList(); + String current = binary; + while (current != null) { + out.add(current); + if ("java.lang.Object".equals(current)) { + return out; + } + AnnotatedClass c = RestControllerAnnotationProcessor.resolveClass(ctx, + current.replace('.', '/')); + if (c != null) { + String parent = c.getSuperInternalName(); + current = parent == null ? null : parent.replace('/', '.'); + continue; + } + if (!current.startsWith("java.")) { + return null; + } + try { + Class k = Class.forName(current, false, + BackendExceptionHandlers.class.getClassLoader()); + for (Class s = k.getSuperclass(); s != null; s = s.getSuperclass()) { + out.add(s.getName()); + } + return out; + } catch (ClassNotFoundException err) { + return null; + } + } + return out; + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendValidation.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendValidation.java new file mode 100644 index 00000000000..b5b888cafe6 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendValidation.java @@ -0,0 +1,418 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.FieldInfo; +import com.codename1.maven.annotations.MethodInfo; +import com.codename1.maven.annotations.ProcessorContext; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +/// Writes the checks behind `com.codename1.backend.validation`: the constraints +/// on a controller parameter as statements in the router, and one public static +/// method per validated class, in a generated `Cn1Validation` class of that +/// class's package -- the one place its package-private fields can be read. +/// +/// Every check is a plain `if`; nothing reads an annotation at run time, which is +/// what lets the translated backend keep them. +final class BackendValidation { + static final String PKG = "Lcom/codename1/backend/validation/"; + static final String VALID = PKG + "Valid;"; + static final String NOT_NULL = PKG + "NotNull;"; + static final String NOT_BLANK = PKG + "NotBlank;"; + static final String NOT_EMPTY = PKG + "NotEmpty;"; + static final String SIZE = PKG + "Size;"; + static final String MIN = PKG + "Min;"; + static final String MAX = PKG + "Max;"; + static final String VIOLATIONS = "com.codename1.backend.validation.Violations"; + static final String CLASS_NAME = "Cn1Validation"; + + private static final List CONSTRAINTS = Arrays.asList(NOT_NULL, NOT_BLANK, + NOT_EMPTY, SIZE, MIN, MAX); + private static final Set COLLECTIONS = new HashSet(Arrays.asList( + "java.util.Collection", "java.util.List", "java.util.Set", "java.util.ArrayList", + "java.util.LinkedList", "java.util.HashSet", "java.util.LinkedHashSet", + "java.util.TreeSet", "java.util.SortedSet", "java.lang.Iterable")); + private static final Set MAPS = new HashSet(Arrays.asList( + "java.util.Map", "java.util.HashMap", "java.util.LinkedHashMap", + "java.util.TreeMap", "java.util.SortedMap")); + private static final Set INTEGRAL = new HashSet(Arrays.asList( + "byte", "short", "int", "long", "java.lang.Byte", "java.lang.Short", + "java.lang.Integer", "java.lang.Long")); + private static final Set PRIMITIVES = new HashSet(Arrays.asList( + "byte", "short", "int", "long", "char", "boolean", "float", "double")); + + private final ProcessorContext ctx; + /// Binary name of each validated class, to the statements of its method. + private final Map methods = new LinkedHashMap(); + private final Set pending = new HashSet(); + private int counter; + + BackendValidation(ProcessorContext ctx) { + this.ctx = ctx; + } + + /// Whether a parameter or field carries anything this class checks. + static boolean constrained(Map annotations) { + if (annotations == null) { + return false; + } + if (annotations.containsKey(VALID)) { + return true; + } + for (String c : CONSTRAINTS) { + if (annotations.containsKey(c)) { + return true; + } + } + return false; + } + + /// The statements checking `expr` against `annotations`, reporting into + /// `violations` at `pathExpr` (a Java expression of type String). + /// + /// @param type the value's Java type, with type arguments when known + /// @param where for an error message + /// @throws IllegalArgumentException for a constraint the type cannot carry + String check(Map annotations, String type, String expr, + String pathExpr, String violations, String pad, String where) { + StringBuilder sb = new StringBuilder(); + String raw = erase(type); + boolean primitive = PRIMITIVES.contains(raw); + for (String c : CONSTRAINTS) { + AnnotationValues a = annotations.get(c); + if (a == null) { + continue; + } + String code = simple(c); + String message = RestControllerAnnotationProcessor.quote( + a.getStringOrDefault("message", "")); + String failed; + String fallback; + if (NOT_NULL.equals(c)) { + if (primitive) { + continue; // a primitive is never null + } + failed = expr + " == null"; + fallback = "must not be null"; + } else if (NOT_BLANK.equals(c)) { + requireText(raw, code, where); + failed = VIOLATIONS + ".isBlank(" + expr + ")"; + fallback = "must not be blank"; + } else if (NOT_EMPTY.equals(c)) { + failed = expr + " == null || " + length(raw, expr, code, where) + " == 0"; + fallback = "must not be empty"; + } else if (SIZE.equals(c)) { + int min = intValue(a, "min", 0); + int max = intValue(a, "max", Integer.MAX_VALUE); + String len = length(raw, expr, code, where); + failed = expr + " != null && (" + len + " < " + min + " || " + len + " > " + + max + ")"; + fallback = "size must be between " + min + " and " + max; + } else { + if (!INTEGRAL.contains(raw)) { + throw new IllegalArgumentException("@" + code + " applies to an integral " + + "number (byte, short, int, long or their boxes), and " + where + + " is a " + raw); + } + long bound = longValue(a, "value"); + boolean isMin = MIN.equals(c); + failed = (primitive ? "" : expr + " != null && ") + expr + + (isMin ? " < " : " > ") + bound + "L"; + fallback = "must be " + (isMin ? "greater" : "less") + " than or equal to " + + bound; + } + sb.append(pad).append("if (").append(failed).append(") {\n"); + sb.append(pad).append(" ").append(violations).append(".add(").append(pathExpr) + .append(", ").append(RestControllerAnnotationProcessor.quote(code)).append(", ") + .append(message).append(", ").append(RestControllerAnnotationProcessor.quote(fallback)) + .append(");\n"); + sb.append(pad).append("}\n"); + } + if (annotations.containsKey(VALID)) { + sb.append(cascade(type, expr, pathExpr, violations, pad, where)); + } + return sb.toString(); + } + + /// `@Valid`: descends into a class of the application, or into each element + /// of a collection or array of them. A value with nothing to descend into -- + /// a String, a Map, a number -- is left alone, as Bean Validation does. + private String cascade(String type, String expr, String pathExpr, String violations, + String pad, String where) { + String raw = erase(type); + StringBuilder sb = new StringBuilder(); + String element = null; + boolean array = raw.endsWith("[]"); + if (array) { + element = raw.substring(0, raw.length() - 2); + } else if (COLLECTIONS.contains(raw)) { + element = typeArgument(type); + } + if (element != null) { + String elementRaw = erase(element); + if (validator(elementRaw) == null) { + return ""; + } + String i = "cn1I" + (counter++); + String e = "cn1E" + (counter++); + sb.append(pad).append("if (").append(expr).append(" != null) {\n"); + sb.append(pad).append(" int ").append(i).append(" = 0;\n"); + if (array) { + sb.append(pad).append(" for (").append(elementRaw.replace('$', '.')) + .append(' ').append(e).append(" : ").append(expr).append(") {\n"); + sb.append(pad).append(" if (").append(e).append(" != null) {\n"); + sb.append(pad).append(" ").append(validator(elementRaw)).append('(') + .append(e).append(", ").append(VIOLATIONS).append(".index(").append(pathExpr) + .append(", ").append(i).append("), ").append(violations).append(");\n"); + } else { + // Tested, never merely cast: the translated runtime does not + // check a cast, so a foreign element would be read as this type. + sb.append(pad).append(" for (Object ").append(e).append(" : ").append(expr) + .append(") {\n"); + sb.append(pad).append(" if (").append(e).append(" instanceof ") + .append(elementRaw.replace('$', '.')).append(") {\n"); + sb.append(pad).append(" ").append(validator(elementRaw)).append("((") + .append(elementRaw.replace('$', '.')).append(") ").append(e).append(", ") + .append(VIOLATIONS).append(".index(").append(pathExpr).append(", ").append(i) + .append("), ").append(violations).append(");\n"); + } + sb.append(pad).append(" }\n"); + sb.append(pad).append(" ").append(i).append("++;\n"); + sb.append(pad).append(" }\n"); + sb.append(pad).append("}\n"); + return sb.toString(); + } + String call = validator(raw); + if (call == null) { + return ""; + } + sb.append(pad).append("if (").append(expr).append(" != null) {\n"); + sb.append(pad).append(" ").append(call).append('(').append(expr).append(", ") + .append(pathExpr).append(", ").append(violations).append(");\n"); + sb.append(pad).append("}\n"); + return sb.toString(); + } + + /// The static method validating `binary`, or null when it is not a class of + /// the application with something to validate. + private String validator(String binary) { + if (binary.startsWith("java.") || PRIMITIVES.contains(binary) || binary.endsWith("[]")) { + return null; + } + String pkg = RestClientAnnotationProcessor.packageOf(binary); + String name = qualify(pkg, CLASS_NAME) + ".validate" + + binary.substring(pkg.length() == 0 ? 0 : pkg.length() + 1).replace('$', '_'); + if (methods.containsKey(binary) || pending.contains(binary)) { + return name; + } + AnnotatedClass cls = RestControllerAnnotationProcessor.resolveClass(ctx, + binary.replace('.', '/')); + if (cls == null || cls.isEnum() || cls.isInterface()) { + return null; + } + pending.add(binary); + String body = classBody(cls, binary); + pending.remove(binary); + if (body == null) { + return null; + } + methods.put(binary, body); + return name; + } + + /// The statements validating each constrained field of `cls` and its + /// superclasses, read through `v`; null when no field is constrained. + private String classBody(AnnotatedClass cls, String binary) { + List chain = new ArrayList(); + for (AnnotatedClass c = cls; c != null; ) { + chain.add(0, c); + String parent = c.getSuperInternalName(); + if (parent == null || parent.startsWith("java/")) { + break; + } + c = RestControllerAnnotationProcessor.resolveClass(ctx, parent); + } + String pkg = RestClientAnnotationProcessor.packageOf(binary); + StringBuilder sb = new StringBuilder(); + boolean any = false; + for (AnnotatedClass c : chain) { + for (FieldInfo f : c.getFields()) { + if ((f.getAccess() & (Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC)) != 0 + || !constrained(f.getAnnotations())) { + continue; + } + String where = binary + "." + f.getName(); + String read = read(chain, c, f, pkg, cls.isRecord()); + if (read == null) { + throw new IllegalArgumentException(where + " carries a constraint, but is " + + "private with no public getter, so the generated check cannot " + + "read it. Add a getter or make the field package-private"); + } + String type = RestClientAnnotationProcessor.javaTypeFor( + Type.getType(f.getDescriptor()), f.getSignature()); + AnnotationValues renamed = f.getAnnotation(BackendJsonCodecs.JSON_PROPERTY); + String json = renamed == null ? null : renamed.getString("value"); + String property = json == null || json.length() == 0 ? f.getName() : json; + String local = "f" + (counter++); + sb.append(" final ").append(BackendJsonCodecs.source(type)).append(' ') + .append(local).append(" = ").append(read).append(";\n"); + sb.append(check(f.getAnnotations(), type, local, VIOLATIONS + ".path(path, " + + RestControllerAnnotationProcessor.quote(property) + ")", "violations", + " ", where)); + any = true; + } + } + return any ? sb.toString() : null; + } + + private static String read(List chain, AnnotatedClass owner, FieldInfo f, + String pkg, boolean record) { + if (record) { + return "v." + f.getName() + "()"; + } + if (f.isPublic() || (!f.isPrivate() + && RestClientAnnotationProcessor.packageOf(owner.getBinaryName()).equals(pkg))) { + return "v." + f.getName(); + } + String cap = Character.toUpperCase(f.getName().charAt(0)) + f.getName().substring(1); + for (int i = chain.size() - 1; i >= 0; i--) { + for (MethodInfo m : chain.get(i).getMethods()) { + if (m.isPublic() && !m.isStatic() && ("()" + f.getDescriptor()).equals(m.getDescriptor()) + && (("get" + cap).equals(m.getName()) + || ("Z".equals(f.getDescriptor()) && ("is" + cap).equals(m.getName())))) { + return "v." + m.getName() + "()"; + } + } + } + return null; + } + + /// One `Cn1Validation` class per package that has a validated class. + Map sources() { + Map byPackage = new LinkedHashMap(); + for (Map.Entry e : methods.entrySet()) { + String binary = e.getKey(); + String pkg = RestClientAnnotationProcessor.packageOf(binary); + StringBuilder sb = byPackage.get(pkg); + if (sb == null) { + sb = new StringBuilder(); + if (pkg.length() > 0) { + sb.append("package ").append(pkg).append(";\n\n"); + } + sb.append("// Generated from the validation constraints of this package. " + + "Do not edit.\n"); + sb.append("@com.codename1.backend.annotations.Generated\n"); + sb.append("public final class ").append(CLASS_NAME).append(" {\n"); + sb.append(" private ").append(CLASS_NAME).append("() {\n }\n"); + byPackage.put(pkg, sb); + } + String simple = binary.substring(pkg.length() == 0 ? 0 : pkg.length() + 1); + sb.append("\n public static void validate").append(simple.replace('$', '_')) + .append('(').append(binary.replace('$', '.')).append(" v, String path, ") + .append(VIOLATIONS).append(" violations) {\n"); + sb.append(e.getValue()); + sb.append(" }\n"); + } + Map out = new LinkedHashMap(); + for (Map.Entry e : byPackage.entrySet()) { + out.put(qualify(e.getKey(), CLASS_NAME), e.getValue().append("}\n").toString()); + } + return out; + } + + private static void requireText(String raw, String code, String where) { + if (!"java.lang.String".equals(raw) && !"java.lang.CharSequence".equals(raw) + && !"java.lang.StringBuilder".equals(raw)) { + throw new IllegalArgumentException("@" + code + " applies to text, and " + where + + " is a " + raw); + } + } + + private static String length(String raw, String expr, String code, String where) { + if ("java.lang.String".equals(raw) || "java.lang.CharSequence".equals(raw) + || "java.lang.StringBuilder".equals(raw)) { + return expr + ".length()"; + } + if (raw.endsWith("[]")) { + return expr + ".length"; + } + if (COLLECTIONS.contains(raw) && !"java.lang.Iterable".equals(raw) || MAPS.contains(raw)) { + return expr + ".size()"; + } + throw new IllegalArgumentException("@" + code + " applies to text, a collection, a " + + "map or an array, and " + where + " is a " + raw); + } + + private static int intValue(AnnotationValues a, String name, int fallback) { + Object v = a.get(name); + return v instanceof Number ? ((Number) v).intValue() : fallback; + } + + private static long longValue(AnnotationValues a, String name) { + Object v = a.get(name); + return v instanceof Number ? ((Number) v).longValue() : 0L; + } + + private static String simple(String descriptor) { + return descriptor.substring(descriptor.lastIndexOf('/') + 1, descriptor.length() - 1); + } + + static String erase(String type) { + int lt = type.indexOf('<'); + if (lt < 0) { + return type; + } + int gt = type.lastIndexOf('>'); + return type.substring(0, lt) + type.substring(gt + 1); + } + + /// The single type argument of `java.util.List`, or null. + private static String typeArgument(String type) { + int lt = type.indexOf('<'); + int gt = type.lastIndexOf('>'); + if (lt < 0 || gt < lt) { + return null; + } + String arg = type.substring(lt + 1, gt).trim(); + if (arg.startsWith("? extends ")) { + arg = arg.substring("? extends ".length()); + } + return arg.startsWith("?") ? null : arg; + } + + private static String qualify(String pkg, String simple) { + return pkg.length() == 0 ? simple : pkg + "." + simple; + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java index 3d0b3a23e80..df5560819e2 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java @@ -307,6 +307,7 @@ private void create(StringBuilder sb, List routers) { sb.append(" }\n }\n"); } sb.append(" java.util.List handlers = new java.util.ArrayList();\n"); + controllerHooks(sb); for (Router r : routers) { BackendBeans.Bean b = model.beanOfClass(r.controllerBinary); String ref = "routed" + b.var; @@ -314,7 +315,7 @@ private void create(StringBuilder sb, List routers) { .append(" = ").append(reference(b)).append(";\n"); sb.append(" if (").append(ref).append(" != null) {\n"); sb.append(" handlers.add(new ").append(r.routerBinary).append('(').append(ref) - .append("));\n"); + .append(", cn1Hooks));\n"); sb.append(" synchronized (this) {\n active.add(") .append(BackendSources.quote(r.controllerBinary)) .append(");\n }\n }\n }\n"); @@ -717,6 +718,59 @@ private String typeOf(BackendBeans.Bean b) { return types.sourceOf(b.type); } + // -------------------------------------------------------- controller hooks + + static final String INTERCEPTOR_TYPE = "com/codename1/backend/web/HandlerInterceptor"; + static final String RESOLVER_TYPE = "com/codename1/backend/web/ExceptionResolver"; + + /// `cn1Hooks`: every HandlerInterceptor bean, and every @ControllerAdvice or + /// ExceptionResolver bean, each in @Order order and then by bean name -- the + /// precedence ControllerHooks documents. A conditional bean that was not + /// created is null here, and ControllerHooks drops it. + private void controllerHooks(StringBuilder sb) { + List ordered = new ArrayList(model.beans); + java.util.Collections.sort(ordered, new java.util.Comparator() { + @Override + public int compare(BackendBeans.Bean a, BackendBeans.Bean b) { + if (a.order != b.order) { + return a.order < b.order ? -1 : 1; + } + return a.name.compareTo(b.name); + } + }); + StringBuilder interceptors = new StringBuilder(); + StringBuilder resolvers = new StringBuilder(); + for (BackendBeans.Bean b : ordered) { + if (b.mockType != null) { + continue; + } + boolean advice = b.factory == null && b.cls != null + && BackendExceptionHandlers.advice(b.cls) != null; + if (b.types.contains(INTERCEPTOR_TYPE)) { + interceptors.append(interceptors.length() == 0 ? "" : ", ") + .append("(com.codename1.backend.web.HandlerInterceptor) ").append(reference(b)); + } + if (advice) { + String ref = "advice" + b.var; + sb.append(" ").append(typeOf(b)).append(' ').append(ref).append(" = ") + .append(reference(b)).append(";\n"); + resolvers.append(resolvers.length() == 0 ? "" : ", ").append(ref) + .append(" == null ? null : new ") + .append(BackendExceptionHandlers.adapterName(b.type.replace('/', '.')).replace('$', '.')) + .append('(').append(ref).append(')'); + } else if (b.types.contains(RESOLVER_TYPE)) { + resolvers.append(resolvers.length() == 0 ? "" : ", ") + .append("(com.codename1.backend.web.ExceptionResolver) ").append(reference(b)); + } + } + sb.append(" com.codename1.backend.web.ControllerHooks cn1Hooks =\n") + .append(" new com.codename1.backend.web.ControllerHooks(\n") + .append(" new com.codename1.backend.web.HandlerInterceptor[] {") + .append(interceptors).append("},\n") + .append(" new com.codename1.backend.web.ExceptionResolver[] {") + .append(resolvers).append("});\n"); + } + // ------------------------------------------------------------- websockets private void webSockets(StringBuilder sb, Map sockets) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java index 6b7909e0807..a7fcd73a662 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java @@ -252,6 +252,9 @@ private static final class Controller { String routerSimpleName; List basePaths = new ArrayList(); List routes = new ArrayList(); + /** The controller's own @ExceptionHandler methods, deepest type first. */ + List handlers = + new ArrayList(); } private static final class Route { @@ -272,6 +275,8 @@ private static final class Route { * when Json writes it as it is. */ String codecWrite; + /** Whether any parameter is checked before the call. */ + boolean validates; } private static final class Param { @@ -293,6 +298,24 @@ private static final class Param { */ String codecRead; String formBinding; + /** + * The checks of the parameter's constraints, against the local + * cn1Arg{index}, reporting into cn1Violations; null when it has none. + */ + String validation; + } + + /// The @ControllerAdvice classes: binary name to the resolver source. + private final Map adviceSources = new LinkedHashMap(); + + /// The validation checks, created with the first constrained parameter. + private BackendValidation validation; + + private BackendValidation validation(ProcessorContext ctx) { + if (validation == null) { + validation = new BackendValidation(ctx); + } + return validation; } /// {AnnotatedClass, description} of every handler parameter the security @@ -316,6 +339,8 @@ public Set getAnnotationDescriptors() { out.add(CONTROLLER); out.add(MVC_CONTROLLER); out.add(WEBSOCKET_MAPPING); + out.add(BackendExceptionHandlers.CONTROLLER_ADVICE); + out.add(BackendExceptionHandlers.REST_CONTROLLER_ADVICE); return out; } @@ -327,6 +352,17 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces // controller and a websocket endpoint, and refusing that would be an // arbitrary rule rather than a real constraint. } + AnnotationValues advice = BackendExceptionHandlers.advice(cls); + if (advice != null && BuildHintAnnotationProcessor.hasBackingSource(cls, + ctx.getCompileSourceRoots(), ctx.getSourceEncoding())) { + List handlers = + BackendExceptionHandlers.collect(ctx, cls); + if (handlers == null) { + return; + } + adviceSources.put(BackendExceptionHandlers.adapterName(cls.getBinaryName()), + BackendExceptionHandlers.adapter(cls, handlers, advice)); + } if (cls.getClassAnnotation(CONTROLLER) == null && cls.getClassAnnotation(MVC_CONTROLLER) == null) { return; } @@ -431,6 +467,11 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces if (!routeShapesAreDistinct(cls, controller, ctx)) { return; } + List handlers = BackendExceptionHandlers.collect(ctx, cls); + if (handlers == null) { + return; + } + controller.handlers.addAll(handlers); controllers.put(controller.binaryName, controller); } @@ -707,6 +748,42 @@ static boolean segmentsOverlap(String left, String right) { return literal.matches(regex.toString()); } + /// Compiles the constraints on one parameter into [Param#validation]. + /// + /// The value is checked AFTER it is bound and BEFORE the method runs, so a + /// request breaking a constraint has no side effect: the controller never + /// sees it. Paths are stable -- the JSON property under the body, the + /// annotation's name for a scalar parameter. + private boolean prepareValidation(AnnotatedClass cls, MethodInfo m, Param p, + Map annotations, String genericType, int index, + Route route, ProcessorContext ctx) { + if (!BackendValidation.constrained(annotations)) { + return true; + } + String where = "parameter " + (index + 1) + " of " + cls.getBinaryName() + "." + + m.getName(); + boolean root = "BODY".equals(p.kind) || "FORM".equals(p.kind); + if (!root && !"PATH".equals(p.kind) && !"QUERY".equals(p.kind) + && !"HEADER".equals(p.kind)) { + ctx.error(cls, "Validation constraints apply to a @RequestBody, @ModelAttribute, " + + "@PathVariable, @RequestParam or @RequestHeader parameter, and " + where + + " is none of those"); + return false; + } + String type = p.genericJavaType != null ? p.genericJavaType + : genericType != null ? genericType : p.javaType; + String path = root ? "" : p.name == null || p.name.length() == 0 ? "arg" + index : p.name; + try { + p.validation = validation(ctx).check(annotations, type, "cn1Arg" + index, + quote(path), "cn1Violations", "", where); + } catch (IllegalArgumentException err) { + ctx.error(cls, err.getMessage()); + return false; + } + route.validates = true; + return true; + } + /** * Splits a route pattern into the parts the generated matcher needs. * @@ -972,6 +1049,9 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St p.genericJavaType = bodyType; p.codecRead = codecs(ctx).readStatements(bodyType, "parsed", "com.codename1" + ".impl.backend.JsonCodec.Path.ROOT", "null", "-1", "0", "target", ""); + if (!prepareValidation(cls, m, p, annotations, genericType, i, route, ctx)) { + return null; + } route.params.add(p); continue; } @@ -1031,6 +1111,9 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St + "value nobody wrote."); return null; } + if (!prepareValidation(cls, m, p, annotations, genericType, i, route, ctx)) { + return null; + } route.params.add(p); } @@ -1514,6 +1597,18 @@ public void finish(ProcessorContext ctx) throws ProcessingException { } sources.putAll(codecSources); } + Map extra = new LinkedHashMap(adviceSources); + if (validation != null) { + extra.putAll(validation.sources()); + } + for (String generated : extra.keySet()) { + if (isNotOurOwnOutput(ctx, generated)) { + ctx.error(generated + " already exists, and the class generated under that " + + "name would replace it. Rename that class."); + return; + } + } + sources.putAll(extra); boolean hasViews = false; for (Controller controller : controllers.values()) for (Route route : controller.routes) hasViews |= route.view; if (hasViews || hasMvcControllers()) { @@ -1577,8 +1672,11 @@ private static String generateRouter(Controller c) { sb.append("// Generated from @RestController on ").append(c.binaryName) .append(". Do not edit.\n"); sb.append("@com.codename1.backend.annotations.Generated\n"); + boolean local = !c.handlers.isEmpty(); sb.append("public final class ").append(c.routerSimpleName) - .append(" implements com.codename1.backend.HttpServer.Handler {\n\n"); + .append(" implements com.codename1.backend.HttpServer.Handler,\n") + .append(" com.codename1.backend.web.ControllerHooks.Invocation") + .append(local ? ", com.codename1.backend.web.ExceptionResolver" : "").append(" {\n\n"); List ordered = new ArrayList(c.routes); Collections.sort(ordered, new java.util.Comparator() { @@ -1616,14 +1714,25 @@ public int compare(Route a, Route b) { sb.append(" private static final String[] A").append(i).append(" = ") .append(stringArrayLiteral(route.after)).append(";\n"); } + sb.append(" private static final com.codename1.backend.web.HandlerMethod H").append(i) + .append(" = new com.codename1.backend.web.HandlerMethod(").append(quote(c.binaryName)) + .append(", ").append(quote(route.javaMethod)).append(", ") + .append(quote(route.httpMethod)).append(", ").append(quote(route.pattern)) + .append(");\n"); } // The SOURCE name, qualified: the router sits in the controller's package, // but a member class is Notes.Api there and not Notes$Api, which javac // reads as a top-level identifier it cannot find. - sb.append("\n private final ").append(c.sourceName).append(" impl;\n\n"); + sb.append("\n private final ").append(c.sourceName).append(" impl;\n"); + // The interceptors and global exception handlers, from the wiring; empty + // for a router built on its own. + sb.append(" private final com.codename1.backend.web.ControllerHooks cn1Hooks;\n\n"); + sb.append(" public ").append(c.routerSimpleName).append("(").append(c.sourceName) + .append(" impl) {\n this(impl, com.codename1.backend.web.ControllerHooks.none());\n }\n\n"); sb.append(" public ").append(c.routerSimpleName).append("(").append(c.sourceName) - .append(" impl) {\n this.impl = impl;\n }\n\n"); + .append(" impl, com.codename1.backend.web.ControllerHooks hooks) {\n") + .append(" this.impl = impl;\n this.cn1Hooks = hooks;\n }\n\n"); // throws Exception because Handler does: a controller method that reads a // database throws IOException, and a router that could not pass it on would @@ -1666,7 +1775,7 @@ public int compare(Route a, Route b) { current = route.httpMethod; open = true; } - emitRoute(sb, route, i, c); + emitMatch(sb, route, i, local); } if (open) { sb.append(" }\n"); @@ -1674,13 +1783,45 @@ public int compare(Route a, Route b) { sb.append(" // No route here. Returning null lets the server answer 404, and\n"); sb.append(" // lets another router be tried first when several are chained.\n"); sb.append(" return null;\n }\n\n"); + // Each route's binding and call, in a method of its own so the hooks can + // run it in the middle of the interceptors and exception handlers. + sb.append(" public com.codename1.backend.HttpServer.Response proceed(int route,\n") + .append(" com.codename1.backend.HttpServer.Request request, String[] bound)\n") + .append(" throws Exception {\n switch (route) {\n"); + for (int i = 0; i < ordered.size(); i++) { + sb.append(" case ").append(i).append(": return cn1Route").append(i) + .append("(request, bound);\n"); + } + sb.append(" default: return null;\n }\n }\n\n"); + for (int i = 0; i < ordered.size(); i++) { + Route route = ordered.get(i); + sb.append(" // ").append(route.httpMethod).append(' ').append(route.pattern) + .append(" -> ").append(route.javaMethod).append('\n'); + sb.append(" private com.codename1.backend.HttpServer.Response cn1Route").append(i) + .append("(\n com.codename1.backend.HttpServer.Request request, ") + .append("String[] bound) throws Exception {\n"); + emitRoute(sb, route, i, c); + sb.append(" }\n\n"); + } + if (local) { + sb.append(" public com.codename1.backend.HttpServer.Response resolve(\n") + .append(" com.codename1.backend.HttpServer.Request request,\n") + .append(" com.codename1.backend.web.HandlerMethod handler,\n") + .append(" Exception failure) throws Exception {\n"); + sb.append(BackendExceptionHandlers.dispatch(c.handlers, " ")); + sb.append(" }\n\n"); + } emitRouterHelpers(sb); sb.append("}\n"); return sb.toString(); } - private static void emitRoute(StringBuilder sb, Route route, int index, Controller c) { + /// The test for one route inside handle(), and the hand-off to its method: + /// straight to it while there is nothing to run around it, otherwise through + /// the hooks. + private static void emitMatch(StringBuilder sb, Route route, int index, boolean local) { String pad = " "; + String bound = "null"; if (route.after.isEmpty()) { sb.append(" if (request.pathIs(P").append(index).append(")) {\n"); } else { @@ -1689,7 +1830,25 @@ private static void emitRoute(StringBuilder sb, Route route, int index, Controll .append(".length), A").append(index).append(");\n"); sb.append(pad).append("if (bound != null) {\n"); pad = " "; + bound = "bound"; + } + String hooked = "cn1Hooks.invoke(request, H" + index + ", this, " + index + ", " + bound + + ", " + (local ? "this" : "null") + ")"; + if (local || route.validates) { + sb.append(pad).append("return ").append(hooked).append(";\n"); + } else { + sb.append(pad).append("return cn1Hooks.isEmpty() ? cn1Route").append(index) + .append("(request, ").append(bound).append(")\n").append(pad).append(" : ") + .append(hooked).append(";\n"); } + if (!route.after.isEmpty()) { + sb.append(" }\n"); + } + sb.append(" }\n"); + } + + private static void emitRoute(StringBuilder sb, Route route, int index, Controller c) { + String pad = " "; // The TEMPLATE names the span, never the path: /pets/7 and /pets/8 are // one operation, and a trace backend grouping by path would give every id // its own. Emitted in EVERY build, not only a traced one: a program can @@ -1730,8 +1889,18 @@ private static void emitRoute(StringBuilder sb, Route route, int index, Controll sb.append(pad).append("final Object cn1Principal = ").append(SECURITY_SUPPORT) .append(".principal();\n"); } - args.append(argumentExpression(p)); + if (p.validation != null) { + // Bound once, into a local the checks and the call share. + String type = BackendJsonCodecs.source(p.genericJavaType != null + && "BODY".equals(p.kind) ? p.genericJavaType : p.javaType); + sb.append(pad).append("final ").append(type).append(" cn1Arg").append(i) + .append(" = ").append(argumentExpression(p)).append(";\n"); + args.append("cn1Arg").append(i); + } else { + args.append(argumentExpression(p)); + } } + emitValidation(sb, route, pad); String call = "impl." + route.javaMethod + "(" + args + ")"; if (route.view) { @@ -1776,11 +1945,51 @@ private static void emitRoute(StringBuilder sb, Route route, int index, Controll sb.append(pad).append(" : request.respondJson(").append(route.status) .append(", result);\n"); } + } - if (!route.after.isEmpty()) { - sb.append(" }\n"); + /// The checks of every constrained parameter, before the call. A form + /// followed by a BindingResult reports into it, as Spring does, and the + /// method decides; every other failure is a ValidationException, thrown so + /// an exception handler can shape the answer. + private static void emitValidation(StringBuilder sb, Route route, String pad) { + if (!route.validates) { + return; + } + String violations = BackendValidation.VIOLATIONS; + for (int i = 0; i < route.params.size(); i++) { + Param p = route.params.get(i); + boolean toBindingResult = p.validation != null && "FORM".equals(p.kind) + && i + 1 < route.params.size() && "ERRORS".equals(route.params.get(i + 1).kind); + if (toBindingResult) { + sb.append(pad).append("{\n"); + sb.append(pad).append(" ").append(violations) + .append(" cn1Violations = new ").append(violations).append("();\n"); + appendIndented(sb, p.validation, pad + " "); + sb.append(pad).append(" for (com.codename1.backend.validation.FieldError cn1Error" + + " : cn1Violations.errors()) {\n"); + sb.append(pad).append(" ").append(p.local).append("Errors.rejectValue(" + + "cn1Error.getField(), cn1Error.getMessage());\n"); + sb.append(pad).append(" }\n"); + sb.append(pad).append("}\n"); + } + } + boolean declared = false; + for (int i = 0; i < route.params.size(); i++) { + Param p = route.params.get(i); + if (p.validation == null || "FORM".equals(p.kind) && i + 1 < route.params.size() + && "ERRORS".equals(route.params.get(i + 1).kind)) { + continue; + } + if (!declared) { + declared = true; + sb.append(pad).append(violations).append(" cn1Violations = new ") + .append(violations).append("();\n"); + } + appendIndented(sb, p.validation, pad); + } + if (declared) { + sb.append(pad).append("cn1Violations.check();\n"); } - sb.append(" }\n"); } /** diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index abdfd20e67c..c7930472e1c 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -3122,6 +3122,78 @@ public void securityParametersAndChainsAreCheckedAtBuildTime() throws Exception // ------------------------------------------------------------------ helpers + /// #5993 end to end: an interceptor bean and a global advice wired by the + /// build, running INSIDE the security chain -- a request it refuses meets + /// neither, and a method-security denial passes a catch-all handler + /// untouched on its way back to the chain's 403. + @Test + public void interceptorsAndAdviceRunInsideTheSecurityChain() throws Exception { + Map s = secured(); + s.put("com.example.Guarded", SECURED + + "@RestController public class Guarded {\n" + + " @GetMapping(\"/api/hello\") public String hello() { return \"hello\"; }\n" + + " @GetMapping(\"/api/boom\") public String boom() { throw new IllegalStateException(\"boom\"); }\n" + + " @PreAuthorize(\"hasRole('ADMIN')\") @GetMapping(\"/api/admin\")\n" + + " public String admin() { return \"admin\"; }\n" + + "}\n"); + s.put("com.example.Audit", SECURED + + "import com.codename1.backend.web.*;\n" + + "@Component public class Audit implements HandlerInterceptor {\n" + + " public static final List LOG = new java.util.ArrayList();\n" + + " public HttpServer.Response preHandle(HttpServer.Request r, HandlerMethod h) {\n" + + " LOG.add(\"pre \" + h.getMethodName()); return null;\n" + + " }\n" + + " public void afterCompletion(HttpServer.Request r, HandlerMethod h,\n" + + " HttpServer.Response response, Throwable failure) {\n" + + " LOG.add(\"after \" + h.getMethodName() + \" \" + (failure == null ? \"ok\"\n" + + " : failure.getClass().getSimpleName()));\n" + + " }\n" + + "}\n"); + s.put("com.example.Errors", SECURED + + "@RestControllerAdvice public class Errors {\n" + + " @ExceptionHandler(Exception.class) @ResponseStatus(418)\n" + + " public String any(Exception e) { return \"handled:\" + e.getMessage(); }\n" + + "}\n"); + File classes = compile(s); + RestControllerAnnotationProcessor proc = new RestControllerAnnotationProcessor(); + proc.setDevTools(false); + assertNoErrors(process(classes, proc)); + String wiring = proc.generateWiring("com.example"); + assertTrue(wiring, wiring.contains("new com.codename1.backend.web.HandlerInterceptor[] {" + + "(com.codename1.backend.web.HandlerInterceptor) b_audit}")); + assertTrue(wiring, wiring.contains("new com.example.ErrorsCn1Advice(")); + + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + @SuppressWarnings("unchecked") + List log = (List) loader.loadClass("com.example.Audit").getField("LOG") + .get(null); + BackendApplication app = (BackendApplication) loader + .loadClass("com.example.BackendWiring").newInstance(); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend.Builder builder = withApplication(Backend.builder(Config.of(settings, "dev")).quiet(), app); + BackendAccess.get().security(builder); + Backend backend = builder.start(); + try { + assertEquals("HTTP 401: Unauthorized", call(port, "/api/hello", null, null)); + assertTrue("security refused it, so no interceptor ran: " + log, log.isEmpty()); + assertEquals("hello", call(port, "/api/hello", "ada", "ada-pw")); + assertEquals(Arrays.asList("pre hello", "after hello ok"), log); + log.clear(); + assertEquals("HTTP 418: handled:boom", call(port, "/api/boom", "ada", "ada-pw")); + assertEquals(Arrays.asList("pre boom", "after boom ok"), log); + log.clear(); + String denied = call(port, "/api/admin", "ada", "ada-pw"); + assertTrue("a catch-all handler must not answer a denial: " + denied, + denied.startsWith("HTTP 403")); + assertEquals(Arrays.asList("pre admin", "after admin AccessDeniedException"), log); + } finally { + backend.stop(); + } + } + private Backend start(File classes, int port, Properties settings) throws Exception { URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java index bac4665b19c..e9d3055f604 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/RestControllerAnnotationProcessorTest.java @@ -2102,6 +2102,269 @@ private static synchronized void reflect() throws Exception { } } + // ------------------------------------------------ exception handlers (#5993) + + private static final String HANDLED_SOURCE = + "package com.example;\n" + + "import com.codename1.backend.annotations.*;\n" + + "import com.codename1.backend.validation.*;\n" + + "import java.util.*;\n" + + "@RestController\n" + + "public class Notes {\n" + + " public static class Addr { @NotBlank @Size(min=5, max=5) public String zip; }\n" + + " public static class Item {\n" + + " @NotBlank public String name;\n" + + " @Min(1) @Max(9) public int count;\n" + + " @Valid public Addr addr;\n" + + " @Valid public List more;\n" + + " }\n" + + " public final List log = new ArrayList();\n" + + " @GetMapping(\"/iae\") public String iae() { throw new IllegalArgumentException(\"bad\"); }\n" + + " @GetMapping(\"/nfe\") public String nfe() { throw new NumberFormatException(\"nan\"); }\n" + + " @GetMapping(\"/io\") public String io() throws java.io.IOException {\n" + + " throw new java.io.IOException(\"disk\");\n" + + " }\n" + + " @GetMapping(\"/denied\") public String denied() {\n" + + " throw new com.codename1.backend.security.AccessDeniedException(\"no\");\n" + + " }\n" + + " @PostMapping(\"/items\") public Map create(@Valid @RequestBody Item item) {\n" + + " log.add(\"created \" + item.name);\n" + + " return Collections.singletonMap(\"name\", item.name);\n" + + " }\n" + + " @GetMapping(\"/page\") public String page(@RequestParam(\"n\") @Min(1) int n,\n" + + " @RequestParam(value=\"q\", required=false) @Size(max=3) String q) {\n" + + " return \"page \" + n;\n" + + " }\n" + + " @ExceptionHandler(IllegalArgumentException.class)\n" + + " @ResponseStatus(422)\n" + + " public Map onIae(IllegalArgumentException e) {\n" + + " return Collections.singletonMap(\"error\", \"iae:\" + e.getMessage());\n" + + " }\n" + + " @ExceptionHandler\n" + + " @ResponseStatus(409)\n" + + " public String onSecurity(SecurityException e, com.codename1.backend.web.HandlerMethod h) {\n" + + " return \"security:\" + h.getMethodName();\n" + + " }\n" + + "}\n"; + + private static final String ADVICE_SOURCE = + "package com.example;\n" + + "import com.codename1.backend.annotations.*;\n" + + "import com.codename1.backend.HttpServer;\n" + + "@ControllerAdvice\n" + + "public class Other {\n" + + " @ExceptionHandler({java.io.IOException.class, Exception.class})\n" + + " public HttpServer.Response any(Exception e, HttpServer.Request request) {\n" + + " return HttpServer.Response.text(503, \"advice:\" + e.getMessage())\n" + + " .header(\"Retry-After\", \"5\");\n" + + " }\n" + + "}\n"; + + /// A router for HANDLED_SOURCE with the given hooks, and the controller. + private Object[] handledRouter(File classes, com.codename1.backend.web.ControllerHooks hooks) + throws Exception { + ProcessorContext ctx = run(classes); + if (ctx.hasErrors()) { + fail("processor reported errors: " + ctx.getErrors()); + } + URLClassLoader loader = new URLClassLoader(new URL[]{ classes.toURI().toURL() }, + getClass().getClassLoader()); + Class controller = loader.loadClass("com.example.Notes"); + Class router = loader.loadClass("com.example.NotesRouter"); + Object impl = controller.newInstance(); + Object instance = router.getConstructor(controller, + com.codename1.backend.web.ControllerHooks.class).newInstance(impl, hooks); + return new Object[] {new Router(instance, router.getMethod("handle", + HttpServer.Request.class)), impl, loader}; + } + + @Test + public void controllerExceptionHandlersPickTheClosestType() throws Exception { + File classes = compile(HANDLED_SOURCE); + Router router = (Router) handledRouter(classes, + com.codename1.backend.web.ControllerHooks.none())[0]; + Object iae = router.call("GET", "/iae", null); + assertEquals("the exact type wins over its superclass", 422, Router.statusOf(iae)); + assertEquals("{\"error\":\"iae:bad\"}", Router.bodyOf(iae)); + Object nfe = router.call("GET", "/nfe", null); + assertEquals("a subclass with no handler of its own goes to the closest one", + 422, Router.statusOf(nfe)); + assertEquals("{\"error\":\"iae:nan\"}", Router.bodyOf(nfe)); + try { + router.call("GET", "/io", null); + fail("nothing handles an IOException here, so it must reach the server"); + } catch (java.lang.reflect.InvocationTargetException err) { + assertTrue(err.getCause() instanceof java.io.IOException); + } + } + + @Test + public void securityFailuresAreNeverOfferedToAHandler() throws Exception { + // onSecurity handles java.lang.SecurityException; AccessDeniedException + // is not one, but a global Exception handler would match it -- see + // globalAdviceAnswersWhatTheControllerDoesNot. + File classes = compile(HANDLED_SOURCE); + Router router = (Router) handledRouter(classes, + com.codename1.backend.web.ControllerHooks.none())[0]; + try { + router.call("GET", "/denied", null); + fail("an AccessDeniedException must reach the security chain"); + } catch (java.lang.reflect.InvocationTargetException err) { + assertTrue(err.getCause() instanceof com.codename1.backend.security.AccessDeniedException); + } + } + + @Test + public void globalAdviceAnswersWhatTheControllerDoesNot() throws Exception { + File classes = tmp.newFolder(); + Map sources = new LinkedHashMap(); + sources.put("com.example.Notes", HANDLED_SOURCE); + sources.put("com.example.Other", ADVICE_SOURCE); + JavaSourceCompiler.compile(sources, classes, backendClasspath()); + ProcessorContext ctx = run(classes); + assertFalse(String.valueOf(ctx.getErrors()), ctx.hasErrors()); + URLClassLoader loader = new URLClassLoader(new URL[]{ classes.toURI().toURL() }, + getClass().getClassLoader()); + Class advice = loader.loadClass("com.example.Other"); + Object resolver = loader.loadClass("com.example.OtherCn1Advice") + .getConstructor(advice).newInstance(advice.newInstance()); + com.codename1.backend.web.ControllerHooks hooks = new com.codename1.backend.web.ControllerHooks( + new com.codename1.backend.web.HandlerInterceptor[0], + new com.codename1.backend.web.ExceptionResolver[] { + (com.codename1.backend.web.ExceptionResolver) resolver}); + Router router = (Router) handledRouter(classes, hooks)[0]; + Object io = router.call("GET", "/io", null); + assertEquals(503, Router.statusOf(io)); + assertEquals("advice:disk", Router.bodyOf(io)); + assertEquals("the controller's own handler still comes first", + 422, Router.statusOf(router.call("GET", "/iae", null))); + try { + router.call("GET", "/denied", null); + fail("not even a global Exception handler sees a security failure"); + } catch (java.lang.reflect.InvocationTargetException err) { + assertTrue(err.getCause() instanceof com.codename1.backend.security.AccessDeniedException); + } + } + + @Test + public void validationRejectsBeforeTheMethodRuns() throws Exception { + File classes = compile(HANDLED_SOURCE); + Object[] built = handledRouter(classes, com.codename1.backend.web.ControllerHooks.none()); + Router router = (Router) built[0]; + List log = (List) built[1].getClass().getField("log").get(built[1]); + Object ok = router.call("POST", "/items", + "{\"name\":\"pen\",\"count\":2,\"addr\":{\"zip\":\"12345\"}}"); + assertEquals(200, Router.statusOf(ok)); + assertEquals(1, log.size()); + Object bad = router.call("POST", "/items", + "{\"name\":\" \",\"count\":0,\"addr\":{\"zip\":\"12\"}," + + "\"more\":[{\"zip\":\"12345\"},{\"zip\":\"\"}]}"); + assertEquals(400, Router.statusOf(bad)); + String body = Router.bodyOf(bad); + assertTrue(body, body.contains("\"field\":\"name\",\"code\":\"NotBlank\"")); + assertTrue(body, body.contains("\"field\":\"count\",\"code\":\"Min\"")); + assertTrue(body, body.contains("\"field\":\"addr.zip\",\"code\":\"Size\"")); + assertTrue(body, body.contains("\"field\":\"more[1].zip\",\"code\":\"NotBlank\"")); + assertFalse(body, body.contains("more[0]")); + assertEquals("the method never ran for invalid input", 1, log.size()); + + assertEquals("page 2", router.text("GET", "/page?n=2")); + Object low = router.call("GET", "/page?n=0&q=toolong", null); + assertEquals(400, Router.statusOf(low)); + String lowBody = Router.bodyOf(low); + assertTrue(lowBody, lowBody.contains("\"field\":\"n\",\"code\":\"Min\"")); + assertTrue(lowBody, lowBody.contains("\"field\":\"q\",\"code\":\"Size\"")); + } + + @Test + public void aConstraintTheTypeCannotCarryFailsTheBuild() throws Exception { + ProcessorContext ctx = run(compile( + "package com.example;\n" + + "import com.codename1.backend.annotations.*;\n" + + "import com.codename1.backend.validation.*;\n" + + "@RestController\n" + + "public class Notes {\n" + + " @GetMapping(\"/x\") public String x(@RequestParam(\"q\") @Min(1) String q) {\n" + + " return q;\n" + + " }\n" + + "}\n")); + assertTrue(ctx.hasErrors()); + assertTrue(String.valueOf(ctx.getErrors()), + String.valueOf(ctx.getErrors()).contains("@Min applies to an integral number")); + } + + /// Records every call into a log shared with the other recorders. + public static final class Recorder implements com.codename1.backend.web.HandlerInterceptor { + final List calls; + final String name; + final boolean refuse; + + Recorder(List calls, String name, boolean refuse) { + this.calls = calls; + this.name = name; + this.refuse = refuse; + } + + @Override + public HttpServer.Response preHandle(HttpServer.Request request, + com.codename1.backend.web.HandlerMethod handler) { + calls.add(name + ".pre " + handler.getPattern()); + return refuse ? HttpServer.Response.text(401, "refused") : null; + } + + @Override + public void postHandle(HttpServer.Request request, + com.codename1.backend.web.HandlerMethod handler, HttpServer.Response response) { + calls.add(name + ".post"); + } + + @Override + public void afterCompletion(HttpServer.Request request, + com.codename1.backend.web.HandlerMethod handler, HttpServer.Response response, + Throwable failure) { + calls.add(name + ".after " + (failure == null ? "-" : failure.getClass().getSimpleName())); + } + } + + @Test + public void interceptorsCompleteOnEveryOutcome() throws Exception { + File classes = compile(HANDLED_SOURCE); + List calls = new java.util.ArrayList(); + com.codename1.backend.web.ControllerHooks hooks = new com.codename1.backend.web.ControllerHooks( + new com.codename1.backend.web.HandlerInterceptor[] { + new Recorder(calls, "a", false), null, new Recorder(calls, "b", false)}, + new com.codename1.backend.web.ExceptionResolver[0]); + Router router = (Router) handledRouter(classes, hooks)[0]; + + router.call("GET", "/page?n=1", null); + assertEquals(Arrays.asList("a.pre /page", "b.pre /page", "b.post", "a.post", + "b.after -", "a.after -"), calls); + + calls.clear(); + assertEquals(400, Router.statusOf(router.call("GET", "/page?n=0", null))); + assertEquals("a validation failure is handled, so no postHandle, but cleanup runs", + Arrays.asList("a.pre /page", "b.pre /page", "b.after -", "a.after -"), calls); + + calls.clear(); + try { + router.call("GET", "/io", null); + fail("nothing handles the IOException"); + } catch (java.lang.reflect.InvocationTargetException expected) { + assertTrue(expected.getCause() instanceof java.io.IOException); + } + assertEquals(Arrays.asList("a.pre /io", "b.pre /io", "b.after IOException", + "a.after IOException"), calls); + + calls.clear(); + Router refused = (Router) handledRouter(classes, new com.codename1.backend.web.ControllerHooks( + new com.codename1.backend.web.HandlerInterceptor[] {new Recorder(calls, "a", false), + new Recorder(calls, "gate", true), new Recorder(calls, "c", false)}, + new com.codename1.backend.web.ExceptionResolver[0]))[0]; + assertEquals(401, Router.statusOf(refused.call("GET", "/page?n=1", null))); + assertEquals("only the interceptors before the refusal complete; nothing after it runs", + Arrays.asList("a.pre /page", "gate.pre /page", "a.after -"), calls); + } + private Router generate(String controllerSource) throws Exception { File classes = compile(controllerSource); ProcessorContext ctx = run(classes); diff --git a/vm/backend/src/com/codename1/backend/annotations/ControllerAdvice.java b/vm/backend/src/com/codename1/backend/annotations/ControllerAdvice.java new file mode 100644 index 00000000000..7bd12ddc705 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ControllerAdvice.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A bean whose [ExceptionHandler] methods apply to every controller. +/// +/// Several are asked in [Order] order, the unordered ones last by bean name, and +/// only after the failing controller's own handlers declined. The build writes +/// the dispatch to each handler as code; nothing is found by reflection. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface ControllerAdvice { + /// The bean name; empty for the default. + String value() default ""; + + /// Limits the advice to controllers in these packages and their + /// subpackages; empty for every controller. + String[] basePackages() default {}; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/ExceptionHandler.java b/vm/backend/src/com/codename1/backend/annotations/ExceptionHandler.java new file mode 100644 index 00000000000..75168301bb3 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/ExceptionHandler.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks a method that answers for an exception a controller method threw. +/// +/// On a `@RestController` or `@Controller` it handles that controller's +/// routes only, and is consulted first; on a [ControllerAdvice] bean it handles +/// every controller's. Among the handlers of one class, the one naming the +/// closest superclass of the thrown exception is chosen. The order, and the +/// security failures no handler ever sees, are described on +/// `com.codename1.backend.web.ControllerHooks`. +/// +/// ```java +/// @ExceptionHandler(NotFound.class) +/// @ResponseStatus(404) +/// public Map notFound(NotFound e) { +/// return Collections.singletonMap("error", e.getMessage()); +/// } +/// ``` +/// +/// The method is public and may take, in any order, the exception (any type +/// the declared exceptions are assignable to), the `HttpServer.Request` and the +/// `com.codename1.backend.web.HandlerMethod`. It returns an `HttpServer.Response` +/// -- complete control of status, headers and body -- or a `String` (text), a +/// `Map`, a `List` or a `Json.Writable` (JSON), or nothing; any of those but a +/// Response is sent with [ResponseStatus]'s status, 200 without one, as in +/// Spring. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface ExceptionHandler { + /// The exceptions handled. Empty means the method's exception parameter's type. + Class[] value() default {}; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/RestControllerAdvice.java b/vm/backend/src/com/codename1/backend/annotations/RestControllerAdvice.java new file mode 100644 index 00000000000..fd868746a09 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/annotations/RestControllerAdvice.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// The same as [ControllerAdvice]; the name Spring applications use for advice +/// whose handlers answer with data rather than views. +/// +/// A bean whose [ExceptionHandler] methods apply to every controller. +/// +/// Several are asked in [Order] order, the unordered ones last by bean name, and +/// only after the failing controller's own handlers declined. The build writes +/// the dispatch to each handler as code; nothing is found by reflection. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface RestControllerAdvice { + /// The bean name; empty for the default. + String value() default ""; + + /// Limits the advice to controllers in these packages and their + /// subpackages; empty for every controller. + String[] basePackages() default {}; +} diff --git a/vm/backend/src/com/codename1/backend/validation/FieldError.java b/vm/backend/src/com/codename1/backend/validation/FieldError.java new file mode 100644 index 00000000000..35d8ec1864e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/validation/FieldError.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.validation; + +/// One constraint one value broke. +public final class FieldError implements java.io.Serializable { + private final String field; + private final String code; + private final String message; + + /// @param field the path of the value, such as `address.zip` or `items[2].name`; + /// for a request parameter, its name + /// @param code the constraint, such as `NotBlank` or `Size` + /// @param message what to tell the caller + public FieldError(String field, String code, String message) { + this.field = field == null ? "" : field; + this.code = code == null ? "" : code; + this.message = message == null ? "" : message; + } + + /// The path of the value that broke the constraint. Stable: it is built from + /// the JSON property names and parameter names, never from Java internals. + public String getField() { + return field; + } + + /// The constraint's simple name, such as `NotBlank`. + public String getCode() { + return code; + } + + /// The message for the caller. + public String getMessage() { + return message; + } + + @Override + public String toString() { + return field + ": " + message; + } +} diff --git a/vm/backend/src/com/codename1/backend/validation/Max.java b/vm/backend/src/com/codename1/backend/validation/Max.java new file mode 100644 index 00000000000..ef73695b5bb --- /dev/null +++ b/vm/backend/src/com/codename1/backend/validation/Max.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// An integral number -- `byte`, `short`, `int`, `long` or their boxes -- +/// that must be at most [#value]. Null is valid. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +public @interface Max { + /// The largest value allowed. + long value(); + + /// The message reported for the field; empty for the default. + String message() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/validation/Min.java b/vm/backend/src/com/codename1/backend/validation/Min.java new file mode 100644 index 00000000000..ca99d7f0d93 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/validation/Min.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// An integral number -- `byte`, `short`, `int`, `long` or their boxes -- +/// that must be at least [#value]. Null is valid. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +public @interface Min { + /// The smallest value allowed. + long value(); + + /// The message reported for the field; empty for the default. + String message() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/validation/NotBlank.java b/vm/backend/src/com/codename1/backend/validation/NotBlank.java new file mode 100644 index 00000000000..c7cebe9a869 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/validation/NotBlank.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A `String` or other `CharSequence` that must hold at least one character +/// that is not whitespace. Null is a violation. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +public @interface NotBlank { + /// The message reported for the field; empty for the default. + String message() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/validation/NotEmpty.java b/vm/backend/src/com/codename1/backend/validation/NotEmpty.java new file mode 100644 index 00000000000..5c1aa89fbb9 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/validation/NotEmpty.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A `CharSequence`, collection, map or array that must hold at least one +/// element. Null is a violation. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +public @interface NotEmpty { + /// The message reported for the field; empty for the default. + String message() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/validation/NotNull.java b/vm/backend/src/com/codename1/backend/validation/NotNull.java new file mode 100644 index 00000000000..469aaabfc07 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/validation/NotNull.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// The value must not be null. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +public @interface NotNull { + /// The message reported for the field; empty for the default. + String message() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/validation/Size.java b/vm/backend/src/com/codename1/backend/validation/Size.java new file mode 100644 index 00000000000..fd0e33f98f0 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/validation/Size.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// The length of a `CharSequence`, or the size of a collection, map or array, +/// must lie between [#min] and [#max], inclusive. Null is valid; add [NotNull] to +/// refuse it. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +public @interface Size { + /// The smallest size allowed. + int min() default 0; + + /// The largest size allowed. + int max() default Integer.MAX_VALUE; + + /// The message reported for the field; empty for the default. + String message() default ""; +} diff --git a/vm/backend/src/com/codename1/backend/validation/Valid.java b/vm/backend/src/com/codename1/backend/validation/Valid.java new file mode 100644 index 00000000000..e085117df2f --- /dev/null +++ b/vm/backend/src/com/codename1/backend/validation/Valid.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Validates the annotated value's own constraints. +/// +/// On a `@RequestBody` or `@ModelAttribute` parameter it asks the build to check +/// the bound object before the controller method runs; on a field of such an +/// object it descends into the field's value -- each element of a collection +/// or array -- and reports its violations under the field's path +/// (`address.zip`, `items[2].name`). The checks are generated code, so the +/// classes need no reflection at run time. +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +public @interface Valid { +} diff --git a/vm/backend/src/com/codename1/backend/validation/ValidationException.java b/vm/backend/src/com/codename1/backend/validation/ValidationException.java new file mode 100644 index 00000000000..f4f31f785b2 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/validation/ValidationException.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.validation; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.ResponseStatusException; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// A request whose input broke its declared constraints. +/// +/// Thrown by the generated router before the controller method runs, so nothing +/// the method does happens for invalid input. An `@ExceptionHandler` for it may +/// shape the answer; unhandled, the request is answered 400 with +/// +/// ```json +/// {"status":400,"error":"Bad Request", +/// "errors":[{"field":"address.zip","code":"Size","message":"size must be between 5 and 5"}]} +/// ``` +/// +/// An application may throw it too, for a check no annotation expresses. +public class ValidationException extends ResponseStatusException { + private final FieldError[] errors; + + /// @param errors what failed; at least one + public ValidationException(List errors) { + super(400, "validation failed"); + this.errors = errors.toArray(new FieldError[errors.size()]); + } + + /// Every constraint that failed, in the order the checks ran. + /// + /// @return the errors, never empty + public List getFieldErrors() { + return Collections.unmodifiableList(Arrays.asList(errors)); + } + + /// The errors for one path. + /// + /// @param field the path + /// @return its errors, possibly empty + public List getFieldErrors(String field) { + List out = new ArrayList(); + for (FieldError e : errors) { + if (e.getField().equals(field)) { + out.add(e); + } + } + return out; + } + + /// The default answer: 400 with the errors as JSON. + /// + /// @param request the request being answered + /// @return the response + public HttpServer.Response toResponse(HttpServer.Request request) { + Map body = new LinkedHashMap(); + body.put("status", Integer.valueOf(400)); + body.put("error", "Bad Request"); + List list = new ArrayList(); + for (FieldError e : errors) { + Map m = new LinkedHashMap(); + m.put("field", e.getField()); + m.put("code", e.getCode()); + m.put("message", e.getMessage()); + list.add(m); + } + body.put("errors", list); + return request.respondJson(400, body); + } +} diff --git a/vm/backend/src/com/codename1/backend/validation/Violations.java b/vm/backend/src/com/codename1/backend/validation/Violations.java new file mode 100644 index 00000000000..8d95f597459 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/validation/Violations.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.validation; + +import java.util.ArrayList; +import java.util.List; + +/// Collects the [FieldError]s of one request. The generated validation code +/// calls it; an application has no reason to. +public final class Violations { + private List errors; + + /// Records one failure. + /// + /// @param field the path + /// @param code the constraint's simple name + /// @param message the declared message, or empty for `fallback` + /// @param fallback the default message + public void add(String field, String code, String message, String fallback) { + if (errors == null) { + errors = new ArrayList(); + } + errors.add(new FieldError(field, code, + message == null || message.length() == 0 ? fallback : message)); + } + + /// What was recorded so far. + /// + /// @return the errors, possibly empty + public List errors() { + return errors == null ? new ArrayList() : errors; + } + + /// Throws a [ValidationException] when anything was recorded. + public void check() { + if (errors != null) { + throw new ValidationException(errors); + } + } + + /// `prefix.name`, or `name` at the root. + /// + /// @param prefix the parent path, empty at the root + /// @param name the property + /// @return the path + public static String path(String prefix, String name) { + return prefix.length() == 0 ? name : prefix + "." + name; + } + + /// `prefix[index]`. + /// + /// @param prefix the collection's path + /// @param index the element + /// @return the path + public static String index(String prefix, int index) { + return prefix + "[" + index + "]"; + } + + /// Whether `s` is null or only whitespace. + /// + /// @param s the value + /// @return true when blank + public static boolean isBlank(CharSequence s) { + if (s == null) { + return true; + } + for (int i = 0; i < s.length(); i++) { + if (!Character.isWhitespace(s.charAt(i))) { + return false; + } + } + return true; + } +} diff --git a/vm/backend/src/com/codename1/backend/validation/package-info.java b/vm/backend/src/com/codename1/backend/validation/package-info.java new file mode 100644 index 00000000000..ea51c3faf75 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/validation/package-info.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Build-time checked constraints on controller input. +/// +/// Put [Valid] on a `@RequestBody` or `@ModelAttribute` parameter, or a +/// constraint such as [NotBlank], [Size], [Min] or [Max] directly on a +/// `@RequestParam`, `@PathVariable` or `@RequestHeader` parameter, and the +/// generated router checks it before the controller method runs. A failure is a +/// [ValidationException] carrying a [FieldError] per broken constraint, with a +/// stable path for each. +/// +/// The checks are generated Java, not reflection, so they cost what a +/// hand-written `if` costs and survive translation. Regular expressions are not +/// among the constraints, because the backend's class library has none; check +/// such a value in the method and throw a [ValidationException] yourself. +package com.codename1.backend.validation; diff --git a/vm/backend/src/com/codename1/backend/web/ControllerHooks.java b/vm/backend/src/com/codename1/backend/web/ControllerHooks.java new file mode 100644 index 00000000000..1abd2b635dc --- /dev/null +++ b/vm/backend/src/com/codename1/backend/web/ControllerHooks.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.web; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.security.AccessDeniedException; +import com.codename1.backend.security.AuthenticationException; +import com.codename1.backend.validation.ValidationException; + +import java.util.ArrayList; +import java.util.List; + +/// The interceptors and global exception resolvers every generated router runs a +/// matched request through. +/// +/// The generated wiring builds one from the application's +/// [HandlerInterceptor] beans and `@ControllerAdvice` beans and hands it to each +/// router. A router whose controller declares no exception handler and whose +/// route validates nothing skips it entirely while it [#isEmpty], so an +/// application with none of these pays one field read per request. +/// +/// #### Exception handling precedence +/// +/// 1. A failure security owns -- [AccessDeniedException] or +/// [AuthenticationException], CSRF failures included -- and any `Error` +/// are never offered to a handler. They go straight back to the server and +/// the security chain, so a broad `@ExceptionHandler(Exception.class)` cannot +/// turn a refusal into a success. +/// 2. The controller's own `@ExceptionHandler` methods; among those that apply, +/// the one declaring the closest superclass of what was thrown. +/// 3. Each `@ControllerAdvice`, in `@Order` order (unordered ones last, by bean +/// name), each again choosing its closest match. The first that answers wins. +/// 4. A [ValidationException] nothing handled answers 400 with its field errors +/// as JSON. +/// 5. Anything else is rethrown, and the server answers it as before: the +/// status of a `ResponseStatusException`, otherwise a 500 that reveals +/// nothing about the failure. +public final class ControllerHooks { + private static final ControllerHooks NONE = + new ControllerHooks(new HandlerInterceptor[0], new ExceptionResolver[0]); + + /// What a generated router implements: running route `route` of itself. + public interface Invocation { + /// @param route the route's index in the router + /// @param request the request + /// @param bound the path variables, or null for a static route + /// @return the response + /// @throws Exception whatever binding or the controller threw + HttpServer.Response proceed(int route, HttpServer.Request request, String[] bound) + throws Exception; + } + + private final HandlerInterceptor[] interceptors; + private final ExceptionResolver[] resolvers; + + /// Null entries are dropped: a conditional bean that was not created is null + /// in the wiring, and leaving the hole in would fail every request. + /// + /// @param interceptors in the order they run + /// @param resolvers the global resolvers, in the order they are asked + public ControllerHooks(HandlerInterceptor[] interceptors, ExceptionResolver[] resolvers) { + this.interceptors = compact(interceptors, new HandlerInterceptor[0]); + this.resolvers = compact(resolvers, new ExceptionResolver[0]); + } + + /// No interceptors and no global resolvers. + /// + /// @return the shared empty instance + public static ControllerHooks none() { + return NONE; + } + + /// Whether there is nothing to run. + /// + /// @return true with no interceptors and no global resolvers + public boolean isEmpty() { + return interceptors.length == 0 && resolvers.length == 0; + } + + private static T[] compact(T[] in, T[] empty) { + if (in == null) { + return empty; + } + List out = new ArrayList(in.length); + for (T t : in) { + if (t != null) { + out.add(t); + } + } + return out.toArray(empty); + } + + /// Runs one matched route through the interceptors and exception handlers. + /// + /// @param request the request + /// @param handler the route it matched + /// @param call the router + /// @param route the route's index in the router + /// @param bound the path variables, or null + /// @param local the controller's own exception handlers, or null + /// @return the response + /// @throws Exception a failure nothing handled + public HttpServer.Response invoke(HttpServer.Request request, HandlerMethod handler, + Invocation call, int route, String[] bound, ExceptionResolver local) + throws Exception { + int admitted = 0; + HttpServer.Response response = null; + Throwable failure = null; + try { + try { + HttpServer.Response refused = null; + while (admitted < interceptors.length) { + refused = interceptors[admitted].preHandle(request, handler); + if (refused != null) { + break; + } + admitted++; + } + if (refused != null) { + response = refused; + return response; + } + response = call.proceed(route, request, bound); + } catch (Exception err) { + // A handled failure is answered, but it is not the controller's + // answer: postHandle is for a method that returned normally. + response = resolve(request, handler, local, err); + return response; + } + for (int i = admitted - 1; i >= 0; i--) { + interceptors[i].postHandle(request, handler, response); + } + return response; + } catch (Exception err) { + failure = err; + response = null; + throw err; + } catch (Error err) { + failure = err; + response = null; + throw err; + } finally { + for (int i = admitted - 1; i >= 0; i--) { + try { + interceptors[i].afterCompletion(request, handler, response, failure); + } catch (Exception cleanup) { + System.err.println("afterCompletion failed in " + + interceptors[i].getClass().getName() + ": " + cleanup); + } + } + } + } + + /// The precedence the class documentation describes. + private HttpServer.Response resolve(HttpServer.Request request, HandlerMethod handler, + ExceptionResolver local, Exception err) throws Exception { + if (err instanceof AccessDeniedException || err instanceof AuthenticationException) { + throw err; + } + if (local != null) { + HttpServer.Response r = local.resolve(request, handler, err); + if (r != null) { + return r; + } + } + for (ExceptionResolver resolver : resolvers) { + HttpServer.Response r = resolver.resolve(request, handler, err); + if (r != null) { + return r; + } + } + if (err instanceof ValidationException) { + return ((ValidationException) err).toResponse(request); + } + throw err; + } +} diff --git a/vm/backend/src/com/codename1/backend/web/ExceptionResolver.java b/vm/backend/src/com/codename1/backend/web/ExceptionResolver.java new file mode 100644 index 00000000000..299b82d2e6e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/web/ExceptionResolver.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.web; + +import com.codename1.backend.HttpServer; + +/// Turns an exception a controller threw into a response. +/// +/// The build writes one for each controller with `@ExceptionHandler` methods and +/// for each `@ControllerAdvice` bean; an application rarely implements it +/// directly, but may, and register it as a bean like an advice. +public interface ExceptionResolver { + + /// @param request the request that failed + /// @param handler the route it matched + /// @param failure what was thrown + /// @return the response, or null when this resolver does not handle it + /// @throws Exception when handling it failed; answered as a server error + HttpServer.Response resolve(HttpServer.Request request, HandlerMethod handler, + Exception failure) throws Exception; +} diff --git a/vm/backend/src/com/codename1/backend/web/HandlerInterceptor.java b/vm/backend/src/com/codename1/backend/web/HandlerInterceptor.java new file mode 100644 index 00000000000..8db064083ca --- /dev/null +++ b/vm/backend/src/com/codename1/backend/web/HandlerInterceptor.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.web; + +import com.codename1.backend.HttpServer; + +/// Runs around every controller method, for cross-cutting work that is not +/// security: timing, auditing, tenant selection, request-scoped cleanup. +/// +/// Declare one as a bean -- a `@Component` class implementing this interface, or +/// a `@Bean` method returning one -- and the build hands it to every generated +/// router. Several run in `@Order` order, and the ones without an order last in +/// bean-name order. +/// +/// #### Where it runs +/// +/// 1. The security filter chain: authentication, CSRF, the URL rules. A request +/// it refuses never reaches an interceptor. +/// 2. Route selection. Only a request that matched a controller route is +/// intercepted; a 404 is not. +/// 3. [#preHandle], in order. +/// 4. Binding, validation and the controller method, with its method security. +/// 5. Exception handlers, when 3 or 4 threw. +/// 6. [#postHandle], in reverse order, only when the controller method +/// returned normally. +/// 7. [#afterCompletion], in reverse order, for every interceptor whose +/// [#preHandle] let the request through -- whatever happened after it. +/// +/// Because the chain sits inside the security layer, an interceptor cannot +/// admit a request security refused, and the failures security owns -- +/// `AccessDeniedException`, `AuthenticationException` and their subclasses, +/// CSRF included -- pass every interceptor and exception handler untouched on +/// their way back to it. +public interface HandlerInterceptor { + + /// Called before binding. Return null to let the request through, or a + /// response to answer with instead -- the controller method is then not + /// called, and [#afterCompletion] runs only for the interceptors before this + /// one. + /// + /// @param request the request + /// @param handler the route it matched + /// @return null to proceed, or the response to answer with + /// @throws Exception routed to the exception handlers like the controller's own + default HttpServer.Response preHandle(HttpServer.Request request, HandlerMethod handler) + throws Exception { + return null; + } + + /// Called after the controller method returned normally; not after a + /// refusal, and not after an exception, handled or not. + /// + /// @param request the request + /// @param handler the route it matched + /// @param response the response about to be written + /// @throws Exception fails the request + default void postHandle(HttpServer.Request request, HandlerMethod handler, + HttpServer.Response response) throws Exception { + } + + /// Called once the request is finished with, on success, on a refusal by a + /// later interceptor, on a binding or validation failure and on a failure + /// nothing handled. This is the place for cleanup. A throw from here is + /// logged and does not change the response. + /// + /// @param request the request + /// @param handler the route it matched + /// @param response the response, or null when the request failed unhandled + /// @param failure what failed and was not handled, or null + default void afterCompletion(HttpServer.Request request, HandlerMethod handler, + HttpServer.Response response, Throwable failure) { + } +} diff --git a/vm/backend/src/com/codename1/backend/web/HandlerMethod.java b/vm/backend/src/com/codename1/backend/web/HandlerMethod.java new file mode 100644 index 00000000000..cc3ef8168b0 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/web/HandlerMethod.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.web; + +/// The controller method a request was routed to, as interceptors and exception +/// resolvers see it. +/// +/// The build creates one per route, as a constant of the generated router, so +/// reading it costs nothing per request. It names the method rather than holding +/// a reflective handle: nothing in a translated backend can call a method by +/// name, and nothing here needs to. +public final class HandlerMethod { + private final String controllerClass; + private final String methodName; + private final String httpMethod; + private final String pattern; + + /// @param controllerClass the binary name of the controller class + /// @param methodName the name of the handler method + /// @param httpMethod the HTTP method the route is mapped to + /// @param pattern the route template, such as `/notes/{id}` + public HandlerMethod(String controllerClass, String methodName, String httpMethod, + String pattern) { + this.controllerClass = controllerClass; + this.methodName = methodName; + this.httpMethod = httpMethod; + this.pattern = pattern; + } + + /// The binary name of the controller class, such as `com.example.Notes`. + public String getControllerClass() { + return controllerClass; + } + + /// The name of the handler method. + public String getMethodName() { + return methodName; + } + + /// The HTTP method the route is mapped to. A `GET` route also answers `HEAD`, + /// and reports `GET` here when it does. + public String getHttpMethod() { + return httpMethod; + } + + /// The route template the request matched, such as `/notes/{id}`; never the + /// concrete path. + public String getPattern() { + return pattern; + } + + /// Whether the controller class is in `pkg` or one of its subpackages. + /// + /// @param pkg a package name, such as `com.example.api` + /// @return true when the controller is declared there + public boolean isInPackage(String pkg) { + if (pkg == null || pkg.length() == 0) { + return true; + } + return controllerClass.startsWith(pkg) + && (controllerClass.length() == pkg.length() + || controllerClass.charAt(pkg.length()) == '.'); + } + + @Override + public String toString() { + return httpMethod + " " + pattern + " -> " + controllerClass + "." + methodName; + } +} diff --git a/vm/backend/src/com/codename1/backend/web/package-info.java b/vm/backend/src/com/codename1/backend/web/package-info.java new file mode 100644 index 00000000000..bffe696c753 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/web/package-info.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Interception and exception handling around generated controller routes. +/// +/// [HandlerInterceptor] beans run around every controller method, +/// `@ExceptionHandler` methods and `@ControllerAdvice` beans turn exceptions +/// into responses, and [ControllerHooks] documents the order both run in and how +/// they sit inside the security filter chain. Everything is wired at build time; +/// nothing is looked up by name or reflection when a request arrives. +package com.codename1.backend.web;