Skip to content

Commit

Permalink
Support count endpoint (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
QubitPi authored Oct 9, 2024
1 parent 70c525a commit d8a41b8
Show file tree
Hide file tree
Showing 7 changed files with 285 additions and 36 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ curl -i -k -X POST https://api.paion-data.dev:8444/services/wilhelm-ws-expand/ro
--data name=wilhelm-ws-expand
```

We should see `HTTP/1.1 201 Created` as signs of success.

#### Example requests:

- https://api.paion-data.dev/wilhelm/languages/german?perPage=100&page=1
Expand Down
92 changes: 92 additions & 0 deletions src/main/java/org/qubitpi/wilhelm/Language.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright Jiaqi Liu
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.qubitpi.wilhelm;

import jakarta.validation.constraints.NotNull;
import net.jcip.annotations.Immutable;
import net.jcip.annotations.ThreadSafe;

import java.util.Arrays;
import java.util.stream.Collectors;

/**
* A natural language represented in webservice that bridges the client request data format to database request format.
*/
@Immutable
@ThreadSafe
public enum Language {

/**
* German language.
*/
GERMAN("german", "German"),

/**
* Ancient Greek.
*/
ANCIENT_GREEK("ancientGreek", "Ancient Greek"),

/**
* Latin.
*/
LATIN("latin", "Latin");

private final String pathName;
private final String databaseName;

/**
* All-args constructor.
*
* @param pathName The client-side language name
* @param databaseName The database language name
*/
Language(@NotNull final String pathName, @NotNull final String databaseName) {
this.pathName = pathName;
this.databaseName = databaseName;
}

/**
* Constructs a {@link Language} from its client-side name.
*
* @param language The client-side requested language name
*
* @return a new instance
*
* @throws IllegalArgumentException if the language name is not a valid one
*/
public static Language ofClientValue(@NotNull final String language) throws IllegalArgumentException {
return Arrays.stream(values())
.filter(value -> value.pathName.equals(language))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
String.format(
"'%s' is not a recognized language. Acceptable ones are %s",
language,
Arrays.stream(values()).map(Language::getPathName).collect(Collectors.joining(", ")
)
)));
}

@NotNull
public String getPathName() {
return pathName;
}

@NotNull
public String getDatabaseName() {
return databaseName;
}
}
36 changes: 36 additions & 0 deletions src/main/java/org/qubitpi/wilhelm/LanguageCheck.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright Jiaqi Liu
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.qubitpi.wilhelm;

import jakarta.ws.rs.NameBinding;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* An annotation used exclusively by {@link org.qubitpi.wilhelm.web.filters.LanguageCheckFilter}.
*
* @see org.qubitpi.wilhelm.web.filters.LanguageCheckFilter
*/
@NameBinding
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface LanguageCheck {

// intentionally left blank
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.qubitpi.wilhelm.web.filters.CorsFilter;

import org.glassfish.hk2.utilities.Binder;
import org.qubitpi.wilhelm.web.filters.LanguageCheckFilter;

import jakarta.inject.Inject;
import jakarta.ws.rs.ApplicationPath;
Expand All @@ -42,6 +43,7 @@ public ResourceConfig() {
packages(ENDPOINT_PACKAGE);

register(CorsFilter.class);
register(LanguageCheckFilter.class);

final Binder binder = new BinderFactory().buildBinder();
register(binder);
Expand Down
91 changes: 55 additions & 36 deletions src/main/java/org/qubitpi/wilhelm/web/endpoints/DataServlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
import org.neo4j.driver.QueryConfig;
import org.neo4j.driver.Value;
import org.neo4j.driver.internal.types.InternalTypeSystem;
import org.qubitpi.wilhelm.Language;
import org.qubitpi.wilhelm.LanguageCheck;
import org.qubitpi.wilhelm.config.ApplicationConfig;

import jakarta.inject.Inject;
Expand All @@ -43,7 +45,6 @@
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
Expand All @@ -64,13 +65,6 @@ public class DataServlet {
private static final String NEO4J_PASSWORD = APPLICATION_CONFIG.neo4jPassword();
private static final String NEO4J_DATABASE = APPLICATION_CONFIG.neo4jDatabase();

private static final Map<String, String> LANGUAGES = Stream.of(
new AbstractMap.SimpleImmutableEntry<>("german", "German"),
new AbstractMap.SimpleImmutableEntry<>("ancientGreek", "Ancient Greek"),
new AbstractMap.SimpleImmutableEntry<>("latin", "Latin")
)
.collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue));


/**
* Constructor for dependency injection.
Expand All @@ -93,59 +87,62 @@ public Response healthcheck() {
.build();
}

/**
* Returns the total number of terms of a specified langauges.
*
* @param language The language. Must be one of the valid return value of {@link Language#getPathName()}; otherwise
* a 400 response is returned
*
* @return a list of one map entry, whose key is 'count' and value is the total
*/
@GET
@LanguageCheck
@Path("/languages/{language}/count")
@Produces(MediaType.APPLICATION_JSON)
public Response getCountByLanguage(@NotNull @PathParam("language") final String language) {
final Language requestedLanguage = Language.ofClientValue(language);

final String query = String.format(
"MATCH (term:Term {language: '%s'}) RETURN count(*) as count", requestedLanguage.getDatabaseName()
);

return Response
.status(Response.Status.OK)
.entity(executeNonPathQuery(query))
.build();
}

/**
* Get paginated vocabularies of a language.
*
* @param language The language. Must be one of "german", "ancientGreek", or "latin". Otherwise a 404 response is
* returned
* @param language The language. Must be one of the valid return value of {@link Language#getPathName()}; otherwise
* a 400 response is returned
* @param perPage Requested number of words to be displayed on each page of results
* @param page Requested page of results desired
*
* @return the paginated Neo4J query results in JSON format
*/
@GET
@LanguageCheck
@Path("/languages/{language}")
@Produces(MediaType.APPLICATION_JSON)
public Response getVocabularyByLanguagePaged(
@NotNull @PathParam("language") final String language,
@NotNull @QueryParam("perPage") final String perPage,
@NotNull @QueryParam("page") final String page
) {
if (!LANGUAGES.containsKey(Objects.requireNonNull(language, "language"))) {
return Response
.status(Response.Status.BAD_REQUEST)
.entity(
String.format(
"'%s' is not a recognized language. Acceptable ones are %s",
language,
String.join(", ", LANGUAGES.keySet())
)
)
.build();
}
final Language requestedLanguage = Language.ofClientValue(language);

final String query = String.format(
"MATCH (t:Term WHERE t.language = '%s')-[r]->(d:Definition) " +
"RETURN t.name AS term, d.name AS definition " +
"SKIP %s LIMIT %s",
LANGUAGES.get(language), (Integer.parseInt(page) - 1) * Integer.parseInt(perPage), perPage
requestedLanguage.getDatabaseName(), (Integer.parseInt(page) - 1) * Integer.parseInt(perPage), perPage
);

final EagerResult result = executeNativeQuery(query);

final Object responseBody = result.records()
.stream()
.map(
record -> record.keys()
.stream()
.map(key -> new AbstractMap.SimpleImmutableEntry<>(key, expand(record.get(key))))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))
)
.collect(Collectors.toList());

return Response
.status(Response.Status.OK)
.entity(responseBody)
.entity(executeNonPathQuery(query))
.build();
}

Expand Down Expand Up @@ -214,6 +211,28 @@ RETURN path, length(path) AS hops
.build();
}

/**
* Runs a cypher query against Neo4J database and return result as a JSON-serializable.
* <p>
* Use this method only if the {@code query} does not involve path, because this method cannot handle query result
* that has path object nested in it
*
* @param query A standard cypher query string
*
* @return query's native result
*/
private Object executeNonPathQuery(@NotNull final String query) {
return executeNativeQuery(query).records()
.stream()
.map(
record -> record.keys()
.stream()
.map(key -> new AbstractMap.SimpleImmutableEntry<>(key, expand(record.get(key))))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))
)
.collect(Collectors.toList());
}

/**
* Runs a cypher query against Neo4J database and return the query result unmodified.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright Jiaqi Liu
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.qubitpi.wilhelm.web.filters;

import org.qubitpi.wilhelm.Language;
import org.qubitpi.wilhelm.LanguageCheck;

import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import net.jcip.annotations.Immutable;
import net.jcip.annotations.ThreadSafe;

/**
* A {@link ContainerRequestFilter} that validates the {@code language} path param in endpoint requests and only applies
* to those with "languages/{language}" in the middle of its endpoint path, for example "/languages/{language}/count".
* <p>
* Endpoints wishing to be validated by this filter must satisfy 2 requirements:
* <ol>
* <li> having a {@link jakarta.ws.rs.PathParam "language" @PathParam}
* <li> the resource/endpoint method has been annotated with {@link LanguageCheck}
* </ol>
*/
@Immutable
@ThreadSafe
@Provider
@LanguageCheck
public class LanguageCheckFilter implements ContainerRequestFilter {

@Override
public void filter(final ContainerRequestContext containerRequestContext) {
final String requestedLanguage = containerRequestContext
.getUriInfo()
.getPathParameters()
.get("language")
.get(0);
try {
Language.ofClientValue(requestedLanguage);
} catch (final IllegalArgumentException exception) {
containerRequestContext.abortWith(
Response
.status(Response.Status.BAD_REQUEST)
.entity(exception.getMessage())
.build()
);
}
}
}
Loading

0 comments on commit d8a41b8

Please sign in to comment.