001/*
002 * Copyright 2024-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet.servlet.jakarta;
018
019import jakarta.servlet.ServletContext;
020import jakarta.servlet.http.HttpSession;
021import jakarta.servlet.http.HttpSessionBindingEvent;
022import jakarta.servlet.http.HttpSessionBindingListener;
023import org.jspecify.annotations.NonNull;
024import org.jspecify.annotations.Nullable;
025
026import javax.annotation.concurrent.ThreadSafe;
027import java.time.Instant;
028import java.util.Collections;
029import java.util.Enumeration;
030import java.util.HashSet;
031import java.util.Map;
032import java.util.Set;
033import java.util.UUID;
034import java.util.concurrent.ConcurrentHashMap;
035
036import static java.util.Objects.requireNonNull;
037
038/**
039 * Soklet integration implementation of {@link HttpSession}.
040 *
041 * @author <a href="https://www.revetkn.com">Mark Allen</a>
042 */
043@ThreadSafe
044public final class SokletHttpSession implements HttpSession {
045        @NonNull
046        private volatile UUID sessionId;
047        @NonNull
048        private final Instant createdAt;
049        @NonNull
050        private volatile Instant lastAccessedAt;
051        @NonNull
052        private final Map<@NonNull String, @NonNull Object> attributes;
053        @NonNull
054        private final ServletContext servletContext;
055        private volatile boolean invalidated;
056        private volatile int maxInactiveInterval;
057        private volatile boolean isNew;
058
059        @NonNull
060        public static SokletHttpSession fromServletContext(@NonNull ServletContext servletContext) {
061                requireNonNull(servletContext);
062                return new SokletHttpSession(servletContext);
063        }
064
065        private SokletHttpSession(@NonNull ServletContext servletContext) {
066                requireNonNull(servletContext);
067
068                this.sessionId = UUID.randomUUID();
069                this.createdAt = Instant.now();
070                this.lastAccessedAt = this.createdAt;
071                this.attributes = new ConcurrentHashMap<>();
072                this.servletContext = servletContext;
073                this.invalidated = false;
074                this.maxInactiveInterval = 0;
075                this.isNew = true;
076        }
077
078        public void setSessionId(@NonNull UUID sessionId) {
079                requireNonNull(sessionId);
080                this.sessionId = sessionId;
081        }
082
083        @NonNull
084        private UUID getSessionId() {
085                return this.sessionId;
086        }
087
088        @NonNull
089        private Instant getCreatedAt() {
090                return this.createdAt;
091        }
092
093        @NonNull
094        private Instant getLastAccessedAt() {
095                return this.lastAccessedAt;
096        }
097
098        @NonNull
099        private Map<@NonNull String, @NonNull Object> getAttributes() {
100                return this.attributes;
101        }
102
103        boolean isInvalidated() {
104                return this.invalidated;
105        }
106
107        private void setInvalidated(boolean invalidated) {
108                this.invalidated = invalidated;
109        }
110
111        private void ensureNotInvalidated() {
112                if (isInvalidated())
113                        throw new IllegalStateException("Session is invalidated");
114        }
115
116        void markAccessed() {
117                this.lastAccessedAt = Instant.now();
118        }
119
120        void markNotNew() {
121                this.isNew = false;
122        }
123
124        // Implementation of HttpSession methods below:
125
126        @Override
127        public long getCreationTime() {
128                ensureNotInvalidated();
129                return getCreatedAt().toEpochMilli();
130        }
131
132        @Override
133        @NonNull
134        public String getId() {
135                ensureNotInvalidated();
136                return getSessionId().toString();
137        }
138
139        @Override
140        public long getLastAccessedTime() {
141                ensureNotInvalidated();
142                return getLastAccessedAt().toEpochMilli();
143        }
144
145        @Override
146        @NonNull
147        public ServletContext getServletContext() {
148                ensureNotInvalidated();
149                return this.servletContext;
150        }
151
152        @Override
153        public void setMaxInactiveInterval(int interval) {
154                ensureNotInvalidated();
155                this.maxInactiveInterval = interval;
156        }
157
158        @Override
159        public int getMaxInactiveInterval() {
160                ensureNotInvalidated();
161                return this.maxInactiveInterval;
162        }
163
164        @Override
165        @Nullable
166        public Object getAttribute(@Nullable String name) {
167                ensureNotInvalidated();
168                return getAttributes().get(name);
169        }
170
171        @Override
172        @NonNull
173        public Enumeration<@NonNull String> getAttributeNames() {
174                ensureNotInvalidated();
175                return Collections.enumeration(getAttributes().keySet());
176        }
177
178        @Override
179        public void setAttribute(@NonNull String name,
180                                                                                                         @Nullable Object value) {
181                requireNonNull(name);
182
183                ensureNotInvalidated();
184
185                if (value == null) {
186                        removeAttribute(name);
187                } else {
188                        Object existingValue = getAttributes().get(name);
189
190                        if (existingValue != null && existingValue instanceof HttpSessionBindingListener)
191                                ((HttpSessionBindingListener) existingValue).valueUnbound(new HttpSessionBindingEvent(this, name, existingValue));
192
193                        getAttributes().put(name, value);
194
195                        if (value instanceof HttpSessionBindingListener)
196                                ((HttpSessionBindingListener) value).valueBound(new HttpSessionBindingEvent(this, name, value));
197                }
198        }
199
200        @Override
201        public void removeAttribute(@NonNull String name) {
202                requireNonNull(name);
203
204                ensureNotInvalidated();
205
206                Object existingValue = getAttributes().get(name);
207
208                if (existingValue != null && existingValue instanceof HttpSessionBindingListener)
209                        ((HttpSessionBindingListener) existingValue).valueUnbound(new HttpSessionBindingEvent(this, name, existingValue));
210
211                getAttributes().remove(name);
212        }
213
214        @Override
215        public void invalidate() {
216                ensureNotInvalidated();
217                // Copy to prevent modification while iterating
218                Set<@NonNull String> namesToRemove = new HashSet<>(getAttributes().keySet());
219
220                for (String name : namesToRemove)
221                        removeAttribute(name);
222
223                setInvalidated(true);
224        }
225
226        @Override
227        public boolean isNew() {
228                ensureNotInvalidated();
229                return this.isNew;
230        }
231}