├── .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 | # 
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 | }
--------------------------------------------------------------------------------