├── .env ├── .gitignore ├── .idea ├── .gitignore ├── dataSources.xml ├── encodings.xml ├── jsLibraryMappings.xml ├── misc.xml ├── sqldialects.xml ├── uiDesigner.xml └── vcs.xml ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yaml ├── favicon.ico ├── logo.png ├── migrations ├── README ├── drivers │ ├── org.checkerframework.checker.qual.jar │ └── org.postgresql.jdbc.jar ├── environments │ └── development.properties └── scripts │ ├── 20241126081351_create_changelog.sql │ ├── 20241126081352_first_migration.sql │ └── bootstrap.sql ├── mvnw ├── mvnw.cmd ├── pom.xml └── src └── main ├── assembly ├── distribution.xml └── server.sh └── java ├── dev └── mccue │ └── jdk │ └── httpserver │ └── realworld │ ├── Main.java │ └── RealWorldAPI.java └── module-info.java /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DRIVER=org.postgresql.Driver 2 | POSTGRES_URL=jdbc:postgresql:postgres 3 | POSTGRES_USERNAME=postgres 4 | POSTGRES_PASSWORD=postgres -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/**/target/ 4 | !**/src/test/**/target/ 5 | 6 | ### IntelliJ IDEA ### 7 | .idea/modules.xml 8 | .idea/jarRepositories.xml 9 | .idea/compiler.xml 10 | .idea/libraries/ 11 | *.iws 12 | *.iml 13 | *.ipr 14 | 15 | ### Eclipse ### 16 | .apt_generated 17 | .classpath 18 | .factorypath 19 | .project 20 | .settings 21 | .springBeans 22 | .sts4-cache 23 | 24 | ### NetBeans ### 25 | /nbproject/private/ 26 | /nbbuild/ 27 | /dist/ 28 | /nbdist/ 29 | /.nb-gradle/ 30 | build/ 31 | !**/src/main/**/build/ 32 | !**/src/test/**/build/ 33 | 34 | ### VS Code ### 35 | .vscode/ 36 | 37 | ### Mac OS ### 38 | .DS_Store 39 | 40 | data 41 | .env -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/dataSources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | postgresql 6 | true 7 | org.postgresql.Driver 8 | jdbc:postgresql://localhost:5432/postgres 9 | 10 | 11 | 12 | 13 | 14 | 15 | $ProjectFileDir$ 16 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /.idea/sqldialects.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/uiDesigner.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bowbahdoe/jdk-httpserver-realworld/5d395a7a5022dc157ff420d241b623c2361db7de/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/4.0.0-beta-3/apache-maven-4.0.0-beta-3-bin.zip 18 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar 19 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement by contacting the maintainer team 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM maven:3.9.9-amazoncorretto-23 AS build 2 | 3 | COPY src src 4 | COPY pom.xml pom.xml 5 | 6 | RUN mvn clean package assembly:single 7 | 8 | FROM amazoncorretto:23 9 | 10 | COPY --from=build target/server-distribution ./server-distribution 11 | 12 | ENTRYPOINT ["sh", "server-distribution/bin/server.sh"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 RealWorld, 2024 Ethan McCue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](logo.png) 2 | 3 | > ### JDK HTTP Server codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 4 | 5 | This codebase was created to demonstrate a fully fledged fullstack application built with the JDK HTTP Server including CRUD operations, authentication, routing, pagination, and more. 6 | 7 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 8 | 9 | This is deployed [here](https://jdk-httpserver-realworld.onrender.com/) 10 | 11 | # How it works 12 | 13 | This is built up of a few components. Primarily 14 | 15 | * The [`jdk.httpserver`](https://docs.oracle.com/en/java/javase/22/docs/api/jdk.httpserver/module-summary.html) module which provides the API that is programmed against 16 | * [Jetty](https://github.com/jetty/jetty.project) which provides the actual backing implementation for `jdk.httpserver` 17 | * [Postgresql](https://postgresql.org) for the database 18 | * [RainbowGum](https://github.com/jstachio/rainbowgum) for logging 19 | 20 | Then, serving specific tasks: 21 | 22 | * [dev.mccue.jdk.httpserver](https://github.com/bowbahdoe/jdk-httpserver) for providing a `Body` abstraction 23 | * [dev.mccue.jdk.httpserver.regexrouter](https://github.com/bowbahdoe/jdk-httpserver-regexrouter) for basic request routing 24 | * [dev.mccue.json](https://github.com/bowbahdoe/json) for reading and writing JSON 25 | * [dev.mccue.jdk.httpserver.json](https://github.com/bowbahdoe/jdk-httpserver-json) for using JSON as a `Body` and reading it from `HttpExchange`s 26 | * [dev.mccue.urlparameters](https://github.com/bowbahdoe/urlparameters) for parsing query params 27 | * [dev.mccue.jdbc](https://github.com/bowbahdoe/jdbc) for `UncheckedSQLException` and `SQLFragment` 28 | * [io.github.cdimascio.dotenv.java](https://github.com/cdimascio/dotenv-java) for local development `.env` files 29 | * [slugify](https://github.com/slugify/slugify) for turning text into a url sage slug 30 | * [com.zaxxer.hikari](https://github.com/brettwooldridge/HikariCP) for connection pooling 31 | * [bcrypt](https://github.com/patrickfav/bcrypt) for password salt and hashing 32 | * [org.slf4j](https://github.com/qos-ch/slf4j) as a logging facade 33 | 34 | Almost all the code is contained in the [`RealWorldAPI`](https://github.com/bowbahdoe/jdk-httpserver-realworld/blob/main/src/main/java/dev/mccue/jdk/httpserver/realworld/RealWorldAPI.java) class. If any of the choices made here offend your sensibilities 35 | I encourage forking and showing the way you would prefer it be done. If you think something is done in a subpar way or 36 | is otherwise objectively broken please open an issue. 37 | 38 | Specifically, I would encourage folks to try and 39 | 40 | * Split up the `RealWorldAPI` class. Where are the natural boundaries? 41 | * Try using their database abstraction of choice. What would this look like with `Hibernate`, `JOOQ`, or `JDBI`? Would there be fewer or more round trips to the database? 42 | * Try using their JSON library of choice. 43 | * Try to do the whole persistence/service/etc. split. Does that make things better? 44 | * Add unit tests. For this exact thing there are already API tests I was able to just use, but how would testing look with JUnit? 45 | * etc. 46 | 47 | I personally see a lot of areas for improvement once string templates are real. Counting `?`s in big queries is maybe the biggest 48 | remaining "raw" JDBC shortcoming. 49 | 50 | # Getting started 51 | 52 | ## Prerequisites 53 | 54 | * Java 22 or above 55 | * SDKMan 56 | * Docker 57 | 58 | ## Usage 59 | 60 | First, start up postgres 61 | 62 | ``` 63 | $ docker compose up -d 64 | ``` 65 | 66 | Then install MyBatis Migrations. This is currently easiest to do with SDKMan. 67 | 68 | ``` 69 | $ sdk install mybatis 70 | ``` 71 | 72 | Apply the migrations to the database 73 | 74 | ``` 75 | $ cd migrations 76 | $ migrate up 77 | $ cd .. 78 | ``` 79 | 80 | Then to run the server either 81 | 82 | * open the project in your editor 83 | * run it through maven (`./mvnw exec:java -Dexec.mainClass="dev.mccue.jdk.httpserver.realworld.Main"`) 84 | * run it through docker 85 | 86 | ``` 87 | $ docker build -t realworld . 88 | $ docker run realworld 89 | ``` 90 | 91 | The `.env` file for this project is committed to the repo. Note that in general this is a bad idea/practice, but the 92 | only secrets here are for the local database connection so it's fine. -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:17 4 | restart: unless-stopped 5 | env_file: ".env" 6 | healthcheck: 7 | test: ['CMD-SHELL', "sh -c 'pg_isready -U postgres -d postgres'"] 8 | interval: 3s 9 | timeout: 3s 10 | retries: 10 11 | environment: 12 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 13 | volumes: 14 | - ./data/db:/var/lib/postgresql/data 15 | ports: 16 | - '5432:5432' -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bowbahdoe/jdk-httpserver-realworld/5d395a7a5022dc157ff420d241b623c2361db7de/favicon.ico -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bowbahdoe/jdk-httpserver-realworld/5d395a7a5022dc157ff420d241b623c2361db7de/logo.png -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Welcome! 2 | 3 | This is an MyBatis Migration repository. You can specify the repository 4 | directory when running migrations using the --path= 5 | option. The default path is the current working directory ("./"). 6 | 7 | The repository base directory contains three subdirectories as follows: 8 | 9 | ./drivers 10 | 11 | Place your JDBC driver .jar or .zip files in this directory. Upon running a 12 | migration, the drivers will be dynamically loaded. 13 | 14 | ./environments 15 | 16 | In the environments folder you will find .properties files that represent 17 | your database instances. By default a development.properties file is 18 | created for you to configure your development time database properties. 19 | You can also create test.properties and production.properties files. 20 | The environment can be specified when running a migration by using 21 | the --env= option (without the path or ".properties" part). 22 | 23 | The default environment is "development". 24 | 25 | ./scripts 26 | 27 | This directory contains your migration SQL files. These are the files 28 | that contain your DDL to both upgrade and downgrade your database 29 | structure. By default, the directory will contain the script to 30 | create the changelog table, plus one empty "first" migration script. 31 | To create a new migration script, use the "new" command. To run 32 | all pending migrations, use the "up" command. To undo the last 33 | migration applied, use the "down" command etc. 34 | 35 | For more information about commands and options, run the MyBatis 36 | Migration script with the --help option. 37 | 38 | Enjoy. 39 | -------------------------------------------------------------------------------- /migrations/drivers/org.checkerframework.checker.qual.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bowbahdoe/jdk-httpserver-realworld/5d395a7a5022dc157ff420d241b623c2361db7de/migrations/drivers/org.checkerframework.checker.qual.jar -------------------------------------------------------------------------------- /migrations/drivers/org.postgresql.jdbc.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bowbahdoe/jdk-httpserver-realworld/5d395a7a5022dc157ff420d241b623c2361db7de/migrations/drivers/org.postgresql.jdbc.jar -------------------------------------------------------------------------------- /migrations/environments/development.properties: -------------------------------------------------------------------------------- 1 | ## Base time zone to ensure times are consistent across machines 2 | time_zone=GMT+0:00 3 | 4 | ## The character set that scripts are encoded with 5 | # script_char_set=UTF-8 6 | 7 | ## JDBC connection properties. 8 | driver=org.postgresql.Driver 9 | url=jdbc:postgresql:postgres 10 | username=postgres 11 | password=postgres 12 | 13 | # 14 | # A NOTE ON STORED PROCEDURES AND DELIMITERS 15 | # 16 | # Stored procedures and functions commonly have nested delimiters 17 | # that conflict with the schema migration parsing. If you tend 18 | # to use procs, functions, triggers or anything that could create 19 | # this situation, then you may want to experiment with 20 | # send_full_script=true (preferred), or if you can't use 21 | # send_full_script, then you may have to resort to a full 22 | # line delimiter such as "GO" or "/" or "!RUN!". 23 | # 24 | # Also play with the autocommit settings, as some drivers 25 | # or databases don't support creating procs, functions or 26 | # even tables in a transaction, and others require it. 27 | # 28 | 29 | # This ignores the line delimiters and 30 | # simply sends the entire script at once. 31 | # Use with JDBC drivers that can accept large 32 | # blocks of delimited text at once. 33 | send_full_script=true 34 | 35 | # This controls how statements are delimited. 36 | # By default statements are delimited by an 37 | # end of line semicolon. Some databases may 38 | # (e.g. MS SQL Server) may require a full line 39 | # delimiter such as GO. 40 | # These are ignored if send_full_script is true. 41 | delimiter=; 42 | full_line_delimiter=false 43 | 44 | # If set to true, each statement is isolated 45 | # in its own transaction. Otherwise the entire 46 | # script is executed in one transaction. 47 | # Few databases should need this set to true, 48 | # but some do. 49 | auto_commit=false 50 | 51 | # If set to false, warnings from the database will interrupt migrations. 52 | ignore_warnings=true 53 | 54 | # Custom driver path to allow you to centralize your driver files 55 | # Default requires the drivers to be in the drivers directory of your 56 | # initialized migration directory (created with "migrate init") 57 | # driver_path= 58 | 59 | # Name of the table that tracks changes to the database 60 | changelog=CHANGELOG 61 | 62 | # Migrations support variable substitutions in the form of ${variable} 63 | # in the migration scripts. All of the above properties will be ignored though, 64 | # with the exception of changelog. 65 | # Example: The following would be referenced in a migration file as ${ip_address} 66 | # ip_address=192.168.0.1 67 | 68 | -------------------------------------------------------------------------------- /migrations/scripts/20241126081351_create_changelog.sql: -------------------------------------------------------------------------------- 1 | -- // Create Changelog 2 | 3 | -- Default DDL for changelog table that will keep 4 | -- a record of the migrations that have been run. 5 | 6 | -- You can modify this to suit your database before 7 | -- running your first migration. 8 | 9 | -- Be sure that ID and DESCRIPTION fields exist in 10 | -- BigInteger and String compatible fields respectively. 11 | 12 | CREATE TABLE ${changelog} ( 13 | ID NUMERIC(20,0) NOT NULL, 14 | APPLIED_AT VARCHAR(25) NOT NULL, 15 | DESCRIPTION VARCHAR(255) NOT NULL 16 | ); 17 | 18 | ALTER TABLE ${changelog} 19 | ADD CONSTRAINT PK_${changelog} 20 | PRIMARY KEY (id); 21 | 22 | -- //@UNDO 23 | 24 | DROP TABLE ${changelog}; 25 | -------------------------------------------------------------------------------- /migrations/scripts/20241126081352_first_migration.sql: -------------------------------------------------------------------------------- 1 | -- // First migration. 2 | CREATE EXTENSION citext; 3 | 4 | CREATE SCHEMA realworld; 5 | 6 | CREATE FUNCTION realworld.set_current_timestamp_updated_at() 7 | RETURNS TRIGGER AS $$ 8 | DECLARE 9 | _new record; 10 | BEGIN 11 | _new := NEW; 12 | _new."updated_at" = NOW(); 13 | RETURN _new; 14 | END; 15 | $$ LANGUAGE plpgsql; 16 | 17 | CREATE TABLE realworld.user ( 18 | id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, 19 | created_at timestamptz NOT NULL DEFAULT now(), 20 | updated_at timestamptz NOT NULL DEFAULT now(), 21 | username text not null unique, 22 | password_hash text not null, 23 | email citext not null unique, 24 | bio text not null default '', 25 | image text, 26 | unique (username, email) 27 | ); 28 | 29 | CREATE TRIGGER set_realworld_user_updated_at 30 | BEFORE UPDATE ON realworld.user 31 | FOR EACH ROW 32 | EXECUTE PROCEDURE realworld.set_current_timestamp_updated_at(); 33 | 34 | CREATE TABLE realworld.article ( 35 | id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, 36 | created_at timestamptz NOT NULL DEFAULT now(), 37 | updated_at timestamptz NOT NULL DEFAULT now(), 38 | user_id uuid not null references realworld.user (id) 39 | ON UPDATE RESTRICT 40 | ON DELETE RESTRICT, 41 | slug text not null unique, 42 | title text not null, 43 | description text not null default '', 44 | body text not null default '', 45 | deleted boolean not null default false 46 | ); 47 | 48 | create index on realworld.article using btree(user_id); 49 | 50 | CREATE TRIGGER set_realworld_article_updated_at 51 | BEFORE UPDATE ON realworld.article 52 | FOR EACH ROW 53 | EXECUTE PROCEDURE realworld.set_current_timestamp_updated_at(); 54 | 55 | create table realworld.favorite ( 56 | id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, 57 | created_at timestamptz NOT NULL DEFAULT now(), 58 | updated_at timestamptz NOT NULL DEFAULT now(), 59 | article_id uuid not null references realworld.article(id) 60 | ON UPDATE RESTRICT 61 | ON DELETE RESTRICT, 62 | user_id uuid not null references realworld.user(id) 63 | ON UPDATE RESTRICT 64 | ON DELETE RESTRICT, 65 | unique (article_id, user_id) 66 | ); 67 | 68 | 69 | create index on realworld.favorite using btree(article_id); 70 | create index on realworld.favorite using btree(user_id); 71 | 72 | CREATE TRIGGER set_realworld_favorite_updated_at 73 | BEFORE UPDATE ON realworld.favorite 74 | FOR EACH ROW 75 | EXECUTE PROCEDURE realworld.set_current_timestamp_updated_at(); 76 | 77 | create table realworld.follow ( 78 | id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, 79 | created_at timestamptz NOT NULL DEFAULT now(), 80 | updated_at timestamptz NOT NULL DEFAULT now(), 81 | from_user_id uuid not null references realworld.user(id) 82 | ON UPDATE RESTRICT 83 | ON DELETE RESTRICT, 84 | to_user_id uuid not null references realworld.user(id) 85 | ON UPDATE RESTRICT 86 | ON DELETE RESTRICT, 87 | UNIQUE (from_user_id, to_user_id) 88 | ); 89 | 90 | create index on realworld.follow using btree(from_user_id); 91 | create index on realworld.follow using btree(to_user_id); 92 | 93 | CREATE TRIGGER set_realworld_follow_updated_at 94 | BEFORE UPDATE ON realworld.follow 95 | FOR EACH ROW 96 | EXECUTE PROCEDURE realworld.set_current_timestamp_updated_at(); 97 | 98 | 99 | create table realworld.tag ( 100 | id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, 101 | created_at timestamptz NOT NULL DEFAULT now(), 102 | updated_at timestamptz NOT NULL DEFAULT now(), 103 | name text not null unique 104 | ); 105 | 106 | CREATE TRIGGER set_realworld_tag_updated_at 107 | BEFORE UPDATE ON realworld.tag 108 | FOR EACH ROW 109 | EXECUTE PROCEDURE realworld.set_current_timestamp_updated_at(); 110 | 111 | create table realworld.article_tag ( 112 | id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, 113 | created_at timestamptz NOT NULL DEFAULT now(), 114 | updated_at timestamptz NOT NULL DEFAULT now(), 115 | article_id uuid not null references realworld.article(id) 116 | on update restrict 117 | on delete restrict, 118 | tag_id uuid not null references realworld.tag(id) 119 | on update restrict 120 | on delete restrict 121 | ); 122 | 123 | 124 | create index on realworld.article_tag using btree(article_id); 125 | create index on realworld.article_tag using btree(tag_id); 126 | 127 | CREATE TRIGGER set_realworld_article_tag_updated_at 128 | BEFORE UPDATE ON realworld.article_tag 129 | FOR EACH ROW 130 | EXECUTE PROCEDURE realworld.set_current_timestamp_updated_at(); 131 | 132 | create table realworld.comment( 133 | id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, 134 | created_at timestamptz NOT NULL DEFAULT now(), 135 | updated_at timestamptz NOT NULL DEFAULT now(), 136 | article_id uuid not null references realworld.article(id) 137 | on update restrict 138 | on delete restrict, 139 | user_id uuid not null references realworld.user(id) 140 | on update restrict 141 | on delete restrict, 142 | body text not null default '', 143 | deleted boolean not null default false 144 | ); 145 | 146 | CREATE TRIGGER set_realworld_comment_updated_at 147 | BEFORE UPDATE ON realworld.comment 148 | FOR EACH ROW 149 | EXECUTE PROCEDURE realworld.set_current_timestamp_updated_at(); 150 | 151 | create table realworld.api_key( 152 | id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, 153 | created_at timestamptz NOT NULL DEFAULT now(), 154 | updated_at timestamptz NOT NULL DEFAULT now(), 155 | user_id uuid not null references realworld.user(id) 156 | on update restrict 157 | on delete restrict, 158 | value text not null unique, 159 | invalidated_at timestamptz 160 | ); 161 | 162 | CREATE TRIGGER set_realworld_api_key_updated_at 163 | BEFORE UPDATE ON realworld.api_key 164 | FOR EACH ROW 165 | EXECUTE PROCEDURE realworld.set_current_timestamp_updated_at(); 166 | 167 | create index on realworld.api_key using btree(value); 168 | 169 | -- //@UNDO 170 | DROP SCHEMA realworld cascade; 171 | 172 | DROP EXTENSION citext; -------------------------------------------------------------------------------- /migrations/scripts/bootstrap.sql: -------------------------------------------------------------------------------- 1 | -- // Bootstrap.sql 2 | 3 | -- This is the only SQL script file that is NOT 4 | -- a valid migration and will not be run or tracked 5 | -- in the changelog. There is no @UNDO section. 6 | 7 | -- // Do I need this file? 8 | 9 | -- New projects likely won't need this file. 10 | -- Existing projects will likely need this file. 11 | -- It's unlikely that this bootstrap should be run 12 | -- in the production environment. 13 | 14 | -- // Purpose 15 | 16 | -- The purpose of this file is to provide a facility 17 | -- to initialize the database to a state before MyBatis 18 | -- SQL migrations were applied. If you already have 19 | -- a database in production, then you probably have 20 | -- a script that you run on your developer machine 21 | -- to initialize the database. That script can now 22 | -- be put in this bootstrap file (but does not have 23 | -- to be if you are comfortable with your current process. 24 | 25 | -- // Running 26 | 27 | -- The bootstrap SQL is run with the "migrate bootstrap" 28 | -- command. It must be run manually, it's never run as 29 | -- part of the regular migration process and will never 30 | -- be undone. Variables (e.g. ${variable}) are still 31 | -- parsed in the bootstrap SQL. 32 | 33 | -- After the boostrap SQL has been run, you can then 34 | -- use the migrations and the changelog for all future 35 | -- database change management. 36 | 37 | -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.2.0 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | # e.g. to debug Maven itself, use 32 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | # ---------------------------------------------------------------------------- 35 | 36 | if [ -z "$MAVEN_SKIP_RC" ] ; then 37 | 38 | if [ -f /usr/local/etc/mavenrc ] ; then 39 | . /usr/local/etc/mavenrc 40 | fi 41 | 42 | if [ -f /etc/mavenrc ] ; then 43 | . /etc/mavenrc 44 | fi 45 | 46 | if [ -f "$HOME/.mavenrc" ] ; then 47 | . "$HOME/.mavenrc" 48 | fi 49 | 50 | fi 51 | 52 | # OS specific support. $var _must_ be set to either true or false. 53 | cygwin=false; 54 | darwin=false; 55 | mingw=false 56 | case "$(uname)" in 57 | CYGWIN*) cygwin=true ;; 58 | MINGW*) mingw=true;; 59 | Darwin*) darwin=true 60 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 61 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 62 | if [ -z "$JAVA_HOME" ]; then 63 | if [ -x "/usr/libexec/java_home" ]; then 64 | JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME 65 | else 66 | JAVA_HOME="/Library/Java/Home"; export JAVA_HOME 67 | fi 68 | fi 69 | ;; 70 | esac 71 | 72 | if [ -z "$JAVA_HOME" ] ; then 73 | if [ -r /etc/gentoo-release ] ; then 74 | JAVA_HOME=$(java-config --jre-home) 75 | fi 76 | fi 77 | 78 | # For Cygwin, ensure paths are in UNIX format before anything is touched 79 | if $cygwin ; then 80 | [ -n "$JAVA_HOME" ] && 81 | JAVA_HOME=$(cygpath --unix "$JAVA_HOME") 82 | [ -n "$CLASSPATH" ] && 83 | CLASSPATH=$(cygpath --path --unix "$CLASSPATH") 84 | fi 85 | 86 | # For Mingw, ensure paths are in UNIX format before anything is touched 87 | if $mingw ; then 88 | [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && 89 | JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" 90 | fi 91 | 92 | if [ -z "$JAVA_HOME" ]; then 93 | javaExecutable="$(which javac)" 94 | if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then 95 | # readlink(1) is not available as standard on Solaris 10. 96 | readLink=$(which readlink) 97 | if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then 98 | if $darwin ; then 99 | javaHome="$(dirname "\"$javaExecutable\"")" 100 | javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" 101 | else 102 | javaExecutable="$(readlink -f "\"$javaExecutable\"")" 103 | fi 104 | javaHome="$(dirname "\"$javaExecutable\"")" 105 | javaHome=$(expr "$javaHome" : '\(.*\)/bin') 106 | JAVA_HOME="$javaHome" 107 | export JAVA_HOME 108 | fi 109 | fi 110 | fi 111 | 112 | if [ -z "$JAVACMD" ] ; then 113 | if [ -n "$JAVA_HOME" ] ; then 114 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 115 | # IBM's JDK on AIX uses strange locations for the executables 116 | JAVACMD="$JAVA_HOME/jre/sh/java" 117 | else 118 | JAVACMD="$JAVA_HOME/bin/java" 119 | fi 120 | else 121 | JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" 122 | fi 123 | fi 124 | 125 | if [ ! -x "$JAVACMD" ] ; then 126 | echo "Error: JAVA_HOME is not defined correctly." >&2 127 | echo " We cannot execute $JAVACMD" >&2 128 | exit 1 129 | fi 130 | 131 | if [ -z "$JAVA_HOME" ] ; then 132 | echo "Warning: JAVA_HOME environment variable is not set." 133 | fi 134 | 135 | # traverses directory structure from process work directory to filesystem root 136 | # first directory with .mvn subdirectory is considered project base directory 137 | find_maven_basedir() { 138 | if [ -z "$1" ] 139 | then 140 | echo "Path not specified to find_maven_basedir" 141 | return 1 142 | fi 143 | 144 | basedir="$1" 145 | wdir="$1" 146 | while [ "$wdir" != '/' ] ; do 147 | if [ -d "$wdir"/.mvn ] ; then 148 | basedir=$wdir 149 | break 150 | fi 151 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 152 | if [ -d "${wdir}" ]; then 153 | wdir=$(cd "$wdir/.." || exit 1; pwd) 154 | fi 155 | # end of workaround 156 | done 157 | printf '%s' "$(cd "$basedir" || exit 1; pwd)" 158 | } 159 | 160 | # concatenates all lines of a file 161 | concat_lines() { 162 | if [ -f "$1" ]; then 163 | # Remove \r in case we run on Windows within Git Bash 164 | # and check out the repository with auto CRLF management 165 | # enabled. Otherwise, we may read lines that are delimited with 166 | # \r\n and produce $'-Xarg\r' rather than -Xarg due to word 167 | # splitting rules. 168 | tr -s '\r\n' ' ' < "$1" 169 | fi 170 | } 171 | 172 | log() { 173 | if [ "$MVNW_VERBOSE" = true ]; then 174 | printf '%s\n' "$1" 175 | fi 176 | } 177 | 178 | BASE_DIR=$(find_maven_basedir "$(dirname "$0")") 179 | if [ -z "$BASE_DIR" ]; then 180 | exit 1; 181 | fi 182 | 183 | MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR 184 | log "$MAVEN_PROJECTBASEDIR" 185 | 186 | ########################################################################################## 187 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 188 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 189 | ########################################################################################## 190 | wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" 191 | if [ -r "$wrapperJarPath" ]; then 192 | log "Found $wrapperJarPath" 193 | else 194 | log "Couldn't find $wrapperJarPath, downloading it ..." 195 | 196 | if [ -n "$MVNW_REPOURL" ]; then 197 | wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 198 | else 199 | wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 200 | fi 201 | while IFS="=" read -r key value; do 202 | # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) 203 | safeValue=$(echo "$value" | tr -d '\r') 204 | case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; 205 | esac 206 | done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" 207 | log "Downloading from: $wrapperUrl" 208 | 209 | if $cygwin; then 210 | wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") 211 | fi 212 | 213 | if command -v wget > /dev/null; then 214 | log "Found wget ... using wget" 215 | [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" 216 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 217 | wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 218 | else 219 | wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 220 | fi 221 | elif command -v curl > /dev/null; then 222 | log "Found curl ... using curl" 223 | [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" 224 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 225 | curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" 226 | else 227 | curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" 228 | fi 229 | else 230 | log "Falling back to using Java to download" 231 | javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" 232 | javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" 233 | # For Cygwin, switch paths to Windows format before running javac 234 | if $cygwin; then 235 | javaSource=$(cygpath --path --windows "$javaSource") 236 | javaClass=$(cygpath --path --windows "$javaClass") 237 | fi 238 | if [ -e "$javaSource" ]; then 239 | if [ ! -e "$javaClass" ]; then 240 | log " - Compiling MavenWrapperDownloader.java ..." 241 | ("$JAVA_HOME/bin/javac" "$javaSource") 242 | fi 243 | if [ -e "$javaClass" ]; then 244 | log " - Running MavenWrapperDownloader.java ..." 245 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" 246 | fi 247 | fi 248 | fi 249 | fi 250 | ########################################################################################## 251 | # End of extension 252 | ########################################################################################## 253 | 254 | # If specified, validate the SHA-256 sum of the Maven wrapper jar file 255 | wrapperSha256Sum="" 256 | while IFS="=" read -r key value; do 257 | case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; 258 | esac 259 | done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" 260 | if [ -n "$wrapperSha256Sum" ]; then 261 | wrapperSha256Result=false 262 | if command -v sha256sum > /dev/null; then 263 | if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then 264 | wrapperSha256Result=true 265 | fi 266 | elif command -v shasum > /dev/null; then 267 | if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then 268 | wrapperSha256Result=true 269 | fi 270 | else 271 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." 272 | echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." 273 | exit 1 274 | fi 275 | if [ $wrapperSha256Result = false ]; then 276 | echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 277 | echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 278 | echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 279 | exit 1 280 | fi 281 | fi 282 | 283 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 284 | 285 | # For Cygwin, switch paths to Windows format before running java 286 | if $cygwin; then 287 | [ -n "$JAVA_HOME" ] && 288 | JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") 289 | [ -n "$CLASSPATH" ] && 290 | CLASSPATH=$(cygpath --path --windows "$CLASSPATH") 291 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 292 | MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") 293 | fi 294 | 295 | # Provide a "standardized" way to retrieve the CLI args that will 296 | # work with both Windows and non-Windows executions. 297 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" 298 | export MAVEN_CMD_LINE_ARGS 299 | 300 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 301 | 302 | # shellcheck disable=SC2086 # safe args 303 | exec "$JAVACMD" \ 304 | $MAVEN_OPTS \ 305 | $MAVEN_DEBUG_OPTS \ 306 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 307 | "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 308 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 309 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM http://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Apache Maven Wrapper startup batch script, version 3.2.0 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 28 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 29 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 30 | @REM e.g. to debug Maven itself, use 31 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 32 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 33 | @REM ---------------------------------------------------------------------------- 34 | 35 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 36 | @echo off 37 | @REM set title of command window 38 | title %0 39 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 40 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 41 | 42 | @REM set %HOME% to equivalent of $HOME 43 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 44 | 45 | @REM Execute a user defined script before this one 46 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 47 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 48 | if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* 49 | if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* 50 | :skipRcPre 51 | 52 | @setlocal 53 | 54 | set ERROR_CODE=0 55 | 56 | @REM To isolate internal variables from possible post scripts, we use another setlocal 57 | @setlocal 58 | 59 | @REM ==== START VALIDATION ==== 60 | if not "%JAVA_HOME%" == "" goto OkJHome 61 | 62 | echo. 63 | echo Error: JAVA_HOME not found in your environment. >&2 64 | echo Please set the JAVA_HOME variable in your environment to match the >&2 65 | echo location of your Java installation. >&2 66 | echo. 67 | goto error 68 | 69 | :OkJHome 70 | if exist "%JAVA_HOME%\bin\java.exe" goto init 71 | 72 | echo. 73 | echo Error: JAVA_HOME is set to an invalid directory. >&2 74 | echo JAVA_HOME = "%JAVA_HOME%" >&2 75 | echo Please set the JAVA_HOME variable in your environment to match the >&2 76 | echo location of your Java installation. >&2 77 | echo. 78 | goto error 79 | 80 | @REM ==== END VALIDATION ==== 81 | 82 | :init 83 | 84 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 85 | @REM Fallback to current working directory if not found. 86 | 87 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 88 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 89 | 90 | set EXEC_DIR=%CD% 91 | set WDIR=%EXEC_DIR% 92 | :findBaseDir 93 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 94 | cd .. 95 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 96 | set WDIR=%CD% 97 | goto findBaseDir 98 | 99 | :baseDirFound 100 | set MAVEN_PROJECTBASEDIR=%WDIR% 101 | cd "%EXEC_DIR%" 102 | goto endDetectBaseDir 103 | 104 | :baseDirNotFound 105 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 106 | cd "%EXEC_DIR%" 107 | 108 | :endDetectBaseDir 109 | 110 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 111 | 112 | @setlocal EnableExtensions EnableDelayedExpansion 113 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 114 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 115 | 116 | :endReadAdditionalConfig 117 | 118 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 119 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 120 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 121 | 122 | set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 123 | 124 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 125 | IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B 126 | ) 127 | 128 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 129 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 130 | if exist %WRAPPER_JAR% ( 131 | if "%MVNW_VERBOSE%" == "true" ( 132 | echo Found %WRAPPER_JAR% 133 | ) 134 | ) else ( 135 | if not "%MVNW_REPOURL%" == "" ( 136 | SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 137 | ) 138 | if "%MVNW_VERBOSE%" == "true" ( 139 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 140 | echo Downloading from: %WRAPPER_URL% 141 | ) 142 | 143 | powershell -Command "&{"^ 144 | "$webclient = new-object System.Net.WebClient;"^ 145 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 146 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 147 | "}"^ 148 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ 149 | "}" 150 | if "%MVNW_VERBOSE%" == "true" ( 151 | echo Finished downloading %WRAPPER_JAR% 152 | ) 153 | ) 154 | @REM End of extension 155 | 156 | @REM If specified, validate the SHA-256 sum of the Maven wrapper jar file 157 | SET WRAPPER_SHA_256_SUM="" 158 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 159 | IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B 160 | ) 161 | IF NOT %WRAPPER_SHA_256_SUM%=="" ( 162 | powershell -Command "&{"^ 163 | "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ 164 | "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ 165 | " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ 166 | " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ 167 | " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ 168 | " exit 1;"^ 169 | "}"^ 170 | "}" 171 | if ERRORLEVEL 1 goto error 172 | ) 173 | 174 | @REM Provide a "standardized" way to retrieve the CLI args that will 175 | @REM work with both Windows and non-Windows executions. 176 | set MAVEN_CMD_LINE_ARGS=%* 177 | 178 | %MAVEN_JAVA_EXE% ^ 179 | %JVM_CONFIG_MAVEN_PROPS% ^ 180 | %MAVEN_OPTS% ^ 181 | %MAVEN_DEBUG_OPTS% ^ 182 | -classpath %WRAPPER_JAR% ^ 183 | "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ 184 | %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 185 | if ERRORLEVEL 1 goto error 186 | goto end 187 | 188 | :error 189 | set ERROR_CODE=1 190 | 191 | :end 192 | @endlocal & set ERROR_CODE=%ERROR_CODE% 193 | 194 | if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost 195 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 196 | if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" 197 | if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" 198 | :skipRcPost 199 | 200 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 201 | if "%MAVEN_BATCH_PAUSE%"=="on" pause 202 | 203 | if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% 204 | 205 | cmd /C exit /B %ERROR_CODE% 206 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | jar 8 | dev.mccue 9 | jdk-httpserver-realworld 10 | 1.0-SNAPSHOT 11 | 12 | 13 | UTF-8 14 | 15 | 16 | 17 | 18 | dev.mccue 19 | json 20 | 2024.11.20 21 | 22 | 23 | 24 | dev.mccue 25 | jdk-httpserver-json 26 | 2024.12.04 27 | 28 | 29 | 30 | dev.mccue 31 | jdk-httpserver 32 | 2024.11.18 33 | 34 | 35 | 36 | dev.mccue 37 | jdk-httpserver-regexrouter 38 | 2024.12.05 39 | 40 | 41 | 42 | org.slf4j 43 | slf4j-api 44 | 2.0.16 45 | 46 | 47 | 48 | io.jstach.rainbowgum 49 | rainbowgum 50 | 0.8.0 51 | 52 | 53 | 54 | dev.mccue 55 | jdbc 56 | 2024.11.27 57 | 58 | 59 | 60 | dev.mccue 61 | urlparameters 62 | 2024.01.05 63 | 64 | 65 | 66 | org.junit.jupiter 67 | junit-jupiter-engine 68 | 5.9.0 69 | test 70 | 71 | 72 | 73 | io.github.cdimascio 74 | dotenv-java 75 | 3.0.0 76 | 77 | 78 | 79 | com.github.slugify 80 | slugify 81 | 3.0.5 82 | 83 | 84 | 85 | com.zaxxer 86 | HikariCP 87 | 6.2.0 88 | 89 | 90 | 91 | org.postgresql 92 | postgresql 93 | 42.7.4 94 | 95 | 96 | 97 | at.favre.lib 98 | bcrypt 99 | 0.10.2 100 | 101 | 102 | 103 | org.eclipse.jetty 104 | jetty-http-spi 105 | 12.0.15 106 | 107 | 108 | 109 | org.eclipse.jetty 110 | jetty-server 111 | 12.0.15 112 | 113 | 114 | 115 | org.jetbrains 116 | annotations 117 | 26.0.0 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | maven-jlink-plugin 126 | 3.1.0 127 | true 128 | 129 | true 130 | true 131 | true 132 | true 133 | 2 134 | 135 | 136 | 137 | org.apache.maven.plugins 138 | maven-compiler-plugin 139 | 3.8.1 140 | 141 | 21 142 | 21 143 | 144 | 145 | 146 | maven-assembly-plugin 147 | 148 | server 149 | 150 | src/main/assembly/distribution.xml 151 | 152 | 153 | 154 | 155 | org.codehaus.mojo 156 | exec-maven-plugin 157 | 3.5.0 158 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /src/main/assembly/distribution.xml: -------------------------------------------------------------------------------- 1 | 2 | distribution 3 | 4 | dir 5 | 6 | false 7 | server 8 | 9 | 10 | /lib 11 | 12 | *:jar:* 13 | 14 | 15 | 16 | 17 | 18 | ${project.build.directory} 19 | lib 20 | 21 | ${project.build.finalName}.jar 22 | 23 | 24 | 25 | src/main/assembly 26 | bin 27 | 28 | server.sh 29 | 30 | 0755 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/main/assembly/server.sh: -------------------------------------------------------------------------------- 1 | DIR=$(dirname "$0") 2 | java --module-path $DIR/../lib \ 3 | --add-modules dev.mccue.jdk.httpserver.realworld \ 4 | --module dev.mccue.jdk.httpserver.realworld/dev.mccue.jdk.httpserver.realworld.Main "$@" -------------------------------------------------------------------------------- /src/main/java/dev/mccue/jdk/httpserver/realworld/Main.java: -------------------------------------------------------------------------------- 1 | package dev.mccue.jdk.httpserver.realworld; 2 | 3 | import com.sun.net.httpserver.HttpServer; 4 | import com.zaxxer.hikari.HikariDataSource; 5 | import dev.mccue.jdk.httpserver.HttpExchanges; 6 | import dev.mccue.jdk.httpserver.json.JsonBody; 7 | import dev.mccue.jdk.httpserver.regexrouter.RegexRouter; 8 | import dev.mccue.json.Json; 9 | import io.github.cdimascio.dotenv.Dotenv; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | 13 | import java.net.InetSocketAddress; 14 | 15 | public final class Main { 16 | private static final Logger LOG = 17 | LoggerFactory.getLogger(Main.class); 18 | 19 | private Main() { 20 | 21 | } 22 | 23 | public void start() throws Exception { 24 | var env = Dotenv.configure() 25 | .ignoreIfMissing() 26 | .load(); 27 | var db = new HikariDataSource(); 28 | db.setDriverClassName(env.get("POSTGRES_DRIVER")); 29 | db.setJdbcUrl(env.get("POSTGRES_URL")); 30 | db.setUsername(env.get("POSTGRES_USERNAME")); 31 | db.setPassword(env.get("POSTGRES_PASSWORD")); 32 | 33 | int port; 34 | try { 35 | port = Integer.parseInt(env.get("PORT")); 36 | } catch (NumberFormatException e) { 37 | port = 7585; 38 | } 39 | 40 | var server = HttpServer.create( 41 | new InetSocketAddress(port), 42 | 0 43 | ); 44 | 45 | var routerBuilder = RegexRouter.builder() 46 | .errorHandler((throwable, httpExchange) -> { 47 | LOG.error( 48 | "Unhandled exception while handling {} {}", 49 | httpExchange.getRequestMethod(), 50 | httpExchange.getRequestURI(), 51 | throwable 52 | ); 53 | HttpExchanges.sendResponse(httpExchange, 401, JsonBody.of( 54 | Json.objectBuilder() 55 | .put("errors", Json.objectBuilder() 56 | .put("request", Json.arrayBuilder() 57 | .add(Json.of("internal error")))))); 58 | }) 59 | .notFoundHandler(exchange -> 60 | HttpExchanges.sendResponse(exchange, 404, JsonBody.of( 61 | Json.objectBuilder() 62 | .put("errors", Json.objectBuilder() 63 | .put("body", Json.arrayBuilder() 64 | .add(Json.of("not found")))) 65 | ))); 66 | 67 | new RealWorldAPI(db).register(routerBuilder); 68 | 69 | var router = routerBuilder.build(); 70 | 71 | server.createContext("/", exchange -> { 72 | LOG.info("{} {}", exchange.getRequestMethod(), exchange.getRequestURI()); 73 | router.handle(exchange); 74 | }); 75 | 76 | server.start(); 77 | } 78 | 79 | public static void main(String[] args) throws Exception { 80 | new Main().start(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/main/java/dev/mccue/jdk/httpserver/realworld/RealWorldAPI.java: -------------------------------------------------------------------------------- 1 | package dev.mccue.jdk.httpserver.realworld; 2 | 3 | import at.favre.lib.crypto.bcrypt.BCrypt; 4 | import com.github.slugify.Slugify; 5 | import com.sun.net.httpserver.HttpExchange; 6 | import com.sun.net.httpserver.HttpHandler; 7 | import dev.mccue.jdbc.ResultSets; 8 | import dev.mccue.jdbc.SQLFragment; 9 | import dev.mccue.jdbc.UncheckedSQLException; 10 | import dev.mccue.jdk.httpserver.Body; 11 | import dev.mccue.jdk.httpserver.HttpExchanges; 12 | import dev.mccue.jdk.httpserver.json.JsonBody; 13 | import dev.mccue.jdk.httpserver.regexrouter.RegexRouter; 14 | import dev.mccue.jdk.httpserver.regexrouter.RouteParams; 15 | import dev.mccue.json.*; 16 | import dev.mccue.urlparameters.UrlParameters; 17 | import org.intellij.lang.annotations.Language; 18 | import org.slf4j.Logger; 19 | import org.slf4j.LoggerFactory; 20 | 21 | import javax.sql.DataSource; 22 | import java.io.IOException; 23 | import java.lang.annotation.ElementType; 24 | import java.lang.annotation.Retention; 25 | import java.lang.annotation.RetentionPolicy; 26 | import java.lang.annotation.Target; 27 | import java.lang.invoke.LambdaMetafactory; 28 | import java.lang.reflect.InvocationTargetException; 29 | import java.nio.charset.StandardCharsets; 30 | import java.sql.Connection; 31 | import java.sql.SQLException; 32 | import java.util.*; 33 | import java.util.concurrent.ThreadLocalRandom; 34 | import java.util.regex.Pattern; 35 | 36 | import static dev.mccue.json.JsonDecoder.*; 37 | 38 | public final class RealWorldAPI { 39 | private static final Logger LOG 40 | = LoggerFactory.getLogger(RealWorldAPI.class); 41 | 42 | private final DataSource db; 43 | 44 | RealWorldAPI(DataSource db) { 45 | this.db = db; 46 | } 47 | 48 | UUID getUserId(HttpExchange exchange) { 49 | var authHeader = exchange.getRequestHeaders().getFirst("Authorization"); 50 | if (authHeader != null && authHeader.startsWith("Token ")) { 51 | var token = authHeader.substring("Token ".length()); 52 | try (var conn = db.getConnection(); 53 | var stmt = conn.prepareStatement(""" 54 | SELECT user_id 55 | FROM realworld.api_key 56 | WHERE value = ? AND invalidated_at IS NULL 57 | """)) { 58 | stmt.setString(1, token); 59 | var rs = stmt.executeQuery(); 60 | if (rs.next()) { 61 | var id = rs.getObject("user_id", UUID.class); 62 | LOG.info("Extracted info for user {}", id); 63 | return id; 64 | } else { 65 | LOG.info("No api key"); 66 | return null; 67 | } 68 | } catch (SQLException e) { 69 | throw new UncheckedSQLException(e); 70 | } 71 | } 72 | else { 73 | LOG.info("No auth header"); 74 | return null; 75 | } 76 | } 77 | 78 | String getToken(Connection conn, UUID userId) throws SQLException { 79 | var apiKey = UUID.randomUUID(); 80 | try (var stmt = conn.prepareStatement(""" 81 | INSERT INTO realworld.api_key(user_id, value) 82 | VALUES (?, ?) 83 | """)) { 84 | stmt.setObject(1, userId); 85 | stmt.setObject(2, apiKey.toString()); 86 | stmt.execute(); 87 | } 88 | return apiKey.toString(); 89 | } 90 | 91 | void unauthenticated(HttpExchange exchange) throws IOException { 92 | HttpExchanges.sendResponse( 93 | exchange, 94 | 401, 95 | JsonBody.of( 96 | Json.objectBuilder() 97 | .put("errors", Json.objectBuilder() 98 | .put("body", Json.arrayBuilder() 99 | .add("unauthenticated"))) 100 | ) 101 | ); 102 | } 103 | 104 | record RegisterRequest( 105 | String username, 106 | String email, 107 | char[] password 108 | ) { 109 | static RegisterRequest fromJson(Json json) { 110 | return field(json, "user", user -> new RegisterRequest( 111 | field(user, "username", string()), 112 | field(user, "email", string()), 113 | field(user, "password", string().map(String::toCharArray)) 114 | )); 115 | } 116 | } 117 | 118 | 119 | void registerHandler(HttpExchange exchange) throws IOException { 120 | var body = JsonBody.read(exchange, RegisterRequest::fromJson); 121 | try (var conn = db.getConnection()) { 122 | UUID userId = null; 123 | JsonObject.Builder userJson = Json.objectBuilder(); 124 | try (var stmt = conn.prepareStatement(""" 125 | INSERT INTO realworld."user"(username, email, password_hash) 126 | VALUES (?, ?, ?) 127 | ON CONFLICT (username) 128 | DO UPDATE SET id = EXCLUDED.id 129 | RETURNING id, email, username, bio, image 130 | """)) { 131 | 132 | stmt.setObject(1, body.username); 133 | stmt.setObject(2, body.email.toLowerCase(Locale.US)); 134 | stmt.setObject(3, BCrypt.withDefaults().hashToString(12, body.password)); 135 | var rs = stmt.executeQuery(); 136 | if (rs.next()) { 137 | userId = rs.getObject("id", UUID.class); 138 | userJson 139 | .put("email", rs.getString("email")) 140 | .put("username", rs.getString("username")) 141 | .put("bio", rs.getString("bio")) 142 | .put("image", rs.getString("image")); 143 | } 144 | } 145 | 146 | if (userId != null) { 147 | userJson.put("token", getToken(conn, userId)); 148 | HttpExchanges.sendResponse( 149 | exchange, 150 | 200, 151 | JsonBody.of(Json.objectBuilder().put("user", userJson)) 152 | ); 153 | } 154 | else { 155 | LOG.warn("Matching user found. Determining why"); 156 | try (var stmt = conn.prepareStatement(""" 157 | SELECT 158 | ( 159 | SELECT COUNT(realworld.user.id) 160 | FROM realworld.user 161 | WHERE username = ? 162 | ) as matching_username, 163 | ( 164 | SELECT COUNT(realworld.user.id) 165 | FROM realworld.user 166 | WHERE email = ? 167 | ) as matching_email 168 | """)) { 169 | stmt.setObject(1, body.username); 170 | stmt.setObject(2, body.email); 171 | var rs = stmt.executeQuery(); 172 | rs.next(); 173 | 174 | var errors = new ArrayList(); 175 | if (ResultSets.getIntegerNotNull(rs, "matching_username") > 0) { 176 | LOG.warn("Duplicate username. {}", body.username); 177 | errors.add(Json.of("username already taken")); 178 | } 179 | 180 | if (ResultSets.getIntegerNotNull(rs, "matching_email") > 0) { 181 | LOG.warn("Duplicate email. {}", body.email); 182 | errors.add(Json.of("email already taken")); 183 | } 184 | 185 | HttpExchanges.sendResponse( 186 | exchange, 187 | 422, 188 | JsonBody.of( 189 | Json.objectBuilder() 190 | .put("errors", Json.objectBuilder() 191 | .put("body", Json.of(errors))) 192 | ) 193 | ); 194 | } 195 | } 196 | } catch (SQLException e) { 197 | throw new UncheckedSQLException(e); 198 | } 199 | } 200 | 201 | record LoginRequest(String email, char[] password) { 202 | static LoginRequest fromJson(Json json) { 203 | return field(json, "user", user -> new LoginRequest( 204 | field(user, "email", string()), 205 | field(user, "password", string().map(String::toCharArray)) 206 | )); 207 | } 208 | } 209 | 210 | void loginHandler(HttpExchange exchange) throws IOException { 211 | var body = JsonBody.read(exchange, LoginRequest::fromJson); 212 | 213 | try (var conn = db.getConnection()) { 214 | UUID userId; 215 | String passwordHash; 216 | JsonObject.Builder userJson = Json.objectBuilder(); 217 | try (var stmt = conn.prepareStatement(""" 218 | SELECT id, email, username, bio, image, password_hash 219 | FROM realworld."user" 220 | WHERE email = ? 221 | """)) { 222 | stmt.setObject(1, body.email); 223 | var rs = stmt.executeQuery(); 224 | if (!rs.next()) { 225 | HttpExchanges.sendResponse( 226 | exchange, 227 | 422, 228 | JsonBody.of( 229 | Json.objectBuilder() 230 | .put("errors", Json.objectBuilder() 231 | .put("body", Json.arrayBuilder() 232 | .add("no matching user"))) 233 | ) 234 | ); 235 | return; 236 | } 237 | 238 | userId = rs.getObject("id", UUID.class); 239 | passwordHash = rs.getString("password_hash"); 240 | 241 | userJson 242 | .put("email", rs.getString("email")) 243 | .put("username", rs.getString("username")) 244 | .put("bio", rs.getString("bio")) 245 | .put("image", rs.getString("image")); 246 | } 247 | 248 | if (!BCrypt.verifyer().verify(body.password, passwordHash).verified) { 249 | HttpExchanges.sendResponse( 250 | exchange, 251 | 422, 252 | JsonBody.of( 253 | Json.objectBuilder() 254 | .put("errors", Json.objectBuilder() 255 | .put("body", Json.arrayBuilder() 256 | .add("password does not match"))) 257 | ) 258 | ); 259 | return; 260 | } 261 | 262 | userJson.put("token", getToken(conn, userId)); 263 | 264 | HttpExchanges.sendResponse(exchange, 200, JsonBody.of(Json.objectBuilder().put("user", userJson))); 265 | } catch (SQLException e) { 266 | throw new UncheckedSQLException(e); 267 | } 268 | } 269 | 270 | void getCurrentUserHandler(HttpExchange exchange) throws IOException { 271 | var userId = getUserId(exchange); 272 | if (userId == null) { 273 | unauthenticated(exchange); 274 | return; 275 | } 276 | 277 | try (var conn = db.getConnection()) { 278 | JsonObject.Builder userJson = Json.objectBuilder(); 279 | try (var stmt = conn.prepareStatement(""" 280 | SELECT email, username, bio, image 281 | FROM realworld."user" 282 | WHERE id = ? 283 | """)) { 284 | stmt.setObject(1, userId); 285 | var rs = stmt.executeQuery(); 286 | rs.next(); 287 | userJson 288 | .put("email", rs.getString("email")) 289 | .put("username", rs.getString("username")) 290 | .put("bio", rs.getString("bio")) 291 | .put("image", rs.getString("image")); 292 | } 293 | 294 | userJson.put("token", getToken(conn, userId)); 295 | 296 | HttpExchanges.sendResponse( 297 | exchange, 298 | 200, 299 | JsonBody.of( 300 | Json.objectBuilder() 301 | .put("user", userJson) 302 | ) 303 | ); 304 | } catch (SQLException e) { 305 | throw new UncheckedSQLException(e); 306 | } 307 | } 308 | 309 | record UpdateUserRequest( 310 | Optional email, 311 | Optional username, 312 | Optional password, 313 | Optional image, 314 | Optional bio 315 | ) { 316 | static UpdateUserRequest fromJson(Json json) { 317 | return field(json, "user", user -> new UpdateUserRequest( 318 | optionalField(user, "email", string()), 319 | optionalField(user, "username", string()), 320 | optionalField(user, "password", string()), 321 | optionalField(user, "image", string()), 322 | optionalField(user, "bio", string()) 323 | )); 324 | } 325 | } 326 | 327 | void updateUserHandler(HttpExchange exchange) throws IOException { 328 | var userId = getUserId(exchange); 329 | if (userId == null) { 330 | unauthenticated(exchange); 331 | return; 332 | } 333 | var request = JsonBody.read(exchange, UpdateUserRequest::fromJson); 334 | 335 | var setFragments = new ArrayList(); 336 | request.email.ifPresent(email -> { 337 | setFragments.add(SQLFragment.of("email = ?", List.of(email))); 338 | }); 339 | 340 | request.username.ifPresent(username -> { 341 | setFragments.add(SQLFragment.of("username = ?", List.of(username))); 342 | }); 343 | 344 | request.password.ifPresent(password -> { 345 | setFragments.add(SQLFragment.of("password = ?", List.of(password))); 346 | }); 347 | 348 | request.image.ifPresent(image -> { 349 | setFragments.add(SQLFragment.of("image = ?", List.of(image))); 350 | }); 351 | 352 | request.bio.ifPresent(bio -> { 353 | setFragments.add(SQLFragment.of("bio = ?", List.of(bio))); 354 | }); 355 | 356 | try (var conn = db.getConnection()) { 357 | if (!setFragments.isEmpty()) { 358 | SQLFragment sql = SQLFragment.of(""" 359 | UPDATE realworld."user" 360 | SET\s 361 | """); 362 | 363 | for (int i = 0; i < setFragments.size(); i++) { 364 | sql = sql.concat(setFragments.get(i)); 365 | if (i != setFragments.size() - 1) { 366 | sql = sql.concat(SQLFragment.of(", ")); 367 | } 368 | } 369 | 370 | sql = sql.concat(SQLFragment.of(""" 371 | 372 | WHERE id = ? 373 | """, List.of(userId))); 374 | 375 | try (var stmt = sql.prepareStatement(conn)) { 376 | stmt.execute(); 377 | } 378 | } 379 | 380 | JsonObject.Builder userJson = Json.objectBuilder(); 381 | try (var stmt = conn.prepareStatement(""" 382 | SELECT email, username, bio, image, password_hash 383 | FROM realworld."user" 384 | WHERE id = ? 385 | """)) { 386 | stmt.setObject(1, userId); 387 | var rs = stmt.executeQuery(); 388 | rs.next(); 389 | 390 | userJson 391 | .put("email", rs.getString("email")) 392 | .put("username", rs.getString("username")) 393 | .put("bio", rs.getString("bio")) 394 | .put("image", rs.getString("image")); 395 | } 396 | 397 | userJson.put("token", getToken(conn, userId)); 398 | 399 | HttpExchanges.sendResponse( 400 | exchange, 401 | 200, 402 | JsonBody.of(Json.objectBuilder().put("user", userJson)) 403 | ); 404 | } catch (SQLException e) { 405 | throw new UncheckedSQLException(e); 406 | } 407 | } 408 | 409 | void getProfileHandler(HttpExchange exchange) throws IOException { 410 | var userId = getUserId(exchange); 411 | var username = RouteParams.get(exchange) 412 | .param("username") 413 | .orElseThrow(); 414 | 415 | try (var conn = db.getConnection()) { 416 | try (var stmt = conn.prepareStatement(""" 417 | SELECT 418 | username, 419 | bio, 420 | image, 421 | EXISTS( 422 | SELECT id 423 | FROM realworld.follow 424 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 425 | ) as following 426 | FROM realworld."user" 427 | WHERE username = ? 428 | """)) { 429 | stmt.setObject(1, userId); 430 | stmt.setObject(2, username); 431 | var rs = stmt.executeQuery(); 432 | rs.next(); 433 | HttpExchanges.sendResponse( 434 | exchange, 435 | 200, 436 | JsonBody.of( 437 | Json.objectBuilder() 438 | .put("profile", Json.objectBuilder() 439 | .put("username", rs.getString("username")) 440 | .put("bio", rs.getString("bio")) 441 | .put("image", rs.getString("image")) 442 | .put("following", ResultSets.getBooleanNotNull(rs, "following"))) 443 | ) 444 | ); 445 | } 446 | } catch (SQLException e) { 447 | throw new UncheckedSQLException(e); 448 | } 449 | } 450 | 451 | void followUserHandler(HttpExchange exchange) throws IOException { 452 | var userId = getUserId(exchange); 453 | if (userId == null) { 454 | unauthenticated(exchange); 455 | return; 456 | } 457 | var username = RouteParams.get(exchange) 458 | .param("username") 459 | .orElseThrow(); 460 | 461 | try (var conn = db.getConnection()) { 462 | try (var stmt = conn.prepareStatement(""" 463 | INSERT INTO realworld.follow(from_user_id, to_user_id) 464 | VALUES (?, ( 465 | SELECT id 466 | FROM realworld."user" 467 | WHERE username = ? 468 | )) 469 | ON CONFLICT DO NOTHING 470 | """)) { 471 | stmt.setObject(1, userId); 472 | stmt.setObject(2, username); 473 | stmt.execute(); 474 | } 475 | 476 | try (var stmt = conn.prepareStatement(""" 477 | SELECT 478 | username, 479 | bio, 480 | image, 481 | EXISTS( 482 | SELECT id 483 | FROM realworld.follow 484 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 485 | ) as following 486 | FROM realworld."user" 487 | WHERE username = ? 488 | """)) { 489 | stmt.setObject(1, userId); 490 | stmt.setObject(2, username); 491 | var rs = stmt.executeQuery(); 492 | rs.next(); 493 | HttpExchanges.sendResponse( 494 | exchange, 495 | 200, 496 | JsonBody.of( 497 | Json.objectBuilder() 498 | .put("profile", Json.objectBuilder() 499 | .put("username", rs.getString("username")) 500 | .put("bio", rs.getString("bio")) 501 | .put("image", rs.getString("image")) 502 | .put("following", ResultSets.getBooleanNotNull(rs, "following"))) 503 | ) 504 | ); 505 | } 506 | } catch (SQLException e) { 507 | throw new UncheckedSQLException(e); 508 | } 509 | } 510 | 511 | void unfollowUserHandler(HttpExchange exchange) throws IOException { 512 | var userId = getUserId(exchange); 513 | if (userId == null) { 514 | unauthenticated(exchange); 515 | return; 516 | } 517 | 518 | var username = RouteParams.get(exchange) 519 | .param("username") 520 | .orElseThrow(); 521 | 522 | try (var conn = db.getConnection()) { 523 | try (var stmt = conn.prepareStatement(""" 524 | DELETE FROM realworld.follow 525 | WHERE from_user_id = ? AND to_user_id = ( 526 | SELECT id 527 | FROM realworld."user" 528 | WHERE username = ? 529 | ) 530 | """)) { 531 | stmt.setObject(1, userId); 532 | stmt.setObject(2, username); 533 | stmt.execute(); 534 | } 535 | 536 | try (var stmt = conn.prepareStatement(""" 537 | SELECT 538 | username, 539 | bio, 540 | image, 541 | EXISTS( 542 | SELECT id 543 | FROM realworld.follow 544 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 545 | ) as following 546 | FROM realworld."user" 547 | WHERE username = ? 548 | """)) { 549 | stmt.setObject(1, userId); 550 | stmt.setObject(2, username); 551 | var rs = stmt.executeQuery(); 552 | rs.next(); 553 | HttpExchanges.sendResponse( 554 | exchange, 555 | 200, 556 | JsonBody.of( 557 | Json.objectBuilder() 558 | .put("profile", Json.objectBuilder() 559 | .put("username", rs.getString("username")) 560 | .put("bio", rs.getString("bio")) 561 | .put("image", rs.getString("image")) 562 | .put("following", ResultSets.getBooleanNotNull(rs, "following"))) 563 | ) 564 | ); 565 | } 566 | } catch (SQLException e) { 567 | throw new UncheckedSQLException(e); 568 | } 569 | } 570 | 571 | void listArticlesHandler(HttpExchange exchange) throws IOException { 572 | var userId = getUserId(exchange); 573 | var urlParameters = UrlParameters.parse(exchange.getRequestURI()); 574 | 575 | var query = new ArrayList(); 576 | query.add(SQLFragment.of(""" 577 | WITH 578 | articles AS ( 579 | SELECT jsonb_build_object( 580 | 'slug', realworld.article.slug, 581 | 'title', realworld.article.title, 582 | 'description', realworld.article.description, 583 | 'tagList', array( 584 | SELECT realworld.tag.name 585 | FROM realworld.article_tag 586 | LEFT JOIN realworld.tag ON realworld.tag.id = realworld.article_tag.tag_id 587 | WHERE realworld.article_tag.article_id = realworld.article.id 588 | ORDER BY realworld.tag.name 589 | ), 590 | 'createdAt', realworld.article.created_at, 591 | 'updatedAt', realworld.article.updated_at, 592 | 'favorited', exists( 593 | SELECT id 594 | FROM realworld.favorite 595 | WHERE article_id = realworld.article.id AND user_id = ? 596 | ), 597 | 'favoritesCount', ( 598 | SELECT count(id) 599 | FROM realworld.favorite 600 | WHERE article_id = realworld.article.id 601 | ), 602 | 'author', ( 603 | SELECT jsonb_build_object( 604 | 'username', realworld."user".username, 605 | 'bio', realworld."user".bio, 606 | 'image', realworld."user".image, 607 | 'following', exists( 608 | SELECT id 609 | FROM realworld.follow 610 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 611 | ) 612 | ) 613 | FROM realworld."user" 614 | WHERE realworld."user".id = realworld.article.user_id 615 | ) 616 | ) 617 | FROM realworld.article 618 | WHERE deleted = false 619 | """, Arrays.asList(userId, userId))); 620 | 621 | urlParameters.firstValue("tag").ifPresent(tag -> { 622 | query.add(SQLFragment.of(""" 623 | AND EXISTS( 624 | SELECT id 625 | FROM realworld.article_tag 626 | WHERE realworld.article_tag.article_id = realworld.article.id 627 | AND ? = ( 628 | SELECT name 629 | FROM realworld.tag 630 | WHERE realworld.tag.id = realworld.article_tag.tag_id 631 | ) 632 | ) 633 | """, List.of(tag))); 634 | }); 635 | 636 | urlParameters.firstValue("favorited").ifPresent(favorited -> { 637 | query.add(SQLFragment.of(""" 638 | AND exists( 639 | SELECT id 640 | FROM realworld.favorite 641 | WHERE article_id = realworld.article.id AND user_id = ( 642 | SELECT id 643 | FROM realworld."user" 644 | WHERE username = ? 645 | ) 646 | ) 647 | """, List.of(favorited))); 648 | }); 649 | 650 | urlParameters.firstValue("author").ifPresent(author -> { 651 | query.add(SQLFragment.of(""" 652 | AND realworld.article.user_id IN ( 653 | SELECT id 654 | FROM realworld."user" 655 | WHERE realworld."user".username = ? 656 | ) 657 | """, List.of(author))); 658 | }); 659 | 660 | query.add(SQLFragment.of(""" 661 | ORDER BY realworld.article.created_at DESC 662 | """)); 663 | 664 | String limitString = urlParameters.firstValue("limit").orElse(null); 665 | if (limitString != null) { 666 | int limit; 667 | try { 668 | limit = Integer.parseInt(limitString); 669 | } catch (NumberFormatException __) { 670 | HttpExchanges.sendResponse( 671 | exchange, 672 | 422, 673 | JsonBody.of( 674 | Json.objectBuilder() 675 | .put("errors", Json.objectBuilder() 676 | .put("body", Json.arrayBuilder() 677 | .add("limit must be an int"))) 678 | ) 679 | ); 680 | return; 681 | } 682 | 683 | query.add(SQLFragment.of(""" 684 | LIMIT ? 685 | """, List.of(limit))); 686 | } 687 | 688 | String offsetString = urlParameters.firstValue("offset").orElse(null); 689 | if (offsetString != null) { 690 | int offset; 691 | try { 692 | offset = Integer.parseInt(offsetString); 693 | } catch (NumberFormatException __) { 694 | HttpExchanges.sendResponse( 695 | exchange, 696 | 422, 697 | JsonBody.of( 698 | Json.objectBuilder() 699 | .put("errors", Json.objectBuilder() 700 | .put("body", Json.arrayBuilder() 701 | .add("offset must be an int"))) 702 | ) 703 | ); 704 | return; 705 | } 706 | 707 | query.add(SQLFragment.of(""" 708 | OFFSET ? 709 | """, List.of(offset))); 710 | } 711 | 712 | query.add(SQLFragment.of(""" 713 | ) 714 | SELECT jsonb_build_object( 715 | 'articles', array( 716 | SELECT * FROM articles 717 | ), 718 | 'articlesCount', ( 719 | SELECT count(*) FROM articles 720 | ) 721 | ) 722 | """)); 723 | 724 | 725 | try (var conn = db.getConnection(); 726 | var stmt = SQLFragment.join("", query).prepareStatement(conn)) { 727 | 728 | var rs = stmt.executeQuery(); 729 | rs.next(); 730 | 731 | System.out.println(exchange.getRequestURI()); 732 | System.out.println(Json.read(rs.getObject(1).toString())); 733 | HttpExchanges.sendResponse( 734 | exchange, 735 | 200, 736 | JsonBody.of(Json.read(rs.getObject(1).toString())) 737 | ); 738 | 739 | } catch (SQLException e) { 740 | throw new UncheckedSQLException(e); 741 | } 742 | } 743 | 744 | void feedArticlesHandler(HttpExchange exchange) throws IOException { 745 | var userId = getUserId(exchange); 746 | if (userId == null) { 747 | unauthenticated(exchange); 748 | return; 749 | } 750 | var urlParameters = UrlParameters.parse(exchange.getRequestURI()); 751 | 752 | var query = new ArrayList(); 753 | query.add(SQLFragment.of(""" 754 | WITH 755 | articles AS ( 756 | SELECT jsonb_build_object( 757 | 'slug', realworld.article.slug, 758 | 'title', realworld.article.title, 759 | 'description', realworld.article.description, 760 | 'tagList', array( 761 | SELECT realworld.tag.name 762 | FROM realworld.article_tag 763 | LEFT JOIN realworld.tag ON realworld.tag.id = realworld.article_tag.tag_id 764 | WHERE realworld.article_tag.article_id = realworld.article.id 765 | ORDER BY realworld.tag.name 766 | ), 767 | 'createdAt', realworld.article.created_at, 768 | 'updatedAt', realworld.article.updated_at, 769 | 'favorited', exists( 770 | SELECT id 771 | FROM realworld.favorite 772 | WHERE article_id = realworld.article.id AND user_id = ? 773 | ), 774 | 'favoritesCount', ( 775 | SELECT count(id) 776 | FROM realworld.favorite 777 | WHERE article_id = realworld.article.id 778 | ), 779 | 'author', ( 780 | SELECT jsonb_build_object( 781 | 'username', realworld."user".username, 782 | 'bio', realworld."user".bio, 783 | 'image', realworld."user".image, 784 | 'following', exists( 785 | SELECT id 786 | FROM realworld.follow 787 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 788 | ) 789 | ) 790 | FROM realworld."user" 791 | WHERE realworld."user".id = realworld.article.user_id 792 | ) 793 | ) 794 | FROM realworld.article 795 | WHERE deleted = false AND user_id IN ( 796 | SELECT from_user_id 797 | FROM realworld.follow 798 | WHERE from_user_id = ? AND to_user_id = ( 799 | SELECT id 800 | FROM realworld."user" 801 | WHERE realworld."user".id = realworld.article.user_id 802 | ) 803 | ) 804 | ORDER BY realworld.article.created_at DESC 805 | """, Arrays.asList(userId, userId, userId))); 806 | 807 | String limitString = urlParameters.firstValue("limit").orElse(null); 808 | if (limitString != null) { 809 | int limit; 810 | try { 811 | limit = Integer.parseInt(limitString); 812 | } catch (NumberFormatException __) { 813 | HttpExchanges.sendResponse( 814 | exchange, 815 | 422, 816 | JsonBody.of( 817 | Json.objectBuilder() 818 | .put("errors", Json.objectBuilder() 819 | .put("body", Json.arrayBuilder() 820 | .add("limit must be an int"))) 821 | ) 822 | ); 823 | return; 824 | } 825 | 826 | query.add(SQLFragment.of(""" 827 | LIMIT ? 828 | """, List.of(limit))); 829 | } 830 | 831 | String offsetString = urlParameters.firstValue("offset").orElse(null); 832 | if (offsetString != null) { 833 | int offset; 834 | try { 835 | offset = Integer.parseInt(offsetString); 836 | } catch (NumberFormatException __) { 837 | HttpExchanges.sendResponse( 838 | exchange, 839 | 422, 840 | JsonBody.of( 841 | Json.objectBuilder() 842 | .put("errors", Json.objectBuilder() 843 | .put("body", Json.arrayBuilder() 844 | .add("offset must be an int"))) 845 | ) 846 | ); 847 | return; 848 | } 849 | 850 | query.add(SQLFragment.of(""" 851 | OFFSET ? 852 | """, List.of(offset))); 853 | } 854 | 855 | query.add(SQLFragment.of(""" 856 | ) 857 | SELECT jsonb_build_object( 858 | 'articles', array( 859 | SELECT * FROM articles 860 | ), 861 | 'articlesCount', ( 862 | SELECT count(*) FROM articles 863 | ) 864 | ) 865 | """)); 866 | 867 | 868 | try (var conn = db.getConnection(); 869 | var stmt = SQLFragment.join("", query).prepareStatement(conn)) { 870 | 871 | var rs = stmt.executeQuery(); 872 | rs.next(); 873 | 874 | HttpExchanges.sendResponse( 875 | exchange, 876 | 200, 877 | JsonBody.of(Json.read(rs.getObject(1).toString())) 878 | ); 879 | 880 | } catch (SQLException e) { 881 | throw new UncheckedSQLException(e); 882 | } 883 | } 884 | 885 | void getArticleHandler(HttpExchange exchange) throws IOException { 886 | var userId = getUserId(exchange); 887 | var slug = RouteParams.get(exchange).param("slug").orElseThrow(); 888 | try (var conn = db.getConnection(); 889 | var stmt = conn.prepareStatement(""" 890 | SELECT 891 | jsonb_build_object( 892 | 'article', jsonb_build_object( 893 | 'slug', realworld.article.slug, 894 | 'title', realworld.article.title, 895 | 'description', realworld.article.description, 896 | 'body', realworld.article.body, 897 | 'tagList', array( 898 | SELECT realworld.tag.name 899 | FROM realworld.article_tag 900 | LEFT JOIN realworld.tag ON realworld.tag.id = realworld.article_tag.tag_id 901 | WHERE realworld.article_tag.article_id = realworld.article.id 902 | ORDER BY realworld.tag.name 903 | ), 904 | 'createdAt', realworld.article.created_at, 905 | 'updatedAt', realworld.article.updated_at, 906 | 'favorited', exists( 907 | SELECT id 908 | FROM realworld.favorite 909 | WHERE article_id = realworld.article.id AND user_id = ? 910 | ), 911 | 'favoritesCount', ( 912 | SELECT count(id) 913 | FROM realworld.favorite 914 | WHERE article_id = realworld.article.id 915 | ), 916 | 'author', ( 917 | SELECT jsonb_build_object( 918 | 'username', realworld."user".username, 919 | 'bio', realworld."user".bio, 920 | 'image', realworld."user".image, 921 | 'following', exists( 922 | SELECT id 923 | FROM realworld.follow 924 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 925 | ) 926 | ) 927 | FROM realworld."user" 928 | WHERE realworld."user".id = realworld.article.user_id 929 | ) 930 | )) 931 | FROM realworld.article 932 | WHERE deleted = false AND slug = ? 933 | """)) { 934 | stmt.setObject(1, userId); 935 | stmt.setObject(2, userId); 936 | stmt.setString(3, slug); 937 | 938 | var rs = stmt.executeQuery(); 939 | if (!rs.next()) { 940 | System.out.println(exchange.getRequestURI()); 941 | HttpExchanges.sendResponse( 942 | exchange, 943 | 422, 944 | JsonBody.of( 945 | Json.objectBuilder() 946 | .put("errors", Json.objectBuilder() 947 | .put("body", Json.arrayBuilder() 948 | .add("No matching article"))) 949 | ) 950 | ); 951 | return; 952 | } 953 | 954 | HttpExchanges.sendResponse(exchange, 200, JsonBody.of( 955 | Json.read(rs.getObject(1).toString()) 956 | )); 957 | 958 | 959 | } catch (SQLException e) { 960 | throw new UncheckedSQLException(e); 961 | } 962 | } 963 | 964 | record CreateArticleRequest( 965 | String title, 966 | String description, 967 | String body, 968 | Optional> tagList 969 | ) { 970 | static CreateArticleRequest fromJson(Json json) { 971 | return field(json, "article", article -> new CreateArticleRequest( 972 | field(article, "title", string()), 973 | field(article, "description", string()), 974 | field(article, "body", string()), 975 | optionalField(article, "tagList", array(string())) 976 | )); 977 | } 978 | } 979 | 980 | String articleSlug(String title) { 981 | var sb = new StringBuilder(Slugify.builder().build().slugify(title)); 982 | sb.append("-"); 983 | for (int i = 0; i < 8; i++) { 984 | sb.append(ThreadLocalRandom.current().nextInt(10)); 985 | } 986 | return sb.toString(); 987 | } 988 | 989 | void createArticleHandler(HttpExchange exchange) throws IOException { 990 | var userId = getUserId(exchange); 991 | if (userId == null) { 992 | unauthenticated(exchange); 993 | return; 994 | } 995 | 996 | var body = JsonBody.read(exchange, CreateArticleRequest::fromJson); 997 | 998 | try (var conn = db.getConnection()) { 999 | conn.setAutoCommit(false); 1000 | 1001 | var articleId = UUID.randomUUID(); 1002 | try (var stmt = conn.prepareStatement(""" 1003 | INSERT INTO realworld.article(id, user_id, title, slug, description, body) 1004 | VALUES (?, ?, ?, ?, ?, ?) 1005 | """)) { 1006 | stmt.setObject(1, articleId); 1007 | stmt.setObject(2, userId); 1008 | stmt.setString(3, body.title); 1009 | stmt.setString(4, articleSlug(body.title)); 1010 | stmt.setString(5, body.description); 1011 | stmt.setString(6, body.body); 1012 | stmt.execute(); 1013 | } 1014 | 1015 | 1016 | var tagIds = new ArrayList(); 1017 | 1018 | var tagList = body.tagList.orElse(List.of()); 1019 | for (var tag : tagList) { 1020 | try (var stmt = conn.prepareStatement(""" 1021 | INSERT INTO realworld.tag(name) 1022 | VALUES (?) 1023 | ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name 1024 | RETURNING id 1025 | """)) { 1026 | stmt.setString(1, tag); 1027 | var rs = stmt.executeQuery(); 1028 | rs.next(); 1029 | tagIds.add(rs.getObject("id", UUID.class)); 1030 | } 1031 | } 1032 | 1033 | 1034 | try (var stmt = conn.prepareStatement(""" 1035 | INSERT INTO realworld.article_tag(article_id, tag_id) 1036 | VALUES (?, ?) 1037 | """)) { 1038 | for (var tagId : tagIds) { 1039 | stmt.setObject(1, articleId); 1040 | stmt.setObject(2, tagId); 1041 | stmt.addBatch(); 1042 | stmt.clearParameters(); 1043 | } 1044 | 1045 | stmt.executeBatch(); 1046 | } 1047 | 1048 | conn.commit(); 1049 | 1050 | try (var stmt = conn.prepareStatement(""" 1051 | SELECT 1052 | jsonb_build_object( 1053 | 'article', jsonb_build_object( 1054 | 'slug', realworld.article.slug, 1055 | 'title', realworld.article.title, 1056 | 'description', realworld.article.description, 1057 | 'body', realworld.article.body, 1058 | 'tagList', array( 1059 | SELECT realworld.tag.name 1060 | FROM realworld.article_tag 1061 | LEFT JOIN realworld.tag ON realworld.tag.id = realworld.article_tag.tag_id 1062 | WHERE realworld.article_tag.article_id = realworld.article.id 1063 | ORDER BY realworld.tag.name 1064 | ), 1065 | 'createdAt', realworld.article.created_at, 1066 | 'updatedAt', realworld.article.updated_at, 1067 | 'favorited', exists( 1068 | SELECT id 1069 | FROM realworld.favorite 1070 | WHERE article_id = realworld.article.id AND user_id = ? 1071 | ), 1072 | 'favoritesCount', ( 1073 | SELECT count(id) 1074 | FROM realworld.favorite 1075 | WHERE article_id = realworld.article.id 1076 | ), 1077 | 'author', ( 1078 | SELECT jsonb_build_object( 1079 | 'username', realworld."user".username, 1080 | 'bio', realworld."user".bio, 1081 | 'image', realworld."user".image, 1082 | 'following', exists( 1083 | SELECT id 1084 | FROM realworld.follow 1085 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 1086 | ) 1087 | ) 1088 | FROM realworld."user" 1089 | WHERE realworld."user".id = article.user_id 1090 | ) 1091 | )) 1092 | FROM realworld.article 1093 | WHERE deleted = false AND id = ? 1094 | """)) { 1095 | stmt.setObject(1, userId); 1096 | stmt.setObject(2, userId); 1097 | stmt.setObject(3, articleId); 1098 | 1099 | var rs = stmt.executeQuery(); 1100 | rs.next(); 1101 | 1102 | HttpExchanges.sendResponse(exchange, 200, JsonBody.of( 1103 | Json.read(rs.getObject(1).toString()) 1104 | )); 1105 | } 1106 | } catch (SQLException e) { 1107 | throw new UncheckedSQLException(e); 1108 | } 1109 | } 1110 | 1111 | record UpdateArticleRequest( 1112 | Optional title, 1113 | Optional description, 1114 | Optional body 1115 | ) { 1116 | static UpdateArticleRequest fromJson(Json json) { 1117 | return field(json, "article", article -> new UpdateArticleRequest( 1118 | optionalField(article, "title", string()), 1119 | optionalField(article, "description", string()), 1120 | optionalField(article, "body", string()) 1121 | )); 1122 | } 1123 | 1124 | boolean anyUpdates() { 1125 | return title.isPresent() || description.isPresent() || body.isPresent(); 1126 | } 1127 | } 1128 | 1129 | void updateArticleHandler(HttpExchange exchange) throws IOException { 1130 | var userId = getUserId(exchange); 1131 | if (userId == null) { 1132 | unauthenticated(exchange); 1133 | return; 1134 | } 1135 | 1136 | var slug = RouteParams.get(exchange).param("slug").orElseThrow(); 1137 | var body = JsonBody.read(exchange, UpdateArticleRequest::fromJson); 1138 | 1139 | try (var conn = db.getConnection()) { 1140 | UUID articleId = null; 1141 | if (body.anyUpdates()) { 1142 | var sets = new ArrayList(); 1143 | 1144 | body.title.ifPresent(title -> { 1145 | sets.add(SQLFragment.of("title = ?, slug = ?", List.of(title, articleSlug(title))) 1146 | ); 1147 | }); 1148 | 1149 | body.description.ifPresent(description -> { 1150 | sets.add(SQLFragment.of("description = ?", List.of(description))); 1151 | }); 1152 | 1153 | body.body.ifPresent(description -> { 1154 | sets.add(SQLFragment.of("body = ?", List.of(description))); 1155 | }); 1156 | 1157 | var sql = SQLFragment.of( 1158 | """ 1159 | UPDATE realworld.article 1160 | SET 1161 | \s\s\s\s 1162 | """ 1163 | ) 1164 | .concat(SQLFragment.join(", ", sets)) 1165 | .concat(SQLFragment.of(""" 1166 | 1167 | WHERE slug = ? 1168 | RETURNING id 1169 | """, List.of(slug))); 1170 | 1171 | try (var stmt = sql.prepareStatement(conn)) { 1172 | var rs = stmt.executeQuery(); 1173 | if (rs.next()) { 1174 | articleId = rs.getObject("id", UUID.class); 1175 | } 1176 | } 1177 | } 1178 | 1179 | if (articleId == null) { 1180 | HttpExchanges.sendResponse( 1181 | exchange, 1182 | 401, 1183 | JsonBody.of( 1184 | Json.objectBuilder() 1185 | .put("errors", Json.objectBuilder() 1186 | .put("body", Json.arrayBuilder() 1187 | .add("No matching article"))) 1188 | ) 1189 | ); 1190 | return; 1191 | } 1192 | 1193 | try (var stmt = conn.prepareStatement(""" 1194 | SELECT 1195 | jsonb_build_object( 1196 | 'article', jsonb_build_object( 1197 | 'slug', realworld.article.slug, 1198 | 'title', realworld.article.title, 1199 | 'description', realworld.article.description, 1200 | 'body', realworld.article.body, 1201 | 'tagList', array( 1202 | SELECT realworld.tag.name 1203 | FROM realworld.article_tag 1204 | LEFT JOIN realworld.tag ON realworld.tag.id = realworld.article_tag.tag_id 1205 | WHERE realworld.article_tag.article_id = realworld.article.id 1206 | ORDER BY realworld.tag.name 1207 | ), 1208 | 'createdAt', realworld.article.created_at, 1209 | 'updatedAt', realworld.article.updated_at, 1210 | 'favorited', exists( 1211 | SELECT id 1212 | FROM realworld.favorite 1213 | WHERE article_id = realworld.article.id AND user_id = ? 1214 | ), 1215 | 'favoritesCount', ( 1216 | SELECT count(id) 1217 | FROM realworld.favorite 1218 | WHERE article_id = realworld.article.id 1219 | ), 1220 | 'author', ( 1221 | SELECT jsonb_build_object( 1222 | 'username', realworld."user".username, 1223 | 'bio', realworld."user".bio, 1224 | 'image', realworld."user".image, 1225 | 'following', exists( 1226 | SELECT id 1227 | FROM realworld.follow 1228 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 1229 | ) 1230 | ) 1231 | FROM realworld."user" 1232 | WHERE realworld."user".id = realworld.article.user_id 1233 | ) 1234 | )) 1235 | FROM realworld.article 1236 | WHERE deleted = false AND id = ? 1237 | """)) { 1238 | stmt.setObject(1, userId); 1239 | stmt.setObject(2, userId); 1240 | stmt.setObject(3, articleId); 1241 | 1242 | var rs = stmt.executeQuery(); 1243 | rs.next(); 1244 | 1245 | HttpExchanges.sendResponse(exchange, 200, JsonBody.of( 1246 | Json.read(rs.getObject(1).toString()) 1247 | )); 1248 | } 1249 | } catch (SQLException e) { 1250 | throw new UncheckedSQLException(e); 1251 | } 1252 | } 1253 | 1254 | void deleteArticleHandler(HttpExchange exchange) throws IOException { 1255 | var userId = getUserId(exchange); 1256 | if (userId == null) { 1257 | unauthenticated(exchange); 1258 | return; 1259 | } 1260 | var slug = RouteParams.get(exchange).param("slug").orElseThrow(); 1261 | 1262 | try (var conn = db.getConnection(); 1263 | var stmt = conn.prepareStatement(""" 1264 | UPDATE realworld.article 1265 | SET deleted = true 1266 | WHERE user_id = ? AND slug = ? 1267 | """)) { 1268 | stmt.setObject(1, userId); 1269 | stmt.setObject(2, slug); 1270 | if (stmt.executeUpdate() == 0) { 1271 | HttpExchanges.sendResponse( 1272 | exchange, 1273 | 401, 1274 | JsonBody.of( 1275 | Json.objectBuilder() 1276 | .put("errors", Json.objectBuilder() 1277 | .put("body", Json.arrayBuilder() 1278 | .add("could not delete article"))) 1279 | ) 1280 | ); 1281 | return; 1282 | } 1283 | 1284 | HttpExchanges.sendResponse( 1285 | exchange, 1286 | 200, 1287 | JsonBody.of(Json.ofNull()) 1288 | ); 1289 | } catch (SQLException e) { 1290 | throw new UncheckedSQLException(e); 1291 | } 1292 | } 1293 | 1294 | void addCommentsToArticleHandler(HttpExchange exchange) throws IOException { 1295 | var userId = getUserId(exchange); 1296 | if (userId == null) { 1297 | unauthenticated(exchange); 1298 | return; 1299 | } 1300 | 1301 | var slug = RouteParams.get(exchange).param("slug").orElseThrow(); 1302 | 1303 | var body = field(JsonBody.read(exchange), "comment", field("body", string())); 1304 | 1305 | try (var conn = db.getConnection()) { 1306 | UUID articleId; 1307 | try (var stmt = conn.prepareStatement(""" 1308 | SELECT id 1309 | FROM realworld.article 1310 | WHERE slug = ? 1311 | """)) { 1312 | stmt.setObject(1, slug); 1313 | var rs = stmt.executeQuery(); 1314 | if (!rs.next()) { 1315 | HttpExchanges.sendResponse( 1316 | exchange, 1317 | 401, 1318 | JsonBody.of( 1319 | Json.objectBuilder() 1320 | .put("errors", Json.objectBuilder() 1321 | .put("body", Json.arrayBuilder() 1322 | .add("No matching article"))) 1323 | ) 1324 | ); 1325 | return; 1326 | } 1327 | 1328 | articleId = rs.getObject("id", UUID.class); 1329 | } 1330 | 1331 | var commentId = UUID.randomUUID(); 1332 | try (var stmt = conn.prepareStatement(""" 1333 | INSERT INTO realworld.comment(id, article_id, user_id, body) 1334 | VALUES (?, ?, ?, ?) 1335 | RETURNING id 1336 | """)) { 1337 | stmt.setObject(1, commentId); 1338 | stmt.setObject(2, articleId); 1339 | stmt.setObject(3, userId); 1340 | stmt.setString(4, body); 1341 | stmt.execute(); 1342 | } 1343 | try (var stmt = conn.prepareStatement(""" 1344 | SELECT 1345 | jsonb_build_object( 1346 | 'comment', ( 1347 | SELECT jsonb_build_object( 1348 | 'id', realworld.comment.id, 1349 | 'createdAt', realworld.comment.created_at, 1350 | 'updatedAt', realworld.comment.updated_at, 1351 | 'body', realworld.comment.body, 1352 | 'author', ( 1353 | SELECT jsonb_build_object( 1354 | 'username', realworld."user".username, 1355 | 'bio', realworld."user".bio, 1356 | 'image', realworld."user".image, 1357 | 'following', exists( 1358 | SELECT id 1359 | FROM realworld.follow 1360 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 1361 | ) 1362 | ) 1363 | FROM realworld."user" 1364 | WHERE realworld."user".id = realworld.comment.user_id 1365 | ) 1366 | ) 1367 | FROM realworld.comment 1368 | WHERE realworld.comment.id = ? 1369 | ) 1370 | ) 1371 | """)) { 1372 | stmt.setObject(1, userId); 1373 | stmt.setObject(2, commentId); 1374 | 1375 | var rs = stmt.executeQuery(); 1376 | rs.next(); 1377 | 1378 | HttpExchanges.sendResponse(exchange, 200, JsonBody.of( 1379 | Json.read(rs.getObject(1).toString()) 1380 | )); 1381 | } 1382 | 1383 | } catch (SQLException e) { 1384 | throw new UncheckedSQLException(e); 1385 | } 1386 | } 1387 | 1388 | void getCommentsFromArticleHandler(HttpExchange exchange) throws IOException { 1389 | var userId = getUserId(exchange); 1390 | var slug = RouteParams.get(exchange).param("slug").orElseThrow(); 1391 | 1392 | try (var conn = db.getConnection(); 1393 | var stmt = conn.prepareStatement(""" 1394 | SELECT 1395 | jsonb_build_object( 1396 | 'comments', array( 1397 | SELECT jsonb_build_object( 1398 | 'id', realworld.comment.id, 1399 | 'createdAt', realworld.comment.created_at, 1400 | 'updatedAt', realworld.comment.updated_at, 1401 | 'body', realworld.comment.body, 1402 | 'author', ( 1403 | SELECT jsonb_build_object( 1404 | 'username', realworld."user".username, 1405 | 'bio', realworld."user".bio, 1406 | 'image', realworld."user".image, 1407 | 'following', exists( 1408 | SELECT id 1409 | FROM realworld.follow 1410 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 1411 | ) 1412 | ) 1413 | FROM realworld."user" 1414 | WHERE realworld."user".id = realworld.comment.user_id 1415 | ) 1416 | ) 1417 | FROM realworld.comment 1418 | WHERE realworld.comment.article_id = realworld.article.id 1419 | ) 1420 | ) 1421 | FROM realworld.article WHERE realworld.article.slug = ? 1422 | """)) { 1423 | stmt.setObject(1, userId); 1424 | stmt.setString(2, slug); 1425 | 1426 | var rs = stmt.executeQuery(); 1427 | 1428 | if (!rs.next()) { 1429 | HttpExchanges.sendResponse( 1430 | exchange, 1431 | 401, 1432 | JsonBody.of( 1433 | Json.objectBuilder() 1434 | .put("errors", Json.objectBuilder() 1435 | .put("body", Json.arrayBuilder() 1436 | .add("No matching article"))) 1437 | ) 1438 | ); 1439 | return; 1440 | } 1441 | 1442 | HttpExchanges.sendResponse(exchange, 200, JsonBody.of( 1443 | Json.read(rs.getObject(1).toString()) 1444 | )); 1445 | } catch (SQLException e) { 1446 | throw new UncheckedSQLException(e); 1447 | } 1448 | } 1449 | 1450 | void deleteCommentHandler(HttpExchange exchange) throws IOException { 1451 | var userId = getUserId(exchange); 1452 | if (userId == null) { 1453 | unauthenticated(exchange); 1454 | return; 1455 | } 1456 | var routeParams = RouteParams.get(exchange); 1457 | var slug = routeParams.param("slug").orElseThrow(); 1458 | var commentId = UUID.fromString(routeParams.param("commentId").orElseThrow()); 1459 | 1460 | try (var conn = db.getConnection(); 1461 | var stmt = conn.prepareStatement(""" 1462 | UPDATE realworld.comment 1463 | SET deleted = true 1464 | WHERE 1465 | realworld.comment.id = ? AND 1466 | realworld.comment.user_id = ? AND 1467 | realworld.comment.article_id IN ( 1468 | SELECT id 1469 | FROM realworld.article 1470 | WHERE realworld.article.slug = ? 1471 | ) 1472 | """)) { 1473 | stmt.setObject(1, commentId); 1474 | stmt.setObject(2, userId); 1475 | stmt.setObject(3, slug); 1476 | 1477 | if (stmt.executeUpdate() == 0) { 1478 | HttpExchanges.sendResponse( 1479 | exchange, 1480 | 401, 1481 | JsonBody.of( 1482 | Json.objectBuilder() 1483 | .put("errors", Json.objectBuilder() 1484 | .put("body", Json.arrayBuilder() 1485 | .add("could not delete article"))) 1486 | ) 1487 | ); 1488 | return; 1489 | } 1490 | 1491 | HttpExchanges.sendResponse( 1492 | exchange, 1493 | 200, 1494 | JsonBody.of(Json.ofNull()) 1495 | ); 1496 | 1497 | } catch (SQLException e) { 1498 | throw new UncheckedSQLException(e); 1499 | } 1500 | } 1501 | 1502 | void favoriteArticleHandler(HttpExchange exchange) throws IOException { 1503 | var userId = getUserId(exchange); 1504 | if (userId == null) { 1505 | unauthenticated(exchange); 1506 | return; 1507 | } 1508 | 1509 | var slug = RouteParams.get(exchange).param("slug").orElseThrow(); 1510 | 1511 | try (var conn = db.getConnection()) { 1512 | UUID articleId; 1513 | try (var stmt = conn.prepareStatement(""" 1514 | SELECT id 1515 | FROM realworld.article 1516 | WHERE deleted = false AND slug = ? 1517 | """)) { 1518 | stmt.setObject(1, slug); 1519 | var rs = stmt.executeQuery(); 1520 | if (!rs.next()) { 1521 | HttpExchanges.sendResponse( 1522 | exchange, 1523 | 401, 1524 | JsonBody.of( 1525 | Json.objectBuilder() 1526 | .put("errors", Json.objectBuilder() 1527 | .put("body", Json.arrayBuilder() 1528 | .add("No matching article"))) 1529 | ) 1530 | ); 1531 | return; 1532 | } 1533 | 1534 | articleId = rs.getObject("id", UUID.class); 1535 | } 1536 | 1537 | try (var stmt = conn.prepareStatement(""" 1538 | INSERT INTO realworld.favorite(article_id, user_id) 1539 | VALUES (?, ?) 1540 | """)) { 1541 | stmt.setObject(1, articleId); 1542 | stmt.setObject(2, userId); 1543 | stmt.execute(); 1544 | } 1545 | 1546 | try (var stmt = conn.prepareStatement(""" 1547 | SELECT 1548 | jsonb_build_object( 1549 | 'article', jsonb_build_object( 1550 | 'slug', realworld.article.slug, 1551 | 'title', realworld.article.title, 1552 | 'description', realworld.article.description, 1553 | 'body', realworld.article.body, 1554 | 'tagList', array( 1555 | SELECT realworld.tag.name 1556 | FROM realworld.article_tag 1557 | LEFT JOIN realworld.tag ON realworld.tag.id = realworld.article_tag.tag_id 1558 | WHERE realworld.article_tag.article_id = realworld.article.id 1559 | ORDER BY realworld.tag.name 1560 | ), 1561 | 'createdAt', realworld.article.created_at, 1562 | 'updatedAt', realworld.article.updated_at, 1563 | 'favorited', exists( 1564 | SELECT id 1565 | FROM realworld.favorite 1566 | WHERE article_id = realworld.article.id AND user_id = ? 1567 | ), 1568 | 'favoritesCount', ( 1569 | SELECT count(id) 1570 | FROM realworld.favorite 1571 | WHERE article_id = realworld.article.id 1572 | ), 1573 | 'author', ( 1574 | SELECT jsonb_build_object( 1575 | 'username', realworld."user".username, 1576 | 'bio', realworld."user".bio, 1577 | 'image', realworld."user".image, 1578 | 'following', exists( 1579 | SELECT id 1580 | FROM realworld.follow 1581 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 1582 | ) 1583 | ) 1584 | FROM realworld."user" 1585 | WHERE realworld."user".id = realworld.article.user_id 1586 | ) 1587 | )) 1588 | FROM realworld.article 1589 | WHERE deleted = false AND id = ? 1590 | """)) { 1591 | stmt.setObject(1, userId); 1592 | stmt.setObject(2, userId); 1593 | stmt.setObject(3, articleId); 1594 | 1595 | var rs = stmt.executeQuery(); 1596 | rs.next(); 1597 | 1598 | HttpExchanges.sendResponse(exchange, 200, JsonBody.of( 1599 | Json.read(rs.getObject(1).toString()) 1600 | )); 1601 | } 1602 | } catch (SQLException e) { 1603 | throw new UncheckedSQLException(e); 1604 | } 1605 | } 1606 | 1607 | void unfavoriteArticleHandler(HttpExchange exchange) throws IOException { 1608 | var userId = getUserId(exchange); 1609 | if (userId == null) { 1610 | unauthenticated(exchange); 1611 | return; 1612 | } 1613 | 1614 | var slug = RouteParams.get(exchange).param("slug").orElseThrow(); 1615 | 1616 | try (var conn = db.getConnection()) { 1617 | UUID articleId; 1618 | try (var stmt = conn.prepareStatement(""" 1619 | SELECT id 1620 | FROM realworld.article 1621 | WHERE deleted = false AND slug = ? 1622 | """)) { 1623 | stmt.setObject(1, slug); 1624 | var rs = stmt.executeQuery(); 1625 | if (!rs.next()) { 1626 | HttpExchanges.sendResponse( 1627 | exchange, 1628 | 401, 1629 | JsonBody.of( 1630 | Json.objectBuilder() 1631 | .put("errors", Json.objectBuilder() 1632 | .put("body", Json.arrayBuilder() 1633 | .add("No matching article"))) 1634 | ) 1635 | ); 1636 | return; 1637 | } 1638 | 1639 | articleId = rs.getObject("id", UUID.class); 1640 | } 1641 | 1642 | try (var stmt = conn.prepareStatement(""" 1643 | DELETE FROM realworld.favorite 1644 | WHERE article_id = ? AND user_id = ? 1645 | """)) { 1646 | stmt.setObject(1, articleId); 1647 | stmt.setObject(2, userId); 1648 | stmt.execute(); 1649 | } 1650 | 1651 | try (var stmt = conn.prepareStatement(""" 1652 | SELECT 1653 | jsonb_build_object( 1654 | 'article', jsonb_build_object( 1655 | 'slug', realworld.article.slug, 1656 | 'title', realworld.article.title, 1657 | 'description', realworld.article.description, 1658 | 'body', realworld.article.body, 1659 | 'tagList', array( 1660 | SELECT realworld.tag.name 1661 | FROM realworld.article_tag 1662 | LEFT JOIN realworld.tag ON realworld.tag.id = realworld.article_tag.tag_id 1663 | WHERE realworld.article_tag.article_id = realworld.article.id 1664 | ORDER BY realworld.tag.name 1665 | ), 1666 | 'createdAt', realworld.article.created_at, 1667 | 'updatedAt', realworld.article.updated_at, 1668 | 'favorited', exists( 1669 | SELECT id 1670 | FROM realworld.favorite 1671 | WHERE article_id = realworld.article.id AND user_id = ? 1672 | ), 1673 | 'favoritesCount', ( 1674 | SELECT count(id) 1675 | FROM realworld.favorite 1676 | WHERE article_id = realworld.article.id 1677 | ), 1678 | 'author', ( 1679 | SELECT jsonb_build_object( 1680 | 'username', realworld."user".username, 1681 | 'bio', realworld."user".bio, 1682 | 'image', realworld."user".image, 1683 | 'following', exists( 1684 | SELECT id 1685 | FROM realworld.follow 1686 | WHERE from_user_id = ? AND to_user_id = realworld."user".id 1687 | ) 1688 | ) 1689 | FROM realworld."user" 1690 | WHERE realworld."user".id = realworld.article.user_id 1691 | ) 1692 | )) 1693 | FROM realworld.article 1694 | WHERE deleted = false AND id = ? 1695 | """)) { 1696 | stmt.setObject(1, userId); 1697 | stmt.setObject(2, userId); 1698 | stmt.setObject(3, articleId); 1699 | 1700 | var rs = stmt.executeQuery(); 1701 | rs.next(); 1702 | 1703 | HttpExchanges.sendResponse(exchange, 200, JsonBody.of( 1704 | Json.read(rs.getObject(1).toString()) 1705 | )); 1706 | } 1707 | } catch (SQLException e) { 1708 | throw new UncheckedSQLException(e); 1709 | } 1710 | } 1711 | 1712 | void getTagsHandler(HttpExchange exchange) throws IOException { 1713 | try (var conn = db.getConnection(); 1714 | var stmt = conn.prepareStatement(""" 1715 | SELECT name FROM realworld.tag 1716 | """)) { 1717 | var rs = stmt.executeQuery(); 1718 | 1719 | HttpExchanges.sendResponse( 1720 | exchange, 1721 | 200, 1722 | JsonBody.of( 1723 | Json.objectBuilder() 1724 | .put("tags", 1725 | ResultSets.getList( 1726 | rs, 1727 | r -> Json.of(r.getString("name")) 1728 | ) 1729 | ) 1730 | ) 1731 | ); 1732 | } catch (SQLException e) { 1733 | throw new UncheckedSQLException(e); 1734 | } 1735 | } 1736 | 1737 | void corsHandler(HttpExchange exchange) throws IOException { 1738 | var headers = exchange.getResponseHeaders(); 1739 | headers.put("Access-Control-Allow-Origin", List.of("*")); 1740 | headers.put("Access-Control-Allow-Headers", List.of("*")); 1741 | HttpExchanges.sendResponse( 1742 | exchange, 1743 | 200, 1744 | Body.empty() 1745 | ); 1746 | } 1747 | 1748 | public void register(RegexRouter.Builder builder) { 1749 | builder 1750 | .post("/api/users", this::registerHandler) 1751 | .post("/api/users/login", this::loginHandler) 1752 | .get("/api/user", this::getCurrentUserHandler) 1753 | .put("/api/user", this::updateUserHandler) 1754 | .get("/api/profiles/(?[a-zA-Z0-9-_]+)", this::getProfileHandler) 1755 | .post("/api/profiles/(?[a-zA-Z0-9-_]+)/follow", this::followUserHandler) 1756 | .delete("/api/profiles/(?[a-zA-Z0-9-_]+)/follow", this::unfollowUserHandler) 1757 | .get("/api/articles", this::listArticlesHandler) 1758 | .get("/api/articles/feed", this::feedArticlesHandler) 1759 | .get("/api/articles/(?[a-zA-Z0-9-_]+)", this::getArticleHandler) 1760 | .post("/api/articles", this::createArticleHandler) 1761 | .put("/api/articles/(?[a-zA-Z0-9-_]+)", this::updateArticleHandler) 1762 | .delete("/api/articles/(?[a-zA-Z0-9-_]+)", this::deleteArticleHandler) 1763 | .post("/api/articles/(?[a-zA-Z0-9-_]+)/comments", this::addCommentsToArticleHandler) 1764 | .get("/api/articles/(?[a-zA-Z0-9-_]+)/comments", this::getCommentsFromArticleHandler) 1765 | .delete("/api/articles/(?[a-zA-Z0-9-_]+)/comments/(?[a-zA-Z0-9-_]+)", this::deleteCommentHandler) 1766 | .post("/api/articles/(?[a-zA-Z0-9-_]+)/favorite", this::favoriteArticleHandler) 1767 | .delete("/api/articles/(?[a-zA-Z0-9-_]+)/favorite", this::unfavoriteArticleHandler) 1768 | .get("/api/tags", this::getTagsHandler) 1769 | .options("/api/.+", this::corsHandler); 1770 | } 1771 | } 1772 | -------------------------------------------------------------------------------- /src/main/java/module-info.java: -------------------------------------------------------------------------------- 1 | module dev.mccue.jdk.httpserver.realworld { 2 | /// Password hash + salt 3 | requires bcrypt; 4 | /// Database connection pooling 5 | requires com.zaxxer.hikari; 6 | /// JDBC extensions 7 | requires dev.mccue.jdbc; 8 | /// JSON support for jdk.httpserver 9 | requires dev.mccue.jdk.httpserver.json; 10 | /// Basic router 11 | requires dev.mccue.jdk.httpserver.regexrouter; 12 | /// JSON library 13 | requires dev.mccue.json; 14 | /// Parse query params 15 | requires dev.mccue.urlparameters; 16 | /// Load .env files 17 | requires io.github.cdimascio.dotenv.java; 18 | /// JDBC 19 | requires java.sql; 20 | /// HTTP server 21 | requires jdk.httpserver; 22 | /// IDE Friendly Annotations 23 | requires org.jetbrains.annotations; 24 | /// Logging Facade 25 | requires org.slf4j; 26 | /// Turns text into url safe slug 27 | requires slugify; 28 | /// Postgres Driver 29 | requires org.postgresql.jdbc; 30 | /// Jetty backing implementation 31 | requires org.eclipse.jetty.http.spi; 32 | 33 | exports dev.mccue.jdk.httpserver.realworld; 34 | } --------------------------------------------------------------------------------