Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion spring-batch-notion/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,16 @@
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.batch</groupId>
<artifactId>spring-batch-infrastructure</artifactId>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>com.h2database</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,19 @@
*/
package org.springframework.batch.extensions.notion;

import notion.api.v1.NotionClient;
import notion.api.v1.http.JavaNetHttpClient;
import notion.api.v1.logging.Slf4jLogger;
import notion.api.v1.model.databases.QueryResults;
import notion.api.v1.model.databases.query.filter.QueryTopLevelFilter;
import notion.api.v1.model.databases.query.sort.QuerySort;
import notion.api.v1.model.pages.Page;
import notion.api.v1.model.pages.PageProperty;
import notion.api.v1.model.pages.PageProperty.RichText;
import notion.api.v1.request.databases.QueryDatabaseRequest;
import org.jspecify.annotations.Nullable;
import org.springframework.batch.extensions.notion.PageProperty.RichTextProperty;
import org.springframework.batch.extensions.notion.PageProperty.TitleProperty;
import org.springframework.batch.extensions.notion.mapping.PropertyMapper;
import org.springframework.batch.infrastructure.item.ExecutionContext;
import org.springframework.batch.infrastructure.item.ItemReader;
import org.springframework.batch.infrastructure.item.data.AbstractPaginatedDataItemReader;
import org.springframework.http.HttpHeaders;
import org.springframework.util.Assert;
import org.springframework.web.client.ApiVersionInserter;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;

import java.util.Collections;
import java.util.Iterator;
Expand All @@ -39,7 +36,6 @@
import java.util.Map.Entry;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
* Restartable {@link ItemReader} that reads entries from a Notion database via a paging
Expand Down Expand Up @@ -71,11 +67,11 @@ public class NotionDatabaseItemReader<T> extends AbstractPaginatedDataItemReader

private String baseUrl = DEFAULT_BASE_URL;

private @Nullable QueryTopLevelFilter filter;
private @Nullable Filter filter;

private @Nullable List<QuerySort> sorts;
private Sort[] sorts = new Sort[0];

private @Nullable NotionClient client;
private @Nullable NotionDatabaseService service;

private boolean hasMore;

Expand Down Expand Up @@ -117,7 +113,7 @@ public void setBaseUrl(String baseUrl) {
* @see Filter#where(Filter)
*/
public void setFilter(Filter filter) {
this.filter = filter.toQueryTopLevelFilter();
this.filter = filter;
}

/**
Expand All @@ -130,7 +126,7 @@ public void setFilter(Filter filter) {
* @see Sort#by(Sort.Timestamp)
*/
public void setSorts(Sort... sorts) {
this.sorts = Stream.of(sorts).map(Sort::toQuerySort).toList();
this.sorts = sorts;
}

/**
Expand All @@ -151,10 +147,15 @@ public void setPageSize(int pageSize) {
*/
@Override
protected void doOpen() {
client = new NotionClient(token);
client.setHttpClient(new JavaNetHttpClient());
client.setLogger(new Slf4jLogger());
client.setBaseUrl(baseUrl);
RestClient restClient = RestClient.builder()
.baseUrl(baseUrl)
.apiVersionInserter(ApiVersionInserter.useHeader("Notion-Version"))
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.build();

RestClientAdapter adapter = RestClientAdapter.create(restClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
service = factory.createClient(NotionDatabaseService.class);

hasMore = true;
}
Expand All @@ -168,53 +169,47 @@ protected Iterator<T> doPageRead() {
return Collections.emptyIterator();
}

QueryDatabaseRequest request = new QueryDatabaseRequest(databaseId);
request.setFilter(filter);
request.setSorts(sorts);
request.setStartCursor(nextCursor);
request.setPageSize(pageSize);
QueryRequest request = new QueryRequest(pageSize, nextCursor, filter, sorts);

@SuppressWarnings("DataFlowIssue")
QueryResults queryResults = client.queryDatabase(request);
QueryResult result = service.query(databaseId, request);

hasMore = queryResults.getHasMore();
nextCursor = queryResults.getNextCursor();
hasMore = result.hasMore();
nextCursor = result.nextCursor();

return queryResults.getResults()
return result.results()
.stream()
.map(NotionDatabaseItemReader::getProperties)
.map(propertyMapper::map)
.iterator();
}

private static Map<String, String> getProperties(Page element) {
return element.getProperties()
private static Map<String, String> getProperties(Page page) {
return page.properties()
.entrySet()
.stream()
.collect(Collectors.toUnmodifiableMap(Entry::getKey, entry -> getPropertyValue(entry.getValue())));
}

private static String getPropertyValue(PageProperty property) {
return switch (property.getType()) {
case RichText -> getPlainText(property.getRichText());
case Title -> getPlainText(property.getTitle());
default -> throw new IllegalArgumentException("Unsupported type: " + property.getType());
};
if (property instanceof RichTextProperty p) {
return getPlainText(p.richText());
}
if (property instanceof TitleProperty p) {
return getPlainText(p.title());
}
throw new IllegalArgumentException("Unsupported type: " + property.getClass());
}

private static String getPlainText(List<RichText> texts) {
return texts.isEmpty() ? "" : texts.get(0).getPlainText();
return texts.isEmpty() ? "" : texts.get(0).plainText();
}

/**
* {@inheritDoc}
*/
@SuppressWarnings("DataFlowIssue")
@Override
protected void doClose() {
client.close();
client = null;

hasMore = false;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright 2024-2025 the original author or authors.
*
* 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
*
* https://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.springframework.batch.extensions.notion;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;

@HttpExchange(url = "/databases", version = "2022-06-28", accept = MediaType.APPLICATION_JSON_VALUE)
interface NotionDatabaseService {

@PostExchange("/{databaseId}/query")
QueryResult query(@PathVariable String databaseId, @RequestBody QueryRequest request);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.springframework.batch.extensions.notion;

import java.util.Map;

record Page(Map<String, PageProperty> properties) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.springframework.batch.extensions.notion;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
import com.fasterxml.jackson.annotation.JsonTypeName;
import tools.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import tools.jackson.databind.annotation.JsonNaming;

import java.util.List;

@JsonTypeInfo(use = Id.NAME, property = "type")
interface PageProperty {

@JsonTypeName("rich_text")
@JsonNaming(SnakeCaseStrategy.class)
record RichTextProperty(List<RichText> richText) implements PageProperty {
}

@JsonTypeName("title")
record TitleProperty(List<RichText> title) implements PageProperty {
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.springframework.batch.extensions.notion;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import org.jspecify.annotations.Nullable;
import tools.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import tools.jackson.databind.annotation.JsonNaming;

import java.util.List;

@JsonNaming(SnakeCaseStrategy.class)
@JsonInclude(Include.NON_EMPTY)
record QueryRequest(int pageSize, @Nullable String startCursor, @Nullable Filter filter, Sort... sorts) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.springframework.batch.extensions.notion;

import tools.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import tools.jackson.databind.annotation.JsonNaming;

import java.util.List;

@JsonNaming(SnakeCaseStrategy.class)
record QueryResult(List<Page> results, String nextCursor, boolean hasMore) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.springframework.batch.extensions.notion;

import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.annotation.JsonNaming;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
record RichText(String plainText) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
*/
package org.springframework.batch.extensions.notion;

import notion.api.v1.model.databases.query.sort.QuerySort;
import notion.api.v1.model.databases.query.sort.QuerySortDirection;
import notion.api.v1.model.databases.query.sort.QuerySortTimestamp;
import com.fasterxml.jackson.annotation.JsonProperty;
import tools.jackson.databind.EnumNamingStrategies;
import tools.jackson.databind.EnumNamingStrategies.SnakeCaseStrategy;
import tools.jackson.databind.annotation.EnumNaming;

import java.util.Objects;

Expand Down Expand Up @@ -81,78 +82,55 @@ public static Sort by(Timestamp timestamp) {
/**
* Timestamps associated with database entries.
*/
@EnumNaming(SnakeCaseStrategy.class)
public enum Timestamp {

/**
* The time the entry was created.
*/
CREATED_TIME(QuerySortTimestamp.CreatedTime),
CREATED_TIME,

/**
* The time the entry was last edited.
*/
LAST_EDITED_TIME(QuerySortTimestamp.LastEditedTime);

private final QuerySortTimestamp querySortTimestamp;

Timestamp(QuerySortTimestamp querySortTimestamp) {
this.querySortTimestamp = querySortTimestamp;
}

private QuerySortTimestamp getQuerySortTimestamp() {
return querySortTimestamp;
}
LAST_EDITED_TIME;

}

/**
* Sort directions.
*/
@EnumNaming(SnakeCaseStrategy.class)
public enum Direction {

/**
* Ascending direction.
*/
ASCENDING(QuerySortDirection.Ascending),
ASCENDING,

/**
* Descending direction.
*/
DESCENDING(QuerySortDirection.Descending);

private final QuerySortDirection querySortDirection;

Direction(QuerySortDirection querySortDirection) {
this.querySortDirection = querySortDirection;
}

private QuerySortDirection getQuerySortDirection() {
return querySortDirection;
}
DESCENDING;

}

private Sort() {
}

abstract QuerySort toQuerySort();

private static final class PropertySort extends Sort {

@JsonProperty
private final String property;

@JsonProperty
private final Direction direction;

private PropertySort(String property, Direction direction) {
this.property = Objects.requireNonNull(property);
this.direction = Objects.requireNonNull(direction);
}

@Override
QuerySort toQuerySort() {
return new QuerySort(property, null, direction.getQuerySortDirection());
}

@Override
public String toString() {
return "%s: %s".formatted(property, direction);
Expand All @@ -162,20 +140,17 @@ public String toString() {

private static final class TimestampSort extends Sort {

@JsonProperty
private final Timestamp timestamp;

@JsonProperty
private final Direction direction;

private TimestampSort(Timestamp timestamp, Direction direction) {
this.timestamp = Objects.requireNonNull(timestamp);
this.direction = Objects.requireNonNull(direction);
}

@Override
QuerySort toQuerySort() {
return new QuerySort(null, timestamp.getQuerySortTimestamp(), direction.getQuerySortDirection());
}

@Override
public String toString() {
return "%s: %s".formatted(timestamp, direction);
Expand Down
Loading
Loading