Categories
Development Java

Spring Boot OAuth2

Ich wollte mal Authentifizierung mit Spring Boot und GitHub-Login antesten. Als inspiration dient dieser Guide.

Die Sourcen lege ich in ein GitHub-Repository.

Creating a New Project

Spring Initializr verwenden.
Als Dependencies Spring Web und OAuth2 Client hinzufügen.

Zip-File herunterladen und in das Projekt entpacken. Warten bis Maven alle Dependencies heruntergeladen hat und anschließend Oauth2Application starten.

Unter http://localhost:8080 wird eine Login-Seite angezeigt:

Add a Home Page

Hier halte ich mich an den Guide. Die WebJars kannte ich bereits, aber noch nicht deren Locator. Den muss ich mal in meinem JSF Projekt ausprobieren.

In your new project, create index.html in the src/main/resources/static folder. You should add some stylesheets and JavaScript links so the result looks like this:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>Demo</title>
    <meta name="description" content=""/>
    <meta name="viewport" content="width=device-width"/>
    <base href="/"/>
    <link rel="stylesheet" type="text/css" href="/webjars/bootstrap/css/bootstrap.min.css"/>
    <script type="text/javascript" src="/webjars/jquery/jquery.min.js"></script>
    <script type="text/javascript" src="/webjars/bootstrap/js/bootstrap.min.js"></script>
</head>
<body>
	<h1>Demo</h1>
	<div class="container"></div>
</body>
</html>

None of this is necessary to demonstrate the OAuth 2.0 login features, but it’ll be nice to have a pleasant UI in the end, so you might as well start with some basic stuff in the home page.

If you start the app and load the home page, you’ll notice that the stylesheets have not been loaded. So, you need to add those as well by adding jQuery and Twitter Bootstrap:

<dependency>
	<groupId>org.webjars</groupId>
	<artifactId>jquery</artifactId>
	<version>3.4.1</version>
</dependency>
<dependency>
	<groupId>org.webjars</groupId>
	<artifactId>bootstrap</artifactId>
	<version>4.3.1</version>
</dependency>
<dependency>
	<groupId>org.webjars</groupId>
	<artifactId>webjars-locator-core</artifactId>
</dependency>

The final dependency is the webjars "locator" which is provided as a library by the webjars site. Spring can use the locator to locate static assets in webjars without needing to know the exact versions (hence the versionless /webjars/** links in the index.html). The webjar locator is activated by default in a Spring Boot app, as long as you don’t switch off the MVC autoconfiguration.

With those changes in place, you should have a nice looking home page for your app.

Oauth2Application starten.
Mir wird wieder die Login-Seite angezeigt.
Ich nehme die spring-boot-starter-oauth2-client Dependency aus der pom.xml heraus, starte Oauth2Application neu und sehe eine leere Seite.

Mir ist nicht ganz klar, woran das liegt, also mache ich erstmal weiter mit dem Guide, in der Hoffnung dass es am Ende laufen wird.

Add a New GitHub App

Webhook: Active Flag deaktivieren

MÖÖÖÖÖÖP FALSCH

Ich habe oben eine GitHub App angelegt und keine OAuth App. Also nochmal von vorn:

In src/resources application.yml anlegen:

spring:
  security:
    oauth2:
      client:
        registration:
          github:
            clientId: github-client-id
            clientSecret: github-client-secret

Boot Up the Application

http://localhost:8080 aufrufen.

In einem anderen Tab des Browsers war ich bereits in GitHub eingeloggt:

Wenn ich den Seitenaufruf in einem neuen privaten Browserfenster tätige, sieht die Anmeldung so aus:

In beiden Fällen wird anschließend eine leere Seite angezeigt:

Auch die index.html bleibt leer:

Also irgendwas ist da noch nicht richtig.
Anscheinend hatte ich die index.html nicht richtig gespeichert, die Datei war noch leer. Also den Code hinzugefügt und gespeichert.

Anschließend funktioniert es auch:

Sehr schön, da kann der Zukunftsingo sich ja freuen, hier weiter herumzuexperimentieren, sobald wieder etwas Zeit ist.

Categories
Development Java

Docx4J

Ein Report soll als Word Dokument (.docx) verfügbar gemacht werden.

Dazu wird zuerst eine Word Datei als Template erstellt mit Platzhaltern an den entsprechenden Stellen. Die Ersetzung einzelner Wörter ist relativ trivial, das Befüllen einer Tabelle mit n Zeilen stellte sich dann etwas kniffliger heraus. Diese Notizen und das dazugehörige Beispielprojekt dienen als Erinnerungsstütze, falls der Zukunftsingo nach dem aktuellen Projekt noch einmal etwas mit docx4j machen darf.

Word Template erstellen

Word mit einem leeren Dokument öffnen, einen Platzhalter für eine Überschrift, einen Text und eine Tabelle hinzufügen.

Die Überschriften der Tabelle sind fix und werden nicht ersetzt.
Die Anzahl der Zeilen in dem Report ist dynamisch.
Die Tabelle erhält keine Zeilen, diese werden später hineingeneriert.

Würde der Report eine Tabelle mit einer fixen Anzahl von Zeilen vorsehen, könnte man die ganze Tabelle in das Dokument eintragen und und später die einzelnen Werte per Wortersetzung hineingenerieren.

Die Tabelle bekommt noch einen Titel, um sie so eindeutig identifizieren zu können. Beispielsweise wenn das Dokument mehrere Tabellen enthält.

Abschließend unter dem Namen template.docx als Word-Dokument speichern, nicht als Strict Open XML-Dokument!

Projekt einrichten

Ein neues Maven Projekt anlegen.

Die Datei template.docx nach src/main/resources kopieren.

Java Klasse anlegen, die das Template läd und eine neue Zieldatei im tmpdir mit teilgeneriertem Name anlegt. Dadurch erhalten wir bei jedem Durchlauf eine neue Datei, um sie ggf. noch einmal miteinander vergleichen zu können, und das Aufräumen übernimmt das Betriebssystem für uns.

Als Dummyaction wird das Template in die Zieldatei kopiert. An dieser Stelle kommt im nächsten Schritt Docx4J ins Spiel um das Template zu befüllen.

Im letzten Schritt lassen wir uns die Zieldatei mit Word öffnen um so direkt das Ergebnis überprüfen zu können.

public class Main {

    public static void main(String[] args) throws Exception {
        File template = getTemplateFile();
        File output   = getOutputFile();
        
        Files.copy(Paths.get(template.toURI()), Paths.get(output.toURI()), StandardCopyOption.REPLACE_EXISTING);

        Desktop.getDesktop().open(output);
    }
    
    private static File getTemplateFile() throws Exception {
        File template = new File( Main.class.getClassLoader().getResource("template.docx").toURI() );
        return template;
    }

    private static File getOutputFile() throws Exception {
        String tmpdir = System.getProperty("java.io.tmpdir");
        Path tmpfile = Files.createTempFile(Paths.get(tmpdir), "MyReport" + "-", ".docx");
        return tmpfile.toFile();
    }
}

Um das Template befüllen zu können legen wir einen Report mit Beispieldaten an:

public class Report {

    public String ueberschrift = "Sample Report";
    public String text = "Lorem Ipsum";
    public List<String[]> tabelle;
    
    public Report() {
        tabelle = new ArrayList<>();
        tabelle.add(new String[] {"Montag", "Regen", "4°C"});
        tabelle.add(new String[] {"Dienstag", "Wolkig", "6°C"});
        tabelle.add(new String[] {"Mittwoch", "Sonne", "10°C"});
    }
}

Docx4J

Projekt Homepage: Link

Projekt GitHub Repository: Link

Artikel zum Einstieg: Baeldung

Dem Projekt dank Maven einfach die notwendigen Abhängigkeiten in der pom.xml hinzufügen:

<dependencies>
  <!-- https://mvnrepository.com/artifact/org.docx4j/docx4j-JAXB-ReferenceImpl -->
  <dependency>
    <groupId>org.docx4j</groupId>
    <artifactId>docx4j-JAXB-ReferenceImpl</artifactId>
    <version>11.4.9</version>
  </dependency>
</dependencies>

Template befüllen

Template laden

WordprocessingMLPackage wordPackage = WordprocessingMLPackage.load(template);

Text ersetzen

Wir suchen alle Texte und wenn ein Platzhalter gefunden wird, wird der Text mit dem Wert aus dem Report gefüllt:

private static void textErsetzen(WordprocessingMLPackage wordPackage, Report report) throws Exception {
  List<Object> texts = wordPackage.getMainDocumentPart().getJAXBNodesViaXPath(XPATH_TO_SELECT_TEXT_NODES, true);
  for (Object obj : texts) {  
    Text text = (Text) ((JAXBElement<?>) obj).getValue();

    String textValue;
    if ("ÜBERSCHRIFT1".equals(text.getValue())) {
      textValue = report.ueberschrift;
    } else if ("TEXT1".equals(text.getValue())) {
      textValue = report.text;
    } else {
      textValue = text.getValue();
    }

    text.setValue(textValue);
  } 
}

Resultat:

Tabellenzeilen erzeugen

Wir laufen über alle Elemente und wenn eine Tabelle gefunden wird, wird deren Titel überprüft und dann die Tabelle befüllt.

private static void tabellenzeilenErzeugen(WordprocessingMLPackage wordPackage, Report report) {
  for (Object o : wordPackage.getMainDocumentPart().getContent()) {
    if (o instanceof JAXBElement) {
      @SuppressWarnings("unchecked")
      Tbl tbl = ((JAXBElement<Tbl>)o).getValue();
      String tblCaption = tbl.getTblPr().getTblCaption() != null ? tbl.getTblPr().getTblCaption().getVal() : null;
      if ("MyTabelle1".equals(tblCaption)) {
        fillTabelle1(tbl, report);
      } else {
        System.out.println("tblCaption: " + tblCaption);
      }
    }
  }
}

private static void fillTabelle1(Tbl tbl, Report report) {
  for (String[] zeile : report.tabelle) {
    addRow(tbl, zeile[0], zeile[1], zeile[2]);
  }
}

Bei den Einträgen in der Tabelle setzen wir noch den gewünschten Font.

private static void addRow(Tbl tbl, String... cells) {
  ObjectFactory factory = new ObjectFactory();

  // setup Font
  String fontName = "Arial";
  RFonts fonts = factory.createRFonts();
  fonts.setAscii(fontName);
  fonts.setCs(fontName);
  fonts.setHAnsi(fontName);
  RPr rpr = factory.createRPr();
  rpr.setRFonts(fonts);

  // new Row
  Tr tr = factory.createTr();
  for (String cell : cells) {
    Tc tc = factory.createTc();

    P p = factory.createP();
    R r = factory.createR();
    r.setRPr(rpr);

    Text text = factory.createText();

    text.setValue(cell);
    r.getContent().add(text);
    p.getContent().add(r);

    tc.getContent().add(p);

    tr.getContent().add(tc);
  }
  tbl.getContent().add(tr);
}

Resultat:

Report speichern

wordPackage.save(output);

Categories
Development Java

Default Charset UTF-8

New Java 18 Feature: Default Charset UTF-8

Intern benutzt Java UTF-16, "extern" wurde bisher standartmäßig das Encoding des Betriebssystems, zB Windows-1252, verwendet. Das hatte in der Vergangenheit immer wieder zu lustigen Encoding Problemen geführt, so dass ich schon lange meine Projekte und IDE Einstellungen auf UTF-8 umgestellt hatte. Ausnahme sind die Property-Dateien, die erst ab Java 9 UTF-8 können. Und Java 9 ist noch lange nicht in allen Projekten verfügbar.

Mit Java 18 wurde endlich JEP 400: UTF-8 by Default umgesetzt.

Das Problem ���

Ich habe ein kleines Testprojekt aufgesetzt und dafür Java 19 verwendet. Dabei stieß ich irgendwann auf ein Encoding Problem, dass sich recht einfach nachstellen ließ:

package deringo;

public class TestMain {
    public static void main(String[] args) {
        String s = "abc äüß def";
        System.out.println(s);
    }
}

Output:

Projekt auf Java 17 umgestellt und der Output ist korrekt:

Die IDE ist Eclipse und der Bug ist bekannt: Bug 579383

Mit Java 17 wurde, als Vorbereitung auf JEP400, eine neue System Property eingeführt: native.encoding

Die kann man sich ganz einfach anzeigen lassen:

        String encoding = System.getProperty("native.encoding");
        System.out.println(encoding);

Sowohl unter Java 17 wie unter Java 19 wird bei mir angezeigt:

Was nicht funktioniert hat

Die eclipse.ini erweitern:

-Dfile.encoding=UTF-8
-Dsun.stdout.encoding=UTF-8
-Dsun.stderr.encoding=UTF-8

Die Lösung

In der Run Configuration für das Programm unter Common -> Encoding auf Use system encoding umstellen.

Oder erstmal weiterhin Java 17 verwenden. 🤷‍♂️

Categories
Development Java Linux

SAPJCO 2

Problem

We have a very, very old application that needs to be migrated into AWS. So we copied all files into AWS EC2 instance and tried to start the application. After fixing a lot of minor problems we faced a tough challenge with a SAPJCO RFC Call.

The Exception message was something like this:

Exception in thread "main" java.lang.ExceptionInInitializerError: JCO.classInitialize(): Could not load middleware layer 'com.sap.mw.jco.rfc.MiddlewareRFC'
JCO.nativeInit(): Could not initialize dynamic link library sapjcorfc [sapjcorfc (Not found in java.library.path)]. java.library.path [/usr/lib/jvm/java-1.6.0-ibm.x86_64/jre/lib/amd64/default:/usr/lib/jvm/java-1.6.0-ibm.x86_64/jre/lib/amd64:/usr/lib]
        at com.sap.mw.jco.JCO.<clinit>(JCO.java:871)
        at java.lang.J9VMInternals.initializeImpl(Native Method)
        at java.lang.J9VMInternals.initialize(J9VMInternals.java:199)

I guess, with a JCO Version 3 we would not have much trouble, but in this ancient application JCO Version 2 is used and we cannot update to Version 3 without a huge efford. In other projects I had the luck that I could migrate to Version.

The application is running on a Linux system. But belive me: it would have been much harder on a Windows machine.

Analysis

To find the cause of the problem I wrote the simpliest JCO Test Programm I can image:

import com.sap.mw.jco.JCO;

public class TestMain {

    public static void main(String[] args) {
        System.out.println(JCO.getVersion());
    }

}

My analysis programm structure:

app
├─ JCo/
│  ├─ librfccm.so
│  ├─ libsapjcorfc.so
│  ├─ sapjco.jar
├─ TestMain.java

Compile from command line:

javac -cp ".:/app/JCo/sapjco.jar" TestMain.java

Run from command line:

java -cp ".:/app/JCo/sapjco.jar" TestMain

That gave me another error:

Exception in thread "main" java.lang.ExceptionInInitializerError: JCO.classInitialize(): Could not load middleware layer 'com.sap.mw.jco.rfc.MiddlewareRFC'
JCO.nativeInit(): Could not initialize dynamic link library sapjcorfc [/app/JCo/libsapjcorfc.so: librfccm.so: cannot open shared object file: No such file or directory]. java.library.path [/app/JCo]
        at com.sap.mw.jco.JCO.<clinit>(JCO.java:871)
        at TestMain.main(TestMain.java:11)

Need to set an environment property first:

export LD_LIBRARY_PATH=/app/JCo

Run command line to start programm again and got another error:

Exception in thread "main" java.lang.ExceptionInInitializerError: JCO.classInitialize(): Could not load middleware layer 'com.sap.mw.jco.rfc.MiddlewareRFC'
JCO.nativeInit(): Could not initialize dynamic link library sapjcorfc [/app/JCo/libsapjcorfc.so: libstdc++.so.5: cannot open shared object file: No such file or directory]. java.library.path [/app/JCo]
        at com.sap.mw.jco.JCO.<clinit>(JCO.java:871)
        at TestMain.main(TestMain.java:11)

The interesting part of the error message:

Could not initialize dynamic link library sapjcorfc [/app/JCo/libsapjcorfc.so: libstdc++.so.5

Solution

We need the libstdc++.so.5 library, but installed is libstdc++.so.6

To get libstdc++.so.5 we installed package compat-libstdc++-33-3.2.3-66.x86_64:

yum install compat-libstdc++-33-3.2.3-66.x86_64

## to be honest, I am not exactly 100% sure, what I did in my investigations, so the command may be a little differend, ex:
# yum install compat-libstdc++-33-3
# yum install compat-libstdc++-33
# yum install compat-libstdc++-33 libstdc++.so.5

Test

Run from command line:

java -cp ".:/app/JCo/sapjco.jar" TestMain

That gave me no error, but SAPJCo Version number.:

2.1.10 (2011-05-10)

Finally it worked 😎

Anekdotum

Quote from an StackOverflow post from 2010:

libstdc++.so.5 is a very old version of the standard c++ library.

Some Analysis Details

Writing this article is giving me the feeling, that this was all super easy. But in reality it was a real pain in the allerwertesten.

To isolate the source of the problem, I did not only write the small Java (JCO.getVersion) application, I also set up a Docker environment.

One challenge was to find a useful Docker image to start from. I started with an OpenJDK Image that was already deprecated. Deprecated was not the problem, but I could not install libstdc++.so.5.

Next I tried to use the newer, undeprecated Eclipse-Temurin Image. But still could not install libstdc++.so.5

So I finally ended in a Debian Image and self installed Java where I was able to install libstdc++5.

FROM debian:bookworm-slim
 RUN apt-get update && apt-get install -y locales openjdk-11-jdk libstdc++5 \
     && rm -rf /var/lib/apt/lists/* \
     && localedef -i de_DE -c -f UTF-8 -A /usr/share/locale/locale.alias de_DE.UTF-8
 ENV LANG de_DE.utf8

COPY ./JCo/* /app/JCo/
COPY TestMain.java /app
WORKDIR /app
 ENV LD_LIBRARY_PATH=/app/JCo
 
 RUN javac -cp ".:/app/JCo/sapjco.jar" TestMain.java 

 CMD ["java", "-cp", ".:/app/JCo/sapjco.jar", "-Djava.library.path=/app/JCo", "TestMain"]

Note:

The locale might not be neccessary.
The "-Djava.library.path=/app/JCo" Parameter is not neccessary. But I leave it as an example, how one can use it.

Build and start the app:

docker build -t my-java-app .
docker run -it --rm --name my-running-app my-java-app
2.1.10 (2011-05-10)

Work in container

To work in the running container:

docker exec -it my-running-app /bin/sh

But there is one problem: You can only interact with a running container. But the TestMain-Programm is executed and immediately closed.

So I wrote another Test Programm, that keeps running, so I can enter the running container and test stuff (install packages, compile Java programm, etc.):

import java.io.BufferedReader;
import java.io.InputStreamReader;

import com.sap.mw.jco.JCO;

public class TestMain {

    public static void main(String[] args) {
        System.out.println("Hello World");
        //System.out.println("JCO Version: " + JCO.getVersion());

        while (true) {
            BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
            System.out.println("Enter Input : ");
            try {
                String s = br.readLine();
                System.out.println(s);
            }catch(Exception e) {
                System.out.println(e);
            }
        }
        
    }

}
Categories
Development Java

OneLogin AccessFilter

Wir wollen unsere Anwendung durch einen Filter schützen, der nur Anfragen von eingeloggten Benutzern mit der richtigen Rolle hindurch lässt.

Für diesen PoC bauen wir einen Reverse Proxy für den Login und einen Anwendungsserver für den Filter. Der Anwendungsserver bekommt einen Zugang über den RP und einen Zugang ohne RP, um so zu zeigen, dass nur über den RP auf die Anwendung zugegriffen werden kann.

In produktiven Umgebungen darf es den Zugang ohne RP so ungeschützt nicht geben, da es sehr einfach ist, zB über ein Browser-Plugin, den RP mittels selbstgesetzter Header zu faken.

Reverse Proxy

Der Reverse Proxy bekommt grundsätzlich die gleiche Konfiguration wie in den Beispielen zuvor.

Hier müssen wir aber noch den Scope params hinzufügen, um so die OneLogin-Rollen des Benutzers zu übertragen:

## OIDCScope params
## to put params including roles into header
OIDCScope "openid email profile groups params"

Im Header sieht das dann beispielsweise so aus:

oidc_claim_params
	{"post_logout_redirect_uri": "http://localhost", "roles": "user;admin"}

ACHTUNG

Das die Rollen als Parameter übertragen werden, funktioniert nur, wenn man explizit angibt, dass diese als Parameter übergeben werden sollen!

Dazu muss die Application in OneLogin entsprechend konfiguriert werden:

Applications -> Applications -> select Application -> Parameters -> "+"-Button:

Im nächsten Fenster des Dialoges die Value ("User Roles") setzen:

Access Filter

Der Access Filter prüft, ob eine UserID vorhanden ist. Das ist in unserem Beispiel die Email, es könnte aber auch der Preferred Username genommen werden.

Des weiteren prüft der Access Filter, ob der Benutzer die erforderliche Rolle user hat.

Sind beide Bedingungen erfüllt, kann auf die Anwendung zugegriffen werden, ansonsten wird lediglich "Access denied" angezeigt.

package deringo.oneloginjavaappsample;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;

import org.apache.commons.lang3.StringUtils;
import org.json.JSONObject;


@WebFilter("/*")
public class AccessFilter implements Filter {

	@Override
	public void init(FilterConfig filterConfig) throws ServletException {
		System.out.println("AccessFilter init");
	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		System.out.println("AccessFilter doFilter");
		
		HttpServletRequest httpRequest = (HttpServletRequest) request;
		
		String oidc_claim_email = httpRequest.getHeader("oidc_claim_email");
		String oidc_claim_preferred_username = httpRequest.getHeader("oidc_claim_preferred_username");
		String userid = oidc_claim_email;
		
		String oidc_claim_params = httpRequest.getHeader("oidc_claim_params");
		List<String> roles = getRoles(oidc_claim_params);
		boolean hasRequiredRole = roles.contains("user");
		
		if (!StringUtils.isBlank(userid) && hasRequiredRole) {
			// Grant access
			chain.doFilter(request, response);
		} else {
			response.getWriter().println("Access denied");
			response.getWriter().close();
		}
	}

	@Override
	public void destroy() {
		System.out.println("AccessFilter destroy");
	}

	private static List<String> getRoles(String oidc_claim_params) {
		List<String> rolesList = new ArrayList<>();
		try {
			JSONObject o = new JSONObject(oidc_claim_params);
			String rolesS = o.get("roles").toString();
			String[] roles = StringUtils.split(rolesS, ";");
			rolesList = Arrays.asList(roles);
		} catch (Exception e) {
			e.printStackTrace();
		}
		return rolesList;
	}
}

Docker

Über Docker Compose werden beide Server gestartet.

Über Port 80 ist der Reverse Proxy erreichbar, über Port 8080 direkt der Anwendungsserver.

version: '3.8'
services:

   reverseproxy:
      build: ./reverseproxy
      hostname: reverseproxy
      volumes:
        - ./reverseproxy/public_html:/usr/local/apache2/htdocs
        - ${PWD}/reverseproxy/conf/reverseproxy_httpd.conf:/usr/local/apache2/conf/httpd.conf
        - ${PWD}/reverseproxy/conf/reverseproxy.conf:/usr/local/apache2/conf/reverseproxy.conf
      ports:
        - 80:80
      
   sampleapp:
      build: ./oneloginjavaappsample
      hostname: sampleapp
      ports:
        - 8080:8080

Im Docker File des Anwendungsservers kopieren wir erst nur das Maven POM, lassen dann Maven bauen, um so die Abhängigkeiten herunterzuladen. Erst danach kopieren wir das gesamte Projekt in den Container und lassen die Anwendung bauen. So wird nach Code Änderungen, ohne Anpassungen in Maven, der Bau des Images beschleunigt, da nicht jedes Mal die Bibliotheken heruntergeladen werden müssen.

   FROM tomcat:8.5-jdk8-openjdk-slim
    RUN apt update && apt install -y \
        maven
   COPY pom.xml /app/pom.xml
WORKDIR /app
    RUN mvn package
   COPY . /app
    RUN mvn package
WORKDIR $CATALINA_HOME
    RUN mv /app/target/ROOT.war webapps
 EXPOSE 8080
    CMD ["catalina.sh", "run"]

Test

Zugriff auf den Anwendungsserver über den Reverse Proxy nach Anmeldung:

http://localhost/private/test.html

Direkter Zugriff auf den Anwendungsserver ohne Anmeldung:

http://localhost:8080/index.html

GitHub

Die Dateien zu diesem Post sind im OneLogin-GitHub-Projekt unter version6 zu finden.

Categories
Development Java

OneLogin API-Zugriff mit Java

Im vorherigen Post haben wir gesehen, dass ein neuer Benutzer sich selbst registrieren kann, aber um den Zugang zur Anwendung zu erhalten, er noch durch einen User-Admin freigeschaltet/der Anwendung hinzugefügt werden muss.

Das Hinzufügen des Benutzers erfolgt in OneLogin, so dass der User-Admin auch in diesem Tool geschult werden müsste.

Um den Schulungsaufwand zu vermeiden und ein flüssigeres Arbeiten, ohne Wechsel der Anwendungen, zu ermöglichen, wollen wir einige Funktionalität von OneLogin in unserer Anwendung ermöglichen. Dazu gibt es in OneLogin eine API-Schnittstelle, die wir mit einem Java-Client ansteuern werden.

Der Java-Client logt sich einmalig mit Client ID und Secret (API Credentials! siehe unten) ein und erhält so ein Access Token, mit dem die weiteren API-Funktionen ausgeführt werden können.

Die API-Dokumentation, für Version 2, findet sich hier: https://developers.onelogin.com/api-docs/2/getting-started/dev-overview

Vorbereitung / Daten sammeln

API-Subdomain

Die API-Domain des Testprojekt ist: deringo-dev.onelogin.com

Die API-Sub-Domain lautet foglich: deringo-dev

API ID & Secret

Für die Benutzung der API wird Client ID und Client Secret benötigt. Das kennen wir schon aus einem vorherigen Post zu OneLogin:

Für den Zugriff über die API benötigen wir aber ein anderes Client ID & Secret Paar! Dieses findet sich in dem Developers-Tab unter API Credentials.

Dort ist ein entsprechender Zugang einzurichten, für das Test-Projekt haben wir mit Manage All eingerichtet:

OneLogin App ID

Die ID unserer Test-Anwendung findet sich in der URL nachdem man auf Applications -> Applications die App auswählt, in unserem Beispiel "1739301":

Java Programm aufsetzen

Abhängigkeiten

Das Projekt wird für Java 8 aufgesetzt und benötigt Apache HttpClient 4.5 und Json:

<properties>
  <maven.compiler.source>8</maven.compiler.source>
  <maven.compiler.target>8</maven.compiler.target>
</properties>

<dependencies>
  <!-- https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient -->
  <dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.13</version>
  </dependency>
  <!-- https://mvnrepository.com/artifact/org.json/json -->
  <dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
    <version>20220320</version>
  </dependency>
</dependencies>

Programm Rumpf

Zum Testen der API-Funktionen wird eine Test-Klasse mit den oben gesammelten Informationen angelegt und dann jeweils eine eigene Methode in der Klasse verwendet.

public class TestMain {

	private static final String ONELOGIN_SUBDOMAIN = "deringo-dev";
	
	// OneLogin Administration -> Applications -> select Application, get ID from URL
	private static final String ONELOGIN_APP_ID = "1739301";
	
	// OneLogin Administration -> Developers -> API Credentials
	private static final String ONELOGIN_CLIENT_ID = "12345<geheim>67890";
	private static final String ONELOGIN_CLIENT_SECRET = "12345<geheim>67890";
	
	
	public static void main(String[] args) {
		TestMain main = new TestMain();

		main.sayHello();
	}

	private void sayHello() {
		System.out.println("Hello World!");
	}
}

Access Token

Für die API Calls wird ein Access Token benötigt, wie dieser zu bekommen ist ist hier beschrieben: Generate OAuth 2.0 Tokens v2| OneLogin Developers

private String getAccessToken() {

  CloseableHttpClient client = HttpClientBuilder.create().build();

  HttpPost request = new HttpPost(String.format("https://%s.onelogin.com/auth/oauth2/v2/token", ONELOGIN_SUBDOMAIN));

  String credentials = String.format("%s:%s", ONELOGIN_CLIENT_ID, ONELOGIN_CLIENT_SECRET);
  byte[] encodedAuth = Base64.getEncoder().encode(credentials.getBytes());
  String authHeader = "Basic " + new String(encodedAuth);

  request.setHeader("Authorization", authHeader);
  request.addHeader("Content-Type", "application/json");
  request.setEntity(new StringEntity("{ \"grant_type\": \"client_credentials\" }", "UTF-8"));

  try {
    CloseableHttpResponse reponse = client.execute(request);

    String content = EntityUtils.toString(reponse.getEntity());

    JSONObject json = new JSONObject(content);

    String accessToken = json.getString("access_token");
    return accessToken;

  } catch (IOException e) {
    e.printStackTrace();
    return null;
  }
}

List App Users

Use this API to return a list of users that are assigned to an App.

Doku: List App Users - OneLogin API

Sample Code:

private void listAppUsers() {
  String resourceURL = String.format("https://%s.onelogin.com/api/2/apps/%s/users", ONELOGIN_SUBDOMAIN, ONELOGIN_APP_ID);

  CloseableHttpClient client = HttpClientBuilder.create().build();

  HttpGet request = new HttpGet(resourceURL);
  request.setHeader("Authorization", "bearer " + getAccessToken());
  request.addHeader("Content-Type", "application/json");

  try {
    CloseableHttpResponse reponse = client.execute(request);

    String content = EntityUtils.toString(reponse.getEntity());

    JSONArray jsonArray = new JSONArray(content);
    jsonArray.forEach(o -> System.out.println(o));

  } catch (IOException e) {
    e.printStackTrace();
  }
}

List Roles

Use this API to return a list of roles.
This endpoint supports pagination and sorting.

Doku: List Roles (onelogin.com)

private void listRoles() {
  String resourceURL = String.format("https://%s.onelogin.com/api/2/roles", ONELOGIN_SUBDOMAIN);

  CloseableHttpClient client = HttpClientBuilder.create().build();

  HttpGet request = new HttpGet(resourceURL);
  request.setHeader("Authorization", "bearer " + getAccessToken());
  request.addHeader("Content-Type", "application/json");

  try {
    CloseableHttpResponse reponse = client.execute(request);

    String content = EntityUtils.toString(reponse.getEntity());

    JSONArray jsonArray = new JSONArray(content);
    jsonArray.forEach(o -> System.out.println(o));

  } catch (IOException e) {
    e.printStackTrace();
  }
}

Wenn zB nur die Rollen für unsere Test-App angezeigt werden sollen und der Rollen Name ein "u" enthalten muss, setzt sich der resourceURL-String wie folgt zusammen:

String resourceURL = String.format("https://%s.onelogin.com/api/2/roles?app_id=%s&name=u", ONELOGIN_SUBDOMAIN, ONELOGIN_APP_ID);

Query Parameter

Es gibt Query Parameter, die anscheinend von vielen Funktionen unterstützt werden: Fields, Search, Pagination & Sort

Doku: Using Query Parameters - OneLogin Developers

Als Beispiel erweitern wir das List App Users Beispiel indem wir lediglich die resourceURL anpassen und für die Query Parameter vorbereiten:

String queryParameter = "";
String resourceURL = String.format("https://%s.onelogin.com/api/2/apps/%s/users%s", ONELOGIN_SUBDOMAIN, ONELOGIN_APP_ID, queryParameter);

Wollen wir uns anstelle der gesamten User-Information lediglich Vor- und Nachname zurückgeben lassen, neben der ID, dann brauchen wir lediglich den Query Parameter anpassen:

String queryParameter = "?fields=firstname,lastname";

Suche nach allen Benutzern mit dem Nachnamen "Mustermann":

String queryParameter = "?lastname=Mustermann";

Was NICHT funktioniert: Suche nach allen Benutzern mit dem Nachnamen "Mustermann" und dem Vornamen "Erika":

// funktioniert NICHT:
String queryParameter = "?lastname=Mustermann,firstname=Erika";

Ups, eigene Dummheit, wenn man es richtig macht, dann geht es; Die Parameter werden natürlich mit einem "&" concatiniert und nicht mit einem ",":

// funktioniert NICHT:
String queryParameter = "?lastname=Mustermann&firstname=Erika";

Alle Benutzer mit Vorname "Erika", der Nachname soll mit "Muster" anfangen und wir brauchen nur Vor- und Nachname:

String queryParameter = "?lastname=Muster*&firstname=Erika&fields=firstname,lastname";

Seite 1 alle Benutzer, absteigend sortiert nach Nachnamen, bei einer Seitengröße von 1:

String queryParameter = "?page=1&limit=1&sort=-lastname";

Seitenweises Vor- oder Zurückblättern ist möglich, dazu gibt es den Parameter "cursor". Im Header der ersten Paginierten Response gibt es, falls vorhanden, After-Curser und Before-Curser. Über diese Werte kann vor- und zurückgeblättert werden.

Beispielsweise um die erste Seite zu erhalten und anschließend eine Seite weiter blättern:

// erster Aufruf:
String queryParameter = "?page=1&limit=1";
[...]
String afterCursor = response.getFirstHeader("After-Cursor").getValue();
[...]

// zweiter Aufruf
queryParameter = "?cursor=" + afterCursor;

kleines User Management

Es soll zuerst ein neuer User angelegt werden. Anschließend bearbeitet, der Anwendung hinzugefügt und wieder entfernt und abschließend gelöscht werden.

Create User

Doku: Create User - OneLogin Developers

Erstmal ein minimales Beispiel:

private void createUser() throws Exception {
  String resourceURL = String.format("https://%s.onelogin.com/api/2/users", ONELOGIN_SUBDOMAIN);

  CloseableHttpClient client = HttpClientBuilder.create().build();

  HttpPost request = new HttpPost(resourceURL);
  request.setHeader("Authorization", "bearer " + getAccessToken());
  request.addHeader("Content-Type", "application/json");

  String json = "{\"username\":\"min.requirements\"}";
  StringEntity entity = new StringEntity(json);
  request.setEntity(entity);

  try {
    CloseableHttpResponse response = client.execute(request);
    System.out.println(response.getStatusLine());
    String content = EntityUtils.toString(response.getEntity());
    System.out.println(content);
    JSONObject o = new JSONObject(content);
    System.out.println("new User ID: " + o.get("id"));
  } catch (IOException e) {
    e.printStackTrace();
  }
}

Consolen Ausgabe:

HTTP/1.1 201 Created
{"firstname":null,"updated_at":"2022-06-01T14:34:52.423Z","role_ids":[],"invitation_sent_at":null,"member_of":null,"distinguished_name":null,"email":null,"id":178924216,"password_changed_at":null,"manager_ad_id":null,"group_id":null,"invalid_login_attempts":0,"phone":null,"title":null,"preferred_locale_code":null,"department":null,"samaccountname":null,"lastname":null,"custom_attributes":{"my_role":null},"created_at":"2022-06-01T14:34:52.423Z","directory_id":null,"locked_until":null,"status":7,"company":null,"activated_at":null,"last_login":null,"trusted_idp_id":null,"manager_user_id":null,"username":"min.requirements","comment":null,"external_id":null,"state":1,"userprincipalname":null}
new User ID: 178924216

Nächstes Beispiel mit Vor-, Nach- und Usernamen, Passwort und schönerem JSON:

private void createUser() throws Exception {
  String resourceURL = String.format("https://%s.onelogin.com/api/2/users", ONELOGIN_SUBDOMAIN);

  CloseableHttpClient client = HttpClientBuilder.create().build();

  HttpPost request = new HttpPost(resourceURL);
  request.setHeader("Authorization", "bearer " + getAccessToken());
  request.addHeader("Content-Type", "application/json");

  JSONObject json = new JSONObject()
    .put("firstname", "Happy")
    .put("lastname", "Gilmore")
    .put("username", "happy.gilmore")
    .put("password", "helloworld123")
    .put("password_confirmation", "helloworld123");
  StringEntity entity = new StringEntity(json.toString());
  request.setEntity(entity);

  try {
    CloseableHttpResponse response = client.execute(request);
    System.out.println(response.getStatusLine());
    String content = EntityUtils.toString(response.getEntity());
    System.out.println(content);
    JSONObject o = new JSONObject(content);
    System.out.println("new User ID: " + o.get("id"));
  } catch (IOException e) {
    e.printStackTrace();
  }
}
HTTP/1.1 201 Created
{"firstname":"Happy","updated_at":"2022-06-01T14:43:30.646Z","role_ids":[],"invitation_sent_at":null,"member_of":null,"distinguished_name":null,"email":null,"id":178924824,"password_changed_at":"2022-06-01T14:43:30.629Z","manager_ad_id":null,"group_id":null,"invalid_login_attempts":0,"phone":null,"title":null,"preferred_locale_code":null,"department":null,"samaccountname":null,"lastname":"Gilmore","custom_attributes":{"my_role":null},"created_at":"2022-06-01T14:43:30.646Z","directory_id":null,"locked_until":null,"status":1,"company":null,"activated_at":"2022-06-01T14:43:30.644Z","last_login":null,"trusted_idp_id":null,"manager_user_id":null,"username":"happy.gilmore","comment":null,"external_id":null,"state":1,"userprincipalname":null}
new User ID: 178924824

Unser neuer User Happy Gilmore ist angelegt und kann sich mit seinem Username & Passwort auch anmelden, kommt aber, wie erwartet, noch nicht auf die Anwendung, da er ihr noch nicht zugewiesen wurde:

Update User

Doku: Update User - OneLogin Developers

private void updateUser(Integer id) throws Exception {
  String resourceURL = String.format("https://%s.onelogin.com/api/2/users/%d", ONELOGIN_SUBDOMAIN, id);

  CloseableHttpClient client = HttpClientBuilder.create().build();

  HttpPut request = new HttpPut(resourceURL);
  request.setHeader("Authorization", "bearer " + getAccessToken());
  request.addHeader("Content-Type", "application/json");

  JSONObject json = new JSONObject()
    .put("firstname", "Very Happy");
  StringEntity entity = new StringEntity(json.toString());
  request.setEntity(entity);

  try {
    CloseableHttpResponse response = client.execute(request);
    System.out.println(response.getStatusLine());
    String content = EntityUtils.toString(response.getEntity());
    System.out.println(content);
    JSONObject o = new JSONObject(content);
    System.out.println("User ID: " + o.get("id"));
    System.out.println("Firstname: " + o.get("firstname"));
  } catch (IOException e) {
    e.printStackTrace();
  }
}
HTTP/1.1 200 OK
{"firstname":"Very Happy","state":1,"manager_user_id":null,"department":null,"comment":null,"created_at":"2022-06-01T14:43:30.646Z","email":null,"last_login":"2022-06-01T14:46:10.795Z","lastname":"Gilmore","activated_at":"2022-06-01T14:43:30.644Z","directory_id":null,"updated_at":"2022-06-01T14:57:22.656Z","group_id":null,"samaccountname":null,"status":1,"userprincipalname":null,"trusted_idp_id":null,"title":null,"manager_ad_id":null,"invalid_login_attempts":0,"locked_until":null,"phone":null,"role_ids":[],"invitation_sent_at":null,"company":null,"distinguished_name":null,"external_id":null,"password_changed_at":"2022-06-01T14:43:30.629Z","preferred_locale_code":null,"id":178924824,"member_of":null,"username":"happy.gilmore","custom_attributes":{"my_role":null}}
User ID: 178924824
Firstname: Very Happy

User einer Anwendung hinzufügen

Als nächstes sollte der User einer Anwendung hinzugefügt werden, um so den Zugang zu gewähren.

Überraschenderweise gibt es aber keine API, um einer App einen User hinzuzufügen. irgendwie ist das nicht ganz nachzuvollziehen, da dies über die OneLogin-Webseite möglich ist und es einen API Call gibt, um alle User einer App anzeigen zu lassen (List App Users - OneLogin API).

Ein Erklärungs- und somit Lösungsansatz lautet:

The proper way to do this is to assign applications to a Role in the admin console and then use the APIs to set the roles for the user.

This, in turn grants the user access to the application assigned to the role.

java - Onelogin API to add applications to user - Stack Overflow

Wir haben ja noch die Rolle "user", der die App zugeordnet ist, aus einem vorhergehendem Versuch. Die sollten wir nehmen können.

Man kann es im Screenshot nicht sehen, aber in der URL ist die ID der Rolle enthalten: 546920

Add User to Role

Doku: Add Role Users - OneLogin API

private void addUserToRole(Integer roleId, Integer userId) throws Exception {
  String resourceURL = String.format("https://%s.onelogin.com/api/2/roles/%d/users", ONELOGIN_SUBDOMAIN, roleId);

  CloseableHttpClient client = HttpClientBuilder.create().build();

  HttpPost request = new HttpPost(resourceURL);
  request.setHeader("Authorization", "bearer " + getAccessToken());
  request.addHeader("Content-Type", "application/json");

  StringEntity entity = new StringEntity("[" + userId + "]");
  request.setEntity(entity);

  try {
    CloseableHttpResponse response = client.execute(request);
    System.out.println(response.getStatusLine());
    String content = EntityUtils.toString(response.getEntity());
    System.out.println(content);
  } catch (IOException e) {
    e.printStackTrace();
  }
}
HTTP/1.1 200 OK
[{"id":178924824}

Die Überprüfung in der OneLogin Webseite ergibt, dass der User in der Tat hinzugefügt wurde, und es wurden auch keine vorhandenen User aus der Rolle entfernt, die nicht in dem Array enthalten waren.

Unser neuer User Happy Gilmore ist angelegt und kann sich mit seinem Username & Passwort auch anmelden, kommt jetzt auf die Anwendung, da er ihr über die Rolle "user" zugewiesen wurde:

Remove User from Role

Doku: Remove Role Users - OneLogin API

private void removeUserFromRole(Integer roleId, Integer userId) throws Exception {
  String resourceURL = String.format("https://%s.onelogin.com/api/2/roles/%d/users", ONELOGIN_SUBDOMAIN, roleId);

  CloseableHttpClient client = HttpClientBuilder.create().build();

  HttpPost request = new HttpPost(resourceURL) {
    @Override
    public String getMethod() {
      return "DELETE";
    }
  };
  request.setHeader("Authorization", "bearer " + getAccessToken());
  request.addHeader("Content-Type", "application/json");

  StringEntity entity = new StringEntity("[" + userId + "]");
  request.setEntity(entity);

  try {
    CloseableHttpResponse response = client.execute(request);
    System.out.println(response.getStatusLine());
  } catch (IOException e) {
    e.printStackTrace();
  }
}
HTTP/1.1 204 No Content

Die Überprüfung in der OneLogin Webseite ergibt, dass der User aus der Rolle entfernt wurde.

Interessant ist an dem Code, dass die Klasse HttpDelete des Apache HttpClients keinen Content im Body vorsieht.

Um trotzdem ein DELETE mit Body absenden zu können, war der einfachste Weg, ein HttpPost zu nehmen und dessen Methode zu überschreiben:

HttpPost request = new HttpPost(resourceURL) {
  @Override
  public String getMethod() {
    return "DELETE";
  }
};
[...]
request.setEntity(entity);

Delete User

Doku: Delete User - OneLogin Developers

private void deleteUser(Integer userId) throws Exception {
  String resourceURL = String.format("https://%s.onelogin.com/api/2/users/%d", ONELOGIN_SUBDOMAIN, userId);

  CloseableHttpClient client = HttpClientBuilder.create().build();

  HttpDelete request = new HttpDelete(resourceURL);
  request.setHeader("Authorization", "bearer " + getAccessToken());
  request.addHeader("Content-Type", "application/json");

  try {
    CloseableHttpResponse response = client.execute(request);
    System.out.println(response.getStatusLine());
  } catch (IOException e) {
    e.printStackTrace();
  }
}
HTTP/1.1 204 No Content

Damit ist Happy Gilmore Geschichte und auch in der Überprüfung auf der OneLogin Webseite nicht mehr zu finden.

GitHub

Die Dateien zu diesem Post sind im OneLogin-GitHub-Projekt unter version5 zu finden.

Categories
Development Java

NTLM mit Java

Um Daten von einem bestimmten Webservice beziehen zu können, muss der Aufruf durch einen authentifizierten User erfolgen. Der Webservice steht im Intranet des Kunden und der Aufruf im Browser funktioniert ohne Authentifizierung, denn im Hintergrund wird der Windows User übermittelt.

Der Aufruf über Java funktioniert nicht, da kein User übermittelt wird.

Die Authentifizierung am Webservice erfolgt lt. Ansprechpartner durch eine Windows Authentifizierung, was technischer ausgedrückt NTLM sein sollte.

Test Projekt aufsetzen

Um den Zugang zu testen ohne dabei den ganzen Ballast der großen Anwendung mitschleppen zu müssen, wird ein neues Projekt zum Testen aufgesetzt.

Das Projekt wird auf Java 8 konfiguriert und kommt mit einer einzigen Abhängigkeit aus: Dem Apache HTTPClient 4.5

<!-- https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient -->
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.13</version>
</dependency>

Test 1

public static void test01() throws Exception {

  DefaultHttpClient httpclient = new DefaultHttpClient();
  httpclient.getAuthSchemes().register("ntlm", new NTLMSchemeFactory());
  httpclient.getCredentialsProvider().setCredentials(new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT),
                                                     new NTCredentials(username, password, "", ""));
  HttpGet httpGet = new HttpGet(url);
  httpGet.addHeader("accept", "application/json;odata=verbose");
  httpGet.addHeader("content-Type", "application/json;odata=verbose");
  httpGet.getParams().setBooleanParameter(CoreProtocolPNames.USE_EXPECT_CONTINUE, false);
  // HttpResponse response = httpclient.execute(httpGet);
  // System.out.println("Responseeee" + response.getStatusLine());
  try (CloseableHttpResponse response2 = httpclient.execute(httpGet)) {
    StatusLine statusLine = response2.getStatusLine();
    System.out.println(statusLine.getStatusCode() + " " + statusLine.getReasonPhrase());
    String responseBody = EntityUtils.toString(response2.getEntity(), StandardCharsets.UTF_8);
    System.out.println("Response body: " + responseBody);
  }
}

Der Code funktioniert, wirft aber noch eine WARNING mit aus:

Mai 30, 2022 4:15:35 PM org.apache.http.client.protocol.RequestTargetAuthentication process 
WARNING: NEGOTIATE authentication error: No valid credentials provided (Mechanism level: No valid credentials provided (Mechanism level: Failed to find any Kerberos tgt))
200 OK
Response body: [{"Vkorg":"","VkorgDesc":"TEST Korea Limited"}]

Test 2

    public static void test02() throws Exception {
        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
        cm.setMaxTotal(18);
        cm.setDefaultMaxPerRoute(6);

        RequestConfig requestConfig = RequestConfig.custom()
        .setSocketTimeout(30000)
        .setConnectTimeout(30000)
        .setTargetPreferredAuthSchemes(Arrays.asList(AuthSchemes.NTLM))
        .setProxyPreferredAuthSchemes(Arrays.asList(AuthSchemes.BASIC))
        .build();

        CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
        credentialsProvider.setCredentials(AuthScope.ANY,
                new NTCredentials(username, password, "", ""));

        // Finally we instantiate the client. Client is a thread safe object and can be used by several threads at the same time. 
        // Client can be used for several request. The life span of the client must be equal to the life span of this EJB.
        CloseableHttpClient httpclient = HttpClients.custom()
        .setConnectionManager(cm)
        .setDefaultCredentialsProvider(credentialsProvider)
        .setDefaultRequestConfig(requestConfig)
        .build();
        
        HttpGet httpGet = new HttpGet(url);            
        // HttpClientContext is not thread safe, one per request must be created.
        HttpClientContext context = HttpClientContext.create();    
        try ( CloseableHttpResponse response = httpclient.execute(httpGet, context) ) {
            StatusLine statusLine = response.getStatusLine();
            System.out.println(statusLine.getStatusCode() + " " + statusLine.getReasonPhrase());
            String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
            System.out.println("Response body: " + responseBody);
        }
    }

Der Code funktioniert und wirft keine Warnung mehr aus.

Categories
Development Java

Java Configuration

Die Konfiguration in Java war für mich jahrelang kein Problem, denn ich durfte mit einem Framework arbeiten, dass die Konfiguration sehr flexibel und komfortabel gelöst hat.

Beispielsweise konnte die URL für ein angebundenes System für die verschiedenen Stages ganz einfach in einer ini-Datei hinterlegt werden:

dev..othersystem.url=http://othersystem-dev.intranet.cnb
qa...othersystem.url=http://othersystem-qa.intranet.cnb
prod.othersystem.url=http://othersystem-prod.intranet.cnb

Diese ini-Datei ist Teil des Java Projekts und liegt im Classpath.

In der ini-Datei sind alle Informationen zu allen Stages zum OtherSystem gespeichert, was ich immer sehr übersichtlich und leicht zu pflegen fand.

Für die laufende Anwendung muss dann lediglich die Stage festgelegt werden, in welcher sie läuft und dann wird die Konfiguration passend zur Stage gezogen. Die Stage kann definiert werden über eine Konfiguration mit der Zuweisung über den HostName, eine System Property (zB im Tomcat definiert) oder über eine lokale Konfigurationsdatei.

Praktisch ist auch die Möglichkeit, über die lokale Konfigurationsdatei einzelne Konfigurationen überschreiben zu können. So ist es beispielsweise möglich, auf dem Entwicklerrechner in der DEV-Stage zu laufen, aber die Verbindung zur PROD DB zu konfigurieren um einen Bug zu reproduzieren.

localhost.othersystem.url=http://othersystem-prod.intranet.cnb

Das Thema Sicherheit lasse ich bewusst außen vor, denn hier soll es einzig um die Konfiguration gehen.

Als ich dann ein Projekt in einem anderen Kundenkreis startete, und das propritäre Framework nicht mehr verwenden konnte, war ich schon sehr erstaunt, dass es anscheinend keine schlanke, flexible Möglichkeit der Konfiguration im Java SE Umfeld gibt.

Sehr schnell bin ich an die Grenzen der Konfigurierbarbeit gekommen, wie zB bei der Konfiguration der Datenbankanbindung.

Also muss ich selbst etwas basteln, etwas kleines, leichtgewichtiges und trotzdem flexibles.

Anforderung

Von dem Luxus, sämtliche Konfigurationen per Präfix in verschiedenen Dateien im Projekt zu hinterlegen, muss ich mich verabschieden. Statt dessen wird es eine Konfigurationsdatei im Projekt geben, deren Konfiguration dann von außen überschrieben werden muss. Beispielsweise mit den Datenbankverbindungsparametern auf dem PROD Server.
Aufgrund der geringeren Komplexität des Projektes ist das aber durchaus ausreichend.

Die im Projekt hinterlegte Standard-Konfiguration soll über eine lokale Konfigurationsdatei überschrieben werden können.
Dazu muss eine Umgebungsvariable (System Environment, bzw. System Property) "localconf" gesetzt werden, die auf diese Datei zeigt.

Außerdem sollen einzelne Konfigurationen über Umgebungsvariablen (System Environment, bzw. System Property) gesetzt werden können.

In den Umgebungsvariablen stehen sehr viele Konfigurationen, wie zB JAVA_HOME,TMP, user.name etc., welche nicht direkt mit der Anwendung zu tun haben. Ob diese Werte auch in unserer Anwendungskonfiguration aufgenommen werden sollen, wird über eine Property "config.includeSystemEnvironmentAndProperties" gesteuert.

Umsetzung

Zum Nachlesen dokumentiere ich hier ein paar Schritte aus dem Code, das Ganze soll später auch in einem GitHub Projekt landen.

Zuerst die Properties aus System Environment und System Properties sammeln:

// System Environment
Properties systemEnvironmentProperties = new Properties();		
systemEnvironmentProperties.putAll(System.getenv());

// System Properties
Properties systemPropertiesProperties = new Properties();		
systemPropertiesProperties.putAll(System.getProperties());

Die BaseProperties / Standard Properties aus dem ClassPath der Anwendung laden, sie müssen unter: /src/main/resources/application.properties gespeichert sein:

String basePropertiesFilename = "application.properties";

Properties baseProperties = new Properties();
try {
  InputStream is = Config.class.getClassLoader().getResourceAsStream(basePropertiesFilename);
  baseProperties.load(is);
} catch (Exception e) {
  logger.error("Could not read {} from ClassLoader", basePropertiesFilename, e);
}

Falls LocalProperties geladen werden sollen, muss der Pfad zu der Datei in der Umgebungsvariablen "localconf" übergeben werden:

String localPropertiesProperty = "localconf";


Properties localProperties = new Properties();
logger.debug("----------------------------------------------------------------------------------");
logger.debug("LocalProperties Path from System Environment: {}", systemEnvironmentProperties.getProperty(localPropertiesProperty));
logger.debug("LocalProperties Path from System Properties: {}", systemPropertiesProperties.getProperty(localPropertiesProperty));
String localPropertiesPath = systemPropertiesProperties.getProperty(localPropertiesProperty) != null ? systemPropertiesProperties.getProperty(localPropertiesProperty) : systemEnvironmentProperties.getProperty(localPropertiesProperty);
if (localPropertiesPath == null) {
  logger.debug("LocalProperties Path is not set, skip loading Local Properties");
} else {
  logger.debug("Load LocalProperties from {}", localPropertiesPath);
  try {
    localProperties.load(new FileInputStream(localPropertiesPath));
  } catch (Exception e) {
    logger.error("Could not read {} from File", localPropertiesPath, e);
  }
}

Sollen die Umgebungsvariablen auch übernommen werden:

String includeSystemEnvironmentAndPropertiesProperty = "config.includeSystemEnvironmentAndProperties";

String includeS = Stream.of(
  systemPropertiesProperties.getProperty(includeSystemEnvironmentAndPropertiesProperty),
  systemEnvironmentProperties.getProperty(includeSystemEnvironmentAndPropertiesProperty),
  localProperties.getProperty(includeSystemEnvironmentAndPropertiesProperty),
  baseProperties.getProperty(includeSystemEnvironmentAndPropertiesProperty))
  .filter(Objects::nonNull)
  .findFirst()
  .orElse(null);		
Boolean include = Boolean.parseBoolean(includeS);

Abschließend alle Properties mergen:

Properties mergedProperties = new Properties();
mergedProperties.putAll(baseProperties);
mergedProperties.putAll(localProperties);
if (include) {
  mergedProperties.putAll(systemEnvironmentProperties);
  mergedProperties.putAll(systemPropertiesProperties);
} else {
  mergedProperties.forEach((key, value) -> {
    value = systemEnvironmentProperties.getProperty((String)key, (String)value);
    value =  systemPropertiesProperties.getProperty((String)key, (String)value);
    mergedProperties.setProperty((String)key, (String)value);
  });
}

Beispiel

In dem vorherigen Post hatte ich die Konfigurierbarkeit von JPA EntityManagerFactory im Code so gelöst:

import static org.hibernate.cfg.AvailableSettings.SHOW_SQL;

Properties properties = new Properties();
Optional.ofNullable(System.getenv(SHOW_SQL)).ifPresent( value -> properties.put(SHOW_SQL, value));
Optional.ofNullable(System.getenv(JPA_JDBC_URL)).ifPresent( value -> properties.put(JPA_JDBC_URL, value));
Optional.ofNullable(System.getenv(JPA_JDBC_USER)).ifPresent( value -> properties.put(JPA_JDBC_USER, value));
Optional.ofNullable(System.getenv(JPA_JDBC_PASSWORD)).ifPresent( value -> properties.put(JPA_JDBC_PASSWORD, value));

EntityManagerFactory emf = Persistence.createEntityManagerFactory("myapp-persistence-unit", properties);

Das lässt sich jetzt einfacher über die Config lösen:

import static org.hibernate.cfg.AvailableSettings.SHOW_SQL;

Properties properties = new Properties();
properties = Config.getAllProperties();

EntityManagerFactory emf = Persistence.createEntityManagerFactory("myapp-persistence-unit", properties);

Sources

Zum Schluss habe ich das Ganze auch noch in ein GitHub Projekt gepackt: configuration

Categories
Development Java

JPA – (nicht) ohne persistence.xml

Mein Ziel war es, JPA konfigurativ ohne persistence.xml zu verwenden. Das habe ich nicht ganz geschafft, aber schon mal den Weg erarbeitet, wie es prinzipiell funktionieren könnte.

Hintergrund ist einfach der, dass ich in der persistence.xml die Konfiguration meiner Datenbank hinterlegen kann, aber wenn ich das war-File baue und auf den produktiven Server schiebe, dann möchte ich, dass diese Konfiguration durch die der produktiven Datenbank überschrieben werden kann.

persistence.xml

Die im vorherigen Post zu JPA sieht folgendermaßen aus:

<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
  http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd"
  version="2.2">
    <persistence-unit name="myapp-persistence-unit">
        <properties>
            <!-- Configure a database connection in Java SE -->
            <property name="javax.persistence.jdbc.driver" value="org.postgresql.Driver" />
            <property name="javax.persistence.jdbc.url" value="jdbc:postgresql://127.0.0.1:5432/myapp" />
            <property name="javax.persistence.jdbc.user" value="postgres" />
            <property name="javax.persistence.jdbc.password" value="PASSWORD" />
 
            <!-- Configure timeouts -->     
            <property name="javax.persistence.lock.timeout" value="100"/>
            <property name="javax.persistence.query.timeout" value="100"/>
        </properties>
    </persistence-unit>
</persistence>

persistence.xml löschen

Der Versuch, die persistence.xml zu löschen führt zu einem Fehler:

persistence.xml minimal

Der einfachste Weg, diesen Fehler zum umgehen, ist eine minimale persistence.xml anzulegen:

<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
  http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd"
  version="2.2">
    <persistence-unit name="myapp-persistence-unit">
        <properties>
        </properties>
    </persistence-unit>
</persistence>

Wird der EntityManager wie bisher erzeugt, gibt es einen Fehler:

EntityManagerFactory emf = Persistence.createEntityManagerFactory("myapp-persistence-unit");

Konfiguration im Java Code

Die ursprüngliche Konfiguration aus der persistence.xml wird jetzt im Java Code vorgenommen:

Properties properties = new Properties();
properties.put("javax.persistence.jdbc.driver", "org.postgresql.Driver");
properties.put("javax.persistence.jdbc.url", "jdbc:postgresql://127.0.0.1:5432/myapp");
properties.put("javax.persistence.jdbc.user", "postgres");
properties.put("javax.persistence.jdbc.password", "PASSWORD");
properties.put("javax.persistence.lock.timeout", "100");
properties.put("javax.persistence.query.timeout", "100");

Der EntityManager wird dann ganz einfach fehlerfrei wie folgt erzeugt:

EntityManagerFactory emf = Persistence.createEntityManagerFactory("myapp-persistence-unit", properties);

DB Zugriff testen

Um den fehlerhaften Zugriff mit minimaler persistence.xml zu testen:

// Test 'broken' persistence.xml
Error error = assertThrows(NoClassDefFoundError.class, () -> {
  AdresseRepository.getLastObjectID();
});
assertTrue(error != null);

Um den fehlerlosen Zugriff mit minimaler persistence.xml und Java Konfiguration zu testen:

Properties properties = new Properties();
properties.put("javax.persistence.jdbc.driver", "org.postgresql.Driver");
properties.put("javax.persistence.jdbc.url", "jdbc:postgresql://127.0.0.1:5432/myapp");
properties.put("javax.persistence.jdbc.user", "postgres");
properties.put("javax.persistence.jdbc.password", "PASSWORD");
properties.put("javax.persistence.lock.timeout", "100");
properties.put("javax.persistence.query.timeout", "100");

EntityManagerFactory emf = Persistence.createEntityManagerFactory("myapp-persistence-unit", properties);

String sequenceName = "public.object_id_seq";
String sql = "SELECT s.last_value FROM " + sequenceName + " s";
EntityManager em = emf.createEntityManager();
BigInteger value = (BigInteger)em.createNativeQuery(sql).getSingleResult();

assertNotNull(value);

Anderer Error Bugfix

Nach meiner Mittagspause hat sich der Test auf einmal anders verhalten und es wurde kein NoClassDefFoundError geschmissen, sondern ein ExceptionInInizlialisationError. Warum dem so ist 🤷‍♂️.

Beide Errors erweitern allerdings den LinkageError, also ist mein Test fix gefixt:

// Test 'broken' persistence.xml
Error error = assertThrows(LinkageError.class, () -> {
  AdresseRepository.getLastObjectID();
});
assertTrue(error != null);

Available Settings

Die Konstanten der verfügbaren Einstellungen sind in der Klasse org.hibernate.cfg.AvailableSettings zu finden.

Dadurch lassen sich die Properties etwas eleganter setzen:

properties.put(org.hibernate.cfg.AvailableSettings.SHOW_SQL, Boolean.TRUE);
// bzw.
properties.put(AvailableSettings.SHOW_SQL, Boolean.TRUE);
// oder
import static org.hibernate.cfg.AvailableSettings.*;
properties.put(SHOW_SQL, Boolean.TRUE);

Umsetzungsvorschlag

Am einfachsten lasse ich meine persistence.xml wie bisher, mit den Einstellungen der Entwicklungsdatenbank.

Einstellungen wie zB der JDBC Driver bleiben auf allen Systemen gleich. Lediglich die URL, Username und das Passwort werden sich ändern.

Diese Werte können als Umgebungsvariable gesetzt werden, zB in einem Docker-File oder im Tomcat, und dann die Werte der persistence.xml überschreiben.

Also wieder die komplette persistence.xml nutzen:

<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
  http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd"
  version="2.2">
    <persistence-unit name="myapp-persistence-unit">
        <properties>
            <!-- Configure a database connection in Java SE -->
            <property name="javax.persistence.jdbc.driver" value="org.postgresql.Driver" />
            <property name="javax.persistence.jdbc.url" value="jdbc:postgresql://127.0.0.1:5432/myapp" />
            <property name="javax.persistence.jdbc.user" value="postgres" />
            <property name="javax.persistence.jdbc.password" value="PASSWORD" />
 
            <!-- Configure timeouts -->     
            <property name="javax.persistence.lock.timeout" value="100"/>
            <property name="javax.persistence.query.timeout" value="100"/>
        </properties>
    </persistence-unit>
</persistence>

Im Java Code könnte man dann die Werte überschreiben, beispielsweise für das Anzeigen der SQLs:

Properties properties = new Properties();
Optional.ofNullable(System.getenv(SHOW_SQL)).ifPresent( value -> properties.put(SHOW_SQL, value));

EntityManagerFactory emf = Persistence.createEntityManagerFactory("myapp-persistence-unit", properties);

Alternativ könnte man auch den Pfad zu einer Konfigurationsdatei auf dem Server setzen und dann von dort die Werte auslesen.

Categories
Database Development Java

JPA

Bisher habe ich für den Datenbankzugriff mit einem proprietärem Framework gearbeitet, das ich jedoch für das aktuelle Projekt nicht verwenden kann. Bei der Wahl einer frei zugänglichen Alternative entschied ich mich für JPA, die Java/Jakarta Persistence API.

Die Datenbank

Als Datenbank benutze ich einfach das Setup aus meinem letzten Post.

Projekt Setup

Es wird ein neues Maven Projekt angelegt. Java Version 1.8.

Es wird die Javax Persistence API benötigt und eine Implementierung, hier: Hibernate. Als DB wird PostgreSQL verwendet, dazu wird der entsprechende Treiber benötigt.

Die pom.xml des Projekts:

<project xmlns="http://maven.apache.org/POM/4.0.0" 
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>deringo</groupId>
  <artifactId>jpa</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>JPATest</name>
  <description>JPA Test Project</description>

  <properties>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>javax.persistence</groupId>
      <artifactId>javax.persistence-api</artifactId>
      <version>2.2</version>
    </dependency>
    <dependency>
      <groupId>org.hibernate</groupId>
      <artifactId>hibernate-core</artifactId>
      <version>5.6.1.Final</version>
    </dependency>
   <!-- https://mvnrepository.com/artifact/org.postgresql/postgresql -->
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.2.18</version>
    </dependency>
  </dependencies>

</project>

Verbindungsbeschreibung

Die benötigten Informationen für den Verbindungsaufbau mit der DB werden in der persistence.xml hinterlegt:

<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
  http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd"
  version="2.2">
    <persistence-unit name="myapp-persistence-unit">
        <properties>
            <!-- Configure a database connection in Java SE -->
            <property name="javax.persistence.jdbc.driver" value="org.postgresql.Driver" />
            <property name="javax.persistence.jdbc.url" value="jdbc:postgresql://127.0.0.1:5432/myapp" />
            <property name="javax.persistence.jdbc.user" value="postgres" />
            <property name="javax.persistence.jdbc.password" value="PASSWORD" />
 
            <!-- Configure timeouts -->     
            <property name="javax.persistence.lock.timeout" value="100"/>
            <property name="javax.persistence.query.timeout" value="100"/>
        </properties>
    </persistence-unit>
</persistence>

Java Klassen

Die beiden Tabellen Adresse und Person werden jeweils in eine Java Klasse überführt. Dabei handelt es sich um POJOs mit Default Constructor, (generierter) toString, hashCode und equals Methoden. Annotation als Entity und für die ID, die uA objectID heißen soll und nicht wie in der DB object_id.

package deringo.jpa.entity;

import java.io.Serializable;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Adresse implements Serializable {
	private static final long serialVersionUID = 1L;

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "object_id")
	private int objectID;
	
	private String strasse;
	private String ort;
	
	public Adresse() {
		// default constructor
	}

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + objectID;
		result = prime * result + ((ort == null) ? 0 : ort.hashCode());
		result = prime * result + ((strasse == null) ? 0 : strasse.hashCode());
		return result;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		Adresse other = (Adresse) obj;
		if (objectID != other.objectID)
			return false;
		if (ort == null) {
			if (other.ort != null)
				return false;
		} else if (!ort.equals(other.ort))
			return false;
		if (strasse == null) {
			if (other.strasse != null)
				return false;
		} else if (!strasse.equals(other.strasse))
			return false;
		return true;
	}

	@Override
	public String toString() {
		return String.format("Adresse [objectID=%s, strasse=%s, ort=%s]", objectID, strasse, ort);
	}

	public int getObjectID() {
		return objectID;
	}

	public void setObjectID(int objectID) {
		this.objectID = objectID;
	}

	public String getStrasse() {
		return strasse;
	}

	public void setStrasse(String strasse) {
		this.strasse = strasse;
	}

	public String getOrt() {
		return ort;
	}

	public void setOrt(String ort) {
		this.ort = ort;
	}
}

Für den Zugriff auf die Tabellen werden die jeweiligen Repository Klassen angelegt.

package deringo.jpa.repository;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;

import deringo.jpa.entity.Adresse;

public class AdresseRepository {
	private static EntityManagerFactory emf = Persistence.createEntityManagerFactory("myapp-persistence-unit");

	public static Adresse getAdresseById(int id) {
		EntityManager em = emf.createEntityManager();
		return em.find(Adresse.class, id);
	}

}

"Geschäftslogik" um zu testen, ob es funktioniert:

package deringo.jpa;

import deringo.jpa.entity.Adresse;
import deringo.jpa.repository.AdresseRepository;

public class TestMain {

	public static void main(String[] args) {
		int adresseID = 4;
		Adresse adresse = AdresseRepository.getAdresseById(adresseID);
		System.out.println(adresse);
	}

}

Test Driven

Den Zugriff über die Repositories (und später auch Service Klassen) habe ich Test Driven entwickelt mit JUnit. Zur Entwicklung mit JUnit hatte ich schon mal einen Post verfasst.

Folgende Dependencies wurden der pom.xml hinzugefügt:

    <!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
    <dependency>
	  <groupId>org.junit.jupiter</groupId>
	  <artifactId>junit-jupiter-api</artifactId>
	  <version>5.8.1</version>
	  <scope>test</scope>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.hamcrest/hamcrest -->
    <dependency>
      <groupId>org.hamcrest</groupId>
      <artifactId>hamcrest</artifactId>
      <version>2.2</version>
      <scope>test</scope>
    </dependency>
package deringo.jpa.repository;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

import org.junit.jupiter.api.Test;

import deringo.jpa.entity.Adresse;

public class AdresseRepositoryTest {

	@Test
	public void getAnmeldungById() {
		int adresseID = 1;
		Adresse adresse = AdresseRepository.getAdresseById(adresseID);
		assertNull(adresse);
		
		adresseID = 4;
		adresse = AdresseRepository.getAdresseById(adresseID);
		assertNotNull(adresse);
		assertThat(adresse.getObjectID(), is(adresseID));
		assertThat(adresse.getStrasse(), is("Beispielstrasse"));
		assertThat(adresse.getOrt(), is("Beispielstadt"));
	}
}

Projektstruktur

Query

Alle Adressen eines Ortes suchen:

	public static List<Adresse> getAdresseByOrt(String ort) {
		EntityManager em = emf.createEntityManager();
		TypedQuery<Adresse> query = em.createQuery("SELECT a FROM Adresse a WHERE a.ort = :ort", Adresse.class);
		query.setParameter("ort", ort);
		return query.getResultList();
	}

Native Query

Um zB herauszufinden, wie die zuletzt vergebene ObjectID lautet, kann ein native Query verwendet werden:

	public static int getLastObjectID() {
		String sequenceName = "public.object_id_seq";
		String sql = "SELECT s.last_value FROM " + sequenceName + " s";
		EntityManager em = emf.createEntityManager();
		BigInteger value = (BigInteger)em.createNativeQuery(sql).getSingleResult();
		return value.intValue();
	}

Kreuztabelle

Nehmen wir mal an, eine Person kann mehrere Adressen haben und an eine Adresse können mehrere Personen gemeldet sein.

Um das abzubilden benötigen wir zunächst eine Kreuztabelle, die wir in der DB anlegen:

DROP TABLE IF EXISTS public.adresse_person;
CREATE TABLE public.adresse_person (
    adresse_object_id integer NOT NULL,
    person_object_id integer NOT NULL
);

Solch eine Relation programmatisch anlegen:

	public static void createAdressePersonRelation(int adresseId, int personId) {
		String sql = "INSERT INTO adresse_person (adresse_object_id, person_object_id) VALUES (?, ?)";//, adresseId, personId);
		EntityManager em = emf.createEntityManager();
		em.getTransaction().begin();
		em.createNativeQuery(sql)
		  .setParameter(1, adresseId)
		  .setParameter(2, personId)
		  .executeUpdate();
		em.getTransaction().commit();
	}

Die Adresse zu einer Person(enID) lässt sich ermitteln:

	public static Adresse getAdresseByPersonID(int personId) {
		String sql = "SELECT adresse_object_id FROM adresse_person WHERE person_object_id = " + personId;
		EntityManager em = emf.createEntityManager();
		Integer adresseId;
		try {
			adresseId = (Integer)em.createNativeQuery(sql).getSingleResult();
		} catch (NoResultException nre) {
			return null;
		}
		return getAdresseById(adresseId.intValue());
	}

Das funktioniert nur, solange die Person nur eine Adresse hat.

Das kann man so machen, schöner ist es aber über entsprechend ausmodellierte ManyToMany Beziehungen in den Entities.
Das Beispiel vervollständige ich hier erstmal nicht, da ich bisher es in meinem Projekt nur so wie oben beschrieben benötigte.

OneToMany

Wandeln wir obiges Beispiel mal ab: An einer Adresse können mehrere Personen gemeldet sein, aber eine Person immer nur an einer Adresse.

Wir fügen also der Person eine zusätzliche Spalte für die Adresse hinzu:

ALTER TABLE person ADD COLUMN adresse_object_id integer;
--
UPDATE person SET adresse_object_id = 4
public class Person implements Serializable {
  [...]
    @ManyToOne
    @JoinColumn(name="adresse_object_id")
	private Adresse adresse;
  [...]
}
public class Adresse implements Serializable {
  [..]
 	@OneToMany
	@JoinColumn(name="adresse_object_id")
	private List<Person> personen = new ArrayList<>();
  [...]
}

Anschließend noch die Getter&Setter, toString, hashCode&equals neu generieren und einen Test ausführen:

	@Test
	public void getAnmeldungById() {
		int adresseID = 4;
		adresse = AdresseRepository.getAdresseById(adresseID);
		assertNotNull(adresse);
		assertThat(adresse.getObjectID(), is(adresseID));
		assertThat(adresse.getStrasse(), is("Beispielstrasse"));
		assertThat(adresse.getOrt(), is("Beispielstadt"));		
		assertThat(adresse.getPersonen().size(), is(3));
	}

Der Test funktioniert.

ABER: Folgende Zeile am Ende bewirkt einen StackOverflow Error:

	public void getAnmeldungById() {
        [...]
		System.out.println(adresse);
    }

Das Problem ist die generierte toString-Methode in Person:

	@Override
	public String toString() {
		return String.format("Person [objectID=%s, vorname=%s, nachname=%s, adresse=%s]", objectID, vorname, nachname, 
                             adresse);
	}

Es soll das Objekt adresse ausgegeben werden, in welchem in der toString-Methode das Objekt person ausgegeben werden soll, in welchem das Objekt adresse ausgegeben werden, in welchem in der toString-Methode das Objekt person ausgegeben werden soll, in welchem das Objekt adresse ... usw.

Als Lösung muss die toString-Methode von Person händisch angepasst werden, so dass nicht mehr das Objekt adresse, sondern lediglich dessen ID ausgegeben wird:

	@Override
	public String toString() {
		return String.format("Person [objectID=%s, vorname=%s, nachname=%s, adresse=%s]", objectID, vorname, nachname, 
                             adresse == null ? null : adresse.getObjectID());
	}

siehe auch: https://stackoverflow.com/questions/23973347/jpa-java-lang-stackoverflowerror-on-adding-tostring-method-in-entity-classes

Neuen Eintrag speichern

Adresse speichern:

	public static void saveAdresse(Adresse adresse) {
		EntityManager em = emf.createEntityManager();
		em.getTransaction().begin();
		if (adresse.getObjectID() == 0) {
			em.persist(adresse);
		} else {
			em.merge(adresse);
		}
		em.getTransaction().commit();
	}

Testen:

	@Test
	public void saveNewAdresse() {
		int objectID = AdresseRepository.getLastObjectID();

		Adresse adresse = new Adresse();
		adresse.setStrasse("neue Stasse");
		adresse.setOrt("neuer Ort");
		assertThat(adresse.getObjectID(), is(0));
		AdresseRepository.saveAdresse(adresse);
		assertThat(adresse.getObjectID(), is(objectID + 1));
		assertThat(adresse.getOrt(), is("neuer Ort"));
		
		adresse.setOrt("neuerer Ort");
		AdresseRepository.saveAdresse(adresse);
		assertThat(adresse.getObjectID(), is(objectID + 1));
		assertThat(adresse.getOrt(), is("neuerer Ort"));
	}

Eintrag löschen

Man möchte meinen, dass der Code zum löschen einer Adresse wie folgt lautet:

	public static void deleteAdresse(Adresse adresse) {
		EntityManager em = emf.createEntityManager();
		em.getTransaction().begin();
		em.remove(adresse);
		em.getTransaction().commit();
	}

Testen:

	@Test
	public void deleteAdresse() {
		int adresseID = 8;
		Adresse adresse = AdresseRepository.getAdresseById(adresseID);
		assertNotNull(adresse);
		
		AdresseRepository.deleteAdresse(adresse);
		assertNull(adresse);
	}

Der Test schlägt fehl mit der Nachricht: "Removing a detached instance".

Das Problem besteht darin, dass die Adresse zuerst über einen EntityManager gezogen wird, aber das Löschen in einem anderen EntityManager, bzw. dessen neuer Transaktion, erfolgen soll. Dadurch ist die Entität detached und muss erst wieder hinzugefügt werden, um sie schließlich löschen zu können:

	public static void deleteAdresse(Adresse adresse) {
		EntityManager em = emf.createEntityManager();
		em.getTransaction().begin();
		em.remove(em.contains(adresse) ? adresse : em.merge(adresse));
		em.getTransaction().commit();
	}