PageRenderTime 95ms CodeModel.GetById 12ms RepoModel.GetById 1ms app.codeStats 0ms

/dumbhippo/branches/production/server/src/com/dumbhippo/web/servlets/HttpMethodsServlet2.java

https://gitlab.com/manoj-makkuboy/magnetism
Java | 1321 lines | 989 code | 237 blank | 95 comment | 212 complexity | 75577873bc584bc769d49543e759f568 MD5 | raw file
  1. package com.dumbhippo.web.servlets;
  2. import java.io.IOException;
  3. import java.io.OutputStream;
  4. import java.lang.reflect.InvocationTargetException;
  5. import java.lang.reflect.Method;
  6. import java.net.MalformedURLException;
  7. import java.net.URL;
  8. import java.util.ArrayList;
  9. import java.util.Arrays;
  10. import java.util.Collection;
  11. import java.util.Collections;
  12. import java.util.Comparator;
  13. import java.util.EnumSet;
  14. import java.util.HashMap;
  15. import java.util.List;
  16. import java.util.Map;
  17. import java.util.Set;
  18. import javax.servlet.ServletException;
  19. import javax.servlet.http.HttpServletRequest;
  20. import javax.servlet.http.HttpServletResponse;
  21. import javax.servlet.http.HttpSession;
  22. import org.slf4j.Logger;
  23. import com.dumbhippo.ExceptionUtils;
  24. import com.dumbhippo.GlobalSetup;
  25. import com.dumbhippo.XmlBuilder;
  26. import com.dumbhippo.identity20.Guid;
  27. import com.dumbhippo.identity20.Guid.ParseException;
  28. import com.dumbhippo.persistence.EmailResource;
  29. import com.dumbhippo.persistence.Group;
  30. import com.dumbhippo.persistence.Post;
  31. import com.dumbhippo.persistence.SharedFile;
  32. import com.dumbhippo.persistence.User;
  33. import com.dumbhippo.persistence.UserBlockData;
  34. import com.dumbhippo.persistence.ValidationException;
  35. import com.dumbhippo.server.GroupSystem;
  36. import com.dumbhippo.server.HttpContentTypes;
  37. import com.dumbhippo.server.HttpMethods;
  38. import com.dumbhippo.server.HttpOptions;
  39. import com.dumbhippo.server.HttpParams;
  40. import com.dumbhippo.server.HttpResponseData;
  41. import com.dumbhippo.server.HumanVisibleException;
  42. import com.dumbhippo.server.IdentitySpider;
  43. import com.dumbhippo.server.NotFoundException;
  44. import com.dumbhippo.server.PostingBoard;
  45. import com.dumbhippo.server.SharedFileSystem;
  46. import com.dumbhippo.server.Stacker;
  47. import com.dumbhippo.server.XmlMethodErrorCode;
  48. import com.dumbhippo.server.XmlMethodException;
  49. import com.dumbhippo.server.util.EJBUtil;
  50. import com.dumbhippo.server.views.AnonymousViewpoint;
  51. import com.dumbhippo.server.views.UserViewpoint;
  52. import com.dumbhippo.server.views.Viewpoint;
  53. import com.dumbhippo.tx.RetryException;
  54. import com.dumbhippo.web.LoginCookie;
  55. import com.dumbhippo.web.SigninBean;
  56. import com.dumbhippo.web.UserSigninBean;
  57. import com.dumbhippo.web.WebEJBUtil;
  58. import com.dumbhippo.web.WebStatistics;
  59. /**
  60. * This servlet proxies http method invocations ("ajax api") onto
  61. * specially-annotated Java methods. It can also spit out docs for
  62. * the available API.
  63. *
  64. */
  65. public class HttpMethodsServlet2 extends AbstractServlet {
  66. private static final Logger logger = GlobalSetup.getLogger(HttpMethodsServlet2.class);
  67. private static final long serialVersionUID = 0L;
  68. private static HttpMethodRepository repository;
  69. private static HttpMethodRepository getRepository() {
  70. synchronized (HttpMethodsServlet2.class) {
  71. if (repository == null) {
  72. repository = new HttpMethodRepository(HttpMethods.class);
  73. }
  74. }
  75. return repository;
  76. }
  77. private static class HttpMethodRepository {
  78. private Map<Class<?>,Marshaller<?>> marshallers;
  79. private Map<String,HttpMethod> methods;
  80. private Map<String,HttpMethod> lowercaseMethods;
  81. private List<HttpMethod> sortedMethods;
  82. HttpMethodRepository(Class<?>... interfaces) {
  83. methods = new HashMap<String,HttpMethod>();
  84. marshallers = new HashMap<Class<?>,Marshaller<?>>();
  85. marshallers.put(String.class, new Marshaller<String>() {
  86. public String marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
  87. return s;
  88. }
  89. public Class<?> getType() {
  90. return String.class;
  91. }
  92. });
  93. marshallers.put(boolean.class, new Marshaller<Boolean>() {
  94. public Boolean marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
  95. if (s == null)
  96. return false;
  97. else if (s.equals("true"))
  98. return true;
  99. else if (s.equals("false"))
  100. return false;
  101. else
  102. throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "could not parse boolean value: '" + s + "' use 'true' or 'false'");
  103. }
  104. public Class<?> getType() {
  105. return boolean.class;
  106. }
  107. });
  108. marshallers.put(int.class, new Marshaller<Integer>() {
  109. public Integer marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
  110. if (s == null)
  111. return -1;
  112. try {
  113. return Integer.parseInt(s);
  114. } catch (NumberFormatException e) {
  115. throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "could not parse integer value: '" + s + "'");
  116. }
  117. }
  118. public Class<?> getType() {
  119. return int.class;
  120. }
  121. });
  122. marshallers.put(User.class, new Marshaller<User>() {
  123. public User marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
  124. if (s == null)
  125. return null;
  126. IdentitySpider identitySpider = WebEJBUtil.defaultLookup(IdentitySpider.class);
  127. try {
  128. return identitySpider.lookupGuidString(User.class, s);
  129. } catch (ParseException e) {
  130. throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, "bad userId " + s);
  131. } catch (NotFoundException e) {
  132. throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "no such person " + s);
  133. }
  134. }
  135. public Class<?> getType() {
  136. return User.class;
  137. }
  138. });
  139. marshallers.put(Group.class, new Marshaller<Group>() {
  140. public Group marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
  141. if (s == null)
  142. return null;
  143. GroupSystem groupSystem = WebEJBUtil.defaultLookup(GroupSystem.class);
  144. try {
  145. return groupSystem.lookupGroupById(viewpoint, s);
  146. } catch (NotFoundException e) {
  147. throw new XmlMethodException(XmlMethodErrorCode.UNKNOWN_GROUP, "Unknown group '" + s + "'");
  148. }
  149. }
  150. public Class<?> getType() {
  151. return Group.class;
  152. }
  153. });
  154. marshallers.put(Post.class, new Marshaller<Post>() {
  155. public Post marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
  156. if (s == null)
  157. return null;
  158. PostingBoard postingBoard = WebEJBUtil.defaultLookup(PostingBoard.class);
  159. try {
  160. return postingBoard.loadRawPost(viewpoint, new Guid(s));
  161. } catch (ParseException e) {
  162. throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, "bad postId " + s);
  163. } catch (NotFoundException e) {
  164. throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "no such post " + s);
  165. }
  166. }
  167. public Class<?> getType() {
  168. return Post.class;
  169. }
  170. });
  171. marshallers.put(UserBlockData.class, new Marshaller<UserBlockData>() {
  172. public UserBlockData marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
  173. if (s == null)
  174. return null;
  175. if (!(viewpoint instanceof UserViewpoint)) {
  176. throw new XmlMethodException(XmlMethodErrorCode.NOT_LOGGED_IN, "This method requires login");
  177. }
  178. UserViewpoint userViewpoint = (UserViewpoint) viewpoint;
  179. Stacker stacker = WebEJBUtil.defaultLookup(Stacker.class);
  180. try {
  181. return stacker.lookupUserBlockData(userViewpoint, new Guid(s));
  182. } catch (ParseException e) {
  183. throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, "bad block id " + s);
  184. } catch (NotFoundException e) {
  185. throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "no such block " + s);
  186. }
  187. }
  188. public Class<?> getType() {
  189. return UserBlockData.class;
  190. }
  191. });
  192. marshallers.put(EmailResource.class, new Marshaller<EmailResource>() {
  193. public EmailResource marshal(Viewpoint viewpoint, String s) throws XmlMethodException, RetryException {
  194. if (s == null)
  195. return null;
  196. IdentitySpider identitySpider = WebEJBUtil.defaultLookup(IdentitySpider.class);
  197. try {
  198. return identitySpider.getEmail(s);
  199. } catch (ValidationException e) {
  200. throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, "bad email address " + s);
  201. }
  202. }
  203. public Class<?> getType() {
  204. return UserBlockData.class;
  205. }
  206. });
  207. marshallers.put(URL.class, new Marshaller<URL>() {
  208. public URL marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
  209. if (s == null)
  210. return null;
  211. s = s.trim();
  212. URL url;
  213. if (s.length() == 0) {
  214. url = null;
  215. } else {
  216. try {
  217. url = new URL(s);
  218. } catch (MalformedURLException e) {
  219. if (!s.startsWith("http://")) {
  220. // let users type just "example.com" instead of "http://example.com"
  221. return marshal(viewpoint, "http://" + s);
  222. } else {
  223. throw new XmlMethodException(XmlMethodErrorCode.INVALID_URL, "Invalid URL: '" + s + "'");
  224. }
  225. }
  226. }
  227. return url;
  228. }
  229. public Class<?> getType() {
  230. return URL.class;
  231. }
  232. });
  233. marshallers.put(SharedFile.class, new Marshaller<SharedFile>() {
  234. public SharedFile marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
  235. if (s == null)
  236. return null;
  237. SharedFileSystem sharedFileSystem = WebEJBUtil.defaultLookup(SharedFileSystem.class);
  238. try {
  239. return sharedFileSystem.lookupFile(viewpoint, new Guid(s), true);
  240. } catch (ParseException e) {
  241. throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, "bad file id " + s);
  242. } catch (NotFoundException e) {
  243. throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT, "no such file " + s);
  244. }
  245. }
  246. public Class<?> getType() {
  247. return SharedFile.class;
  248. }
  249. });
  250. marshallers.put(Guid.class, new Marshaller<Guid>() {
  251. public Guid marshal(Viewpoint viewpoint, String s) throws XmlMethodException {
  252. if (s == null)
  253. return null;
  254. try {
  255. return new Guid(s);
  256. } catch (ParseException e) {
  257. throw new XmlMethodException(XmlMethodErrorCode.PARSE_ERROR, e.getMessage());
  258. }
  259. }
  260. public Class<?> getType() {
  261. return Guid.class;
  262. }
  263. });
  264. for (Class<?> iface : interfaces) {
  265. scanInterface(iface);
  266. }
  267. lock();
  268. logger.debug("HttpMethodRepository ready with {} methods on {} interfaces", methods.size(), interfaces.length);
  269. }
  270. Marshaller lookupMarshaller(Class<?> klass) {
  271. return marshallers.get(klass);
  272. }
  273. private void scanInterface(Class<?> iface) {
  274. if (!iface.isInterface())
  275. throw new IllegalArgumentException("not an interface: " + iface.getName());
  276. int count = 0;
  277. logger.debug("Scanning interface {} for http-invokable methods", iface.getName());
  278. for (Method m : iface.getMethods()) {
  279. HttpContentTypes contentAnnotation = m.getAnnotation(HttpContentTypes.class);
  280. if (contentAnnotation == null) {
  281. logger.debug(" method {} has no content type annotation, skipping", m.getName());
  282. continue;
  283. }
  284. HttpMethod hMethod = new HttpMethod(this, m, contentAnnotation);
  285. methods.put(hMethod.getName(), hMethod);
  286. count += 1;
  287. }
  288. logger.debug("Found {} methods on {}", count, iface.getName());
  289. }
  290. /*
  291. private void scanClass(Class<?> klass) {
  292. Class<?>[] interfaces = klass.getInterfaces();
  293. for (Class<?> iface : interfaces) {
  294. scanInterface(iface);
  295. }
  296. }
  297. */
  298. Collection<HttpMethod> getMethods() {
  299. return sortedMethods;
  300. }
  301. HttpMethod lookupMethod(String name) {
  302. if (sortedMethods == null || lowercaseMethods == null)
  303. throw new IllegalStateException("need to lock() prior to lookupMethod()");
  304. if (name == null)
  305. throw new IllegalArgumentException("null name passed to lookupMethod()");
  306. HttpMethod m = methods.get(name);
  307. if (m == null)
  308. m = lowercaseMethods.get(name);
  309. return m;
  310. }
  311. private void lock() {
  312. if (sortedMethods != null)
  313. throw new IllegalStateException("can only lock() once");
  314. sortedMethods = new ArrayList<HttpMethod>();
  315. for (HttpMethod m : methods.values()) {
  316. sortedMethods.add(m);
  317. }
  318. Collections.sort(sortedMethods, new Comparator<HttpMethod>() {
  319. public int compare(HttpMethod a, HttpMethod b) {
  320. return String.CASE_INSENSITIVE_ORDER.compare(a.getName(), b.getName());
  321. }
  322. });
  323. // so we can return it by reference
  324. sortedMethods = Collections.unmodifiableList(sortedMethods);
  325. // this is really for legacy javascript, studlyCaps can be used
  326. // now
  327. lowercaseMethods = new HashMap<String,HttpMethod>();
  328. for (HttpMethod m : methods.values()) {
  329. lowercaseMethods.put(m.getName().toLowerCase(), m);
  330. }
  331. }
  332. }
  333. private interface Marshaller<BoxedArgType> {
  334. /**
  335. * Marshal from a string parameter to a Java method argument
  336. * @param viewpoint viewpoint of the request
  337. * @param s string from http request, or null if param not provided
  338. *
  339. * @return the marshaled object (null is allowed)
  340. * @throws XmlMethodException thrown if the string can't be parsed
  341. * @throws RetryException
  342. */
  343. public BoxedArgType marshal(Viewpoint viewpoint, String s) throws XmlMethodException, RetryException;
  344. /**
  345. * Gets the unboxed type that we marshal to; not templatized
  346. * since unboxed types can't be used in generics
  347. * @return the unboxed type
  348. */
  349. public Class<?> getType();
  350. }
  351. private static class HttpMethodParam {
  352. private String name;
  353. private boolean isOptional;
  354. private Marshaller marshaller;
  355. public HttpMethodParam(String name, boolean isOptional,
  356. Marshaller marshaller) {
  357. this.name = name;
  358. this.isOptional = isOptional;
  359. this.marshaller = marshaller;
  360. }
  361. public boolean isOptional() {
  362. return isOptional;
  363. }
  364. public String getName() {
  365. return name;
  366. }
  367. public Marshaller getMarshaller() {
  368. return marshaller;
  369. }
  370. }
  371. private static class HttpMethod {
  372. private String name;
  373. private boolean requiresPost;
  374. private List<HttpMethodParam> params;
  375. private boolean invalidatesSession;
  376. private boolean needsOutputStream;
  377. private boolean needsXmlBuilder;
  378. private boolean needsRequestedContentType;
  379. private boolean needsViewpoint;
  380. private boolean needsUserViewpoint;
  381. private Set<HttpResponseData> contentTypes;
  382. private boolean adminOnly;
  383. private boolean allowsDisabledAccount;
  384. private boolean requiresTransaction;
  385. private Method method;
  386. HttpMethod(HttpMethodRepository repository, Method m,
  387. HttpContentTypes contentAnnotation) {
  388. this.method = m;
  389. HttpParams paramsAnnotation = m.getAnnotation(HttpParams.class);
  390. HttpOptions optionsAnnotation = m.getAnnotation(HttpOptions.class);
  391. if (paramsAnnotation == null) {
  392. throw new RuntimeException("missing params annotation on " + m.getName());
  393. }
  394. if (optionsAnnotation != null) {
  395. adminOnly = optionsAnnotation.adminOnly();
  396. allowsDisabledAccount = optionsAnnotation.allowDisabledAccount();
  397. invalidatesSession = optionsAnnotation.invalidatesSession();
  398. requiresTransaction = optionsAnnotation.transaction();
  399. } else {
  400. requiresTransaction = true;
  401. }
  402. if (m.getReturnType() != void.class)
  403. throw new RuntimeException("HTTP method " + m.getName() + " must return void not "
  404. + m.getReturnType().getCanonicalName());
  405. String javaName = m.getName();
  406. if (javaName.startsWith("get")) {
  407. requiresPost = false;
  408. name = Character.toLowerCase(javaName.charAt(3)) + javaName.substring(4);
  409. } else if (javaName.startsWith("do")) {
  410. requiresPost = true;
  411. name = Character.toLowerCase(javaName.charAt(2)) + javaName.substring(3);
  412. } else {
  413. throw new RuntimeException("http method must start with get or do");
  414. }
  415. contentTypes = EnumSet.noneOf(HttpResponseData.class);
  416. for (HttpResponseData r : contentAnnotation.value()) {
  417. contentTypes.add(r);
  418. }
  419. if (contentTypes.contains(HttpResponseData.XML) &&
  420. contentTypes.contains(HttpResponseData.XMLMETHOD)) {
  421. throw new RuntimeException("Can't return both hand-coded XML and XMLMETHOD style XML from the same API call " + m.getName());
  422. }
  423. if (contentTypes.isEmpty())
  424. throw new RuntimeException("method has no return types specified " + m.getName());
  425. // lock it down
  426. contentTypes = Collections.unmodifiableSet(contentTypes);
  427. Class<?> args[] = m.getParameterTypes();
  428. if (args.length == 0)
  429. throw new RuntimeException("method doesn't take any args! " + m.getName());
  430. int i = 0;
  431. if (OutputStream.class.isAssignableFrom(args[i])) {
  432. needsOutputStream = true;
  433. ++i;
  434. } else if (XmlBuilder.class.isAssignableFrom(args[i])) {
  435. needsXmlBuilder = true;
  436. ++i;
  437. }
  438. boolean neverReturnsData = contentTypes.size() == 1 &&
  439. contentTypes.contains(HttpResponseData.NONE);
  440. if (neverReturnsData && (needsOutputStream || needsXmlBuilder)) {
  441. throw new RuntimeException("method never returns data but has an output arg " + m.getName());
  442. }
  443. if (!neverReturnsData && !(needsOutputStream || needsXmlBuilder)) {
  444. throw new RuntimeException("method returns data but has no OutputStream or XmlBuilder arg " + m.getName());
  445. }
  446. if (!neverReturnsData && allowsDisabledAccount) {
  447. throw new RuntimeException("account-disabling methods can't return data since the current code will try setting a cookie after the method runs");
  448. }
  449. if (i < args.length && HttpResponseData.class.isAssignableFrom(args[i])) {
  450. needsRequestedContentType = true;
  451. ++i;
  452. }
  453. if (contentTypes.size() > 1 && !needsRequestedContentType) {
  454. throw new RuntimeException("method must have HttpResponseData arg to specify which content type since it supports multiple " + m.getName());
  455. }
  456. if (i < args.length && Viewpoint.class.isAssignableFrom(args[i])) {
  457. needsViewpoint = true;
  458. if (UserViewpoint.class.isAssignableFrom(args[i])) {
  459. needsUserViewpoint = true;
  460. }
  461. ++i;
  462. }
  463. if (args.length != i + paramsAnnotation.value().length) {
  464. throw new RuntimeException("method " + m.getName() + " should have params " + Arrays.toString(paramsAnnotation.value()));
  465. }
  466. params = new ArrayList<HttpMethodParam>(paramsAnnotation.value().length);
  467. for (String pname : paramsAnnotation.value()) {
  468. Marshaller marshaller = repository.lookupMarshaller(args[i]);
  469. if (marshaller == null)
  470. throw new RuntimeException("don't know how to marshal argument to " + m.getName() + " of type " + args[i].getName());
  471. boolean isOptional = false;
  472. if (optionsAnnotation != null) {
  473. String[] optional = optionsAnnotation.optionalParams();
  474. for (String o : optional) {
  475. if (o.equals(pname)) {
  476. isOptional = true;
  477. break;
  478. }
  479. }
  480. }
  481. params.add(new HttpMethodParam(pname, isOptional, marshaller));
  482. ++i;
  483. }
  484. // lock it down
  485. params = Collections.unmodifiableList(params);
  486. }
  487. public String getName() {
  488. return name;
  489. }
  490. public boolean isRequiresPost() {
  491. return requiresPost;
  492. }
  493. public boolean isAdminOnly() {
  494. return adminOnly;
  495. }
  496. public boolean isRequiresTransaction() {
  497. return requiresTransaction;
  498. }
  499. public boolean isAllowsDisabledAccount() {
  500. return allowsDisabledAccount;
  501. }
  502. public Set<HttpResponseData> getContentTypes() {
  503. return contentTypes;
  504. }
  505. public boolean isNeedsOutputStream() {
  506. return needsOutputStream;
  507. }
  508. public boolean isNeedsRequestedContentType() {
  509. return needsRequestedContentType;
  510. }
  511. public boolean isNeedsUserViewpoint() {
  512. return needsUserViewpoint;
  513. }
  514. public boolean isNeedsViewpoint() {
  515. return needsViewpoint;
  516. }
  517. public boolean isNeedsXmlBuilder() {
  518. return needsXmlBuilder;
  519. }
  520. public List<HttpMethodParam> getParams() {
  521. return params;
  522. }
  523. public Method getMethod() {
  524. return method;
  525. }
  526. public boolean isInvalidatesSession() {
  527. return invalidatesSession;
  528. }
  529. }
  530. public HttpMethodsServlet2() {
  531. }
  532. interface MethodFilter {
  533. public boolean included(HttpMethod m);
  534. }
  535. static private void appendIndexSection(XmlBuilder xml,
  536. String title,
  537. Collection<HttpMethod> methods,
  538. MethodFilter filter) {
  539. xml.appendTextNode("h2", title);
  540. xml.openElement("ul");
  541. for (HttpMethod m : methods) {
  542. if (filter.included(m)) {
  543. xml.openElement("li");
  544. xml.appendTextNode("a", m.getName(), "href",
  545. "/api-docs/" + m.getName());
  546. xml.closeElement();
  547. }
  548. }
  549. xml.closeElement();
  550. }
  551. static private void handleApiDocsIndex(HttpServletRequest request,
  552. HttpServletResponse response) throws HttpException, IOException {
  553. // probably we should just use a jsp for this sooner or later
  554. XmlBuilder xml = new XmlBuilder();
  555. xml.appendHtmlHead("API Index");
  556. xml.openElement("body");
  557. xml.openElement("div");
  558. {
  559. Collection<HttpMethod> methods = getRepository().getMethods();
  560. appendIndexSection(xml, "Anonymously-callable Methods", methods,
  561. new MethodFilter() {
  562. public boolean included(HttpMethod m) {
  563. return !m.isNeedsUserViewpoint() && !m.isAdminOnly();
  564. }
  565. });
  566. appendIndexSection(xml, "Login-required Methods", methods,
  567. new MethodFilter() {
  568. public boolean included(HttpMethod m) {
  569. return m.isNeedsUserViewpoint() && !m.isAdminOnly();
  570. }
  571. });
  572. appendIndexSection(xml, "Administrator-only Methods", methods,
  573. new MethodFilter() {
  574. public boolean included(HttpMethod m) {
  575. return m.isAdminOnly();
  576. }
  577. });
  578. }
  579. xml.closeElement();
  580. xml.closeElement();
  581. response.setContentType("text/html");
  582. response.getOutputStream().write(xml.getBytes());
  583. }
  584. static private void handleApiDocsMethod(HttpServletRequest request,
  585. HttpServletResponse response, String methodName) throws HttpException, IOException {
  586. HttpMethod method = getRepository().lookupMethod(methodName);
  587. if (method == null)
  588. throw new HttpException(HttpResponseCode.NOT_FOUND, "Unknown API method '" + methodName + "'");
  589. // probably we should just use a jsp for this sooner or later
  590. XmlBuilder xml = new XmlBuilder();
  591. xml.appendHtmlHead("API method " + method.getName());
  592. xml.openElement("body");
  593. {
  594. xml.openElement("div");
  595. xml.appendTextNode("h2", method.getName());
  596. {
  597. xml.openElement("div");
  598. String getOrPost;
  599. if (method.isRequiresPost())
  600. getOrPost = "Only works with POST, not GET";
  601. else
  602. getOrPost = "Works with GET or POST";
  603. xml.appendTextNode("i", getOrPost);
  604. xml.closeElement();
  605. }
  606. {
  607. xml.openElement("div");
  608. String login;
  609. if (method.isNeedsUserViewpoint())
  610. login = "Requires a login cookie (cannot be used anonymously)";
  611. else
  612. login = "Works anonymously (login cookie need not be provided)";
  613. xml.appendTextNode("i", login);
  614. xml.closeElement();
  615. }
  616. if (method.isAdminOnly()) {
  617. xml.openElement("div");
  618. xml.appendTextNode("i", "Requires administrator privileges");
  619. xml.closeElement();
  620. }
  621. {
  622. xml.openElement("div");
  623. xml.appendTextNode("h3", "Parameters");
  624. xml.openElement("ul");
  625. for (HttpMethodParam p : method.getParams()) {
  626. xml.openElement("li");
  627. xml.append(p.getName());
  628. xml.append(" - ");
  629. xml.append(p.getMarshaller().getType().getName());
  630. if (p.isOptional()) {
  631. xml.appendTextNode("i", " - (optional)");
  632. }
  633. }
  634. xml.closeElement();
  635. xml.closeElement();
  636. }
  637. xml.closeElement();
  638. }
  639. xml.closeElement();
  640. response.setContentType("text/html");
  641. response.getOutputStream().write(xml.getBytes());
  642. }
  643. static private String arrayToStringXmlBuilderWorkaround(Object[] array) {
  644. // XmlBuilder.toString() has kind of a broken side effect of closing the XML document,
  645. // so we can't use Arrays.toString()
  646. StringBuilder sb = new StringBuilder();
  647. sb.append("{");
  648. for (Object arg : array) {
  649. if (arg instanceof XmlBuilder)
  650. sb.append("XmlBuilder");
  651. else
  652. sb.append(arg != null ? arg.toString() : "null");
  653. sb.append(", ");
  654. }
  655. if (array.length > 0)
  656. sb.setLength(sb.length() - 2); // delete last comma
  657. sb.append("}");
  658. return sb.toString();
  659. }
  660. /**
  661. * This should run the method and write successful output to the output stream.
  662. *
  663. * The caller will write any exception to the output stream, so this method
  664. * should just throw the exception (as an XmlMethodException).
  665. *
  666. * The caller should take care of side effects and sanity checks, this
  667. * method should only marshal args and run the method. This method does
  668. * do some checks that relate to args, such as those that use the viewpoint.
  669. *
  670. * Note that checks for the validity of the method itself and its annotations
  671. * should be done when the method is initially loaded, not now on each invocation.
  672. *
  673. * @param m
  674. * @param requestedContentType
  675. * @param request
  676. * @param response
  677. * @throws IOException
  678. * @throws XmlMethodException
  679. * @throws RetryException
  680. */
  681. static private void invokeMethod(HttpMethod m, HttpResponseData requestedContentType, HttpServletRequest request, HttpServletResponse response) throws IOException, XmlMethodException, RetryException {
  682. Class<?> iface = m.getMethod().getDeclaringClass();
  683. Object instance = WebEJBUtil.defaultLookup(iface);
  684. Method javaMethod = m.getMethod();
  685. OutputStream out = response.getOutputStream();
  686. if (requestedContentType != HttpResponseData.NONE)
  687. response.setContentType(requestedContentType.getMimeType());
  688. SigninBean signin = SigninBean.getForRequest(request);
  689. UserViewpoint userViewpoint = null;
  690. // if the method doesn't specifically allow disabled accounts, we treat
  691. // the request as anonymous instead of as from a user viewpoint
  692. if ((m.isAllowsDisabledAccount() || signin.isActive()) && signin instanceof UserSigninBean)
  693. userViewpoint = ((UserSigninBean)signin).getViewpoint();
  694. if (userViewpoint == null && m.isNeedsUserViewpoint()) {
  695. throw new XmlMethodException(XmlMethodErrorCode.NOT_LOGGED_IN, "You need to be signed in to call this");
  696. }
  697. if (m.isAdminOnly()) {
  698. IdentitySpider spider = EJBUtil.defaultLookup(IdentitySpider.class);
  699. if (userViewpoint == null || !spider.isAdministrator(userViewpoint.getViewer()))
  700. throw new XmlMethodException(XmlMethodErrorCode.FORBIDDEN, "You need administrator privileges to do this");
  701. }
  702. Viewpoint viewpoint = null;
  703. if (userViewpoint != null)
  704. viewpoint = userViewpoint;
  705. else
  706. viewpoint = AnonymousViewpoint.getInstance();
  707. // FIXME allow an XmlBuilder arg instead of output stream for
  708. // HttpResponseData.XML as well as XMLMETHOD
  709. XmlBuilder xml = null;
  710. if (m.isNeedsXmlBuilder() && requestedContentType == HttpResponseData.XMLMETHOD) {
  711. xml = new XmlBuilder();
  712. xml.appendStandaloneFragmentHeader();
  713. xml.openElement("rsp", "stat", "ok");
  714. }
  715. int argc = m.getParams().size();
  716. if (m.isNeedsOutputStream())
  717. ++argc;
  718. if (m.isNeedsXmlBuilder())
  719. ++argc;
  720. if (m.isNeedsRequestedContentType())
  721. ++argc;
  722. if (m.isNeedsViewpoint())
  723. ++argc;
  724. Object[] argv = new Object[argc];
  725. int i = 0;
  726. if (m.isNeedsOutputStream()) {
  727. argv[i] = out;
  728. ++i;
  729. }
  730. if (m.isNeedsXmlBuilder()) {
  731. // note xml may just be null here e.g. if a method supports both
  732. // XMLMETHOD and NONE
  733. argv[i] = xml;
  734. ++i;
  735. }
  736. if (m.isNeedsRequestedContentType()) {
  737. argv[i] = requestedContentType;
  738. ++i;
  739. }
  740. if (m.isNeedsViewpoint()) {
  741. argv[i] = viewpoint;
  742. ++i;
  743. }
  744. for (HttpMethodParam param : m.getParams()) {
  745. String value = request.getParameter(param.getName());
  746. if (value == null && !param.isOptional()) {
  747. throw new XmlMethodException(XmlMethodErrorCode.INVALID_ARGUMENT,
  748. "Parameter " + param.getName() + " is required");
  749. }
  750. // if value is null for an optional param, the marshaller is supposed
  751. // to pass it through appropriately.
  752. argv[i] = param.getMarshaller().marshal(viewpoint, value);
  753. ++i;
  754. }
  755. try {
  756. if (logger.isDebugEnabled()) {
  757. String showArgs = arrayToStringXmlBuilderWorkaround(argv);
  758. // suppress plaintext password from appearing in log
  759. if (javaMethod.getName().equals("setPassword"))
  760. showArgs = "[SUPPRESSED FROM LOG]";
  761. logger.debug("Invoking method {} with args {}", javaMethod.getName(), showArgs);
  762. }
  763. javaMethod.invoke(instance, argv);
  764. } catch (IllegalArgumentException e) {
  765. logger.error("invoking method on http methods bean, illegal argument", e);
  766. throw new XmlMethodException(XmlMethodErrorCode.INTERNAL_SERVER_ERROR, "Internal server error");
  767. } catch (IllegalAccessException e) {
  768. logger.error("invoking method on http methods bean, illegal access", e);
  769. throw new XmlMethodException(XmlMethodErrorCode.INTERNAL_SERVER_ERROR, "Internal server error");
  770. } catch (InvocationTargetException e) {
  771. Throwable cause = e.getCause();
  772. Throwable rootCause = ExceptionUtils.getRootCause(e);
  773. WebStatistics.getInstance().incrementHttpMethodErrors();
  774. // HumanVisibleException is a legacy thing; it's useless
  775. // from an http method since it redirects to the error page.
  776. // We convert it to XmlMethodException
  777. if (cause instanceof HumanVisibleException) {
  778. HumanVisibleException visibleException = (HumanVisibleException) cause;
  779. throw new XmlMethodException(XmlMethodErrorCode.FAILED, visibleException.getMessage());
  780. } else if (cause instanceof XmlMethodException){
  781. XmlMethodException methodException = (XmlMethodException) cause;
  782. throw methodException;
  783. } else if (cause instanceof IOException) {
  784. IOException ioException = (IOException) cause;
  785. throw ioException;
  786. } else if (cause instanceof RetryException) {
  787. RetryException retryException = (RetryException) cause;
  788. throw retryException;
  789. } else {
  790. logger.error("Exception root cause is {} message: {}", rootCause.getClass().getName(), rootCause.getMessage());
  791. logger.error("invoking method on http methods bean, unexpected", e);
  792. throw new XmlMethodException(XmlMethodErrorCode.INTERNAL_SERVER_ERROR, "Internal server error");
  793. }
  794. }
  795. if (xml != null) {
  796. // content type already set at the top of the method
  797. xml.closeElement();
  798. byte[] bytes = xml.getBytes();
  799. response.setContentLength(bytes.length);
  800. out.write(bytes);
  801. }
  802. // The point of the allowDisabledUser annotations is to allow methods that
  803. // take a user from the disabled state to the enabled state; once we are
  804. // enabled, we need to persistant cookies, so we check that here. This
  805. // is a little hacky, but simpler than creating custom servlets.
  806. //
  807. // Note that this won't work well with methods that have write
  808. // output, since output may have already been written, and it
  809. // will be too late to set cookies. So we disallow that when first
  810. // scanning the method.
  811. if (m.isAllowsDisabledAccount()) {
  812. SigninBean.updateAuthentication(request, response);
  813. }
  814. logger.debug("Finishing generating reply for {}", m.getName());
  815. }
  816. static private void writeXmlMethodError(HttpServletResponse response, String code, String message) throws IOException {
  817. XmlBuilder xml = new XmlBuilder();
  818. xml.appendStandaloneFragmentHeader();
  819. xml.openElement("rsp", "stat", "fail");
  820. xml.appendTextNode("err", "", "code", code, "msg", message);
  821. xml.closeElement();
  822. response.setContentType(HttpResponseData.XMLMETHOD.getMimeType());
  823. byte[] bytes = xml.getBytes();
  824. response.setContentLength(bytes.length);
  825. OutputStream out = response.getOutputStream();
  826. out.write(bytes);
  827. out.flush();
  828. out.close();
  829. }
  830. static private void writeXmlMethodError(HttpServletResponse response, XmlMethodException exception) throws IOException {
  831. writeXmlMethodError(response, exception.getCodeString(), exception.getMessage());
  832. }
  833. private interface RequestHandler {
  834. public boolean getNoCache();
  835. public boolean getRequiresTransaction();
  836. public void handle(HttpServletRequest request, HttpServletResponse response, boolean isPost)
  837. throws HttpException, IOException, RetryException;
  838. public boolean isReadWrite();
  839. }
  840. private static class HttpMethodRequestHandler implements RequestHandler {
  841. private String typeDir;
  842. private HttpMethod method;
  843. HttpMethodRequestHandler(String typeDir, HttpMethod method) {
  844. this.typeDir = typeDir;
  845. this.method = method;
  846. }
  847. public boolean getNoCache() {
  848. return true;
  849. }
  850. public boolean getRequiresTransaction() {
  851. return method.isRequiresTransaction();
  852. }
  853. public boolean isReadWrite() {
  854. return method.isRequiresPost();
  855. }
  856. public void handle(HttpServletRequest request, HttpServletResponse response, boolean isPost) throws HttpException, IOException, RetryException {
  857. HttpResponseData requestedContentType;
  858. if (typeDir.equals("xml")) {
  859. // gets overwritten with XMLMETHOD later if appropriate
  860. requestedContentType = HttpResponseData.XML;
  861. } else if (typeDir.equals("text"))
  862. requestedContentType = HttpResponseData.TEXT;
  863. else if (isPost && typeDir.equals("action"))
  864. requestedContentType = HttpResponseData.NONE;
  865. else {
  866. throw new HttpException(HttpResponseCode.NOT_FOUND,
  867. "Don't know about URI path /" + typeDir + " , only /xml, /text for GET plus /action for POST only)");
  868. }
  869. if (requestedContentType == HttpResponseData.XML &&
  870. method.getContentTypes().contains(HttpResponseData.XMLMETHOD))
  871. requestedContentType = HttpResponseData.XMLMETHOD;
  872. if (!method.getContentTypes().contains(requestedContentType)) {
  873. throw new HttpException(HttpResponseCode.NOT_FOUND, "Wrong content type requested "
  874. + requestedContentType + " valid types for method are " + method.getContentTypes());
  875. }
  876. if (!isPost && method.isRequiresPost())
  877. throw new HttpException(HttpResponseCode.BAD_REQUEST, "Method only works via POST not GET");
  878. try {
  879. invokeMethod(method, requestedContentType, request, response);
  880. } catch (XmlMethodException e) {
  881. WebStatistics.getInstance().incrementHttpMethodErrors();
  882. if (requestedContentType == HttpResponseData.XMLMETHOD) {
  883. writeXmlMethodError(response, e);
  884. return;
  885. } else {
  886. throw new HttpException(HttpResponseCode.BAD_REQUEST,
  887. e.getCodeString() + ": " + e.getMessage());
  888. }
  889. }
  890. ////// Note that we always throw or return on exception... so this code
  891. ////// runs only on method success
  892. WebStatistics.getInstance().incrementHttpMethodsServed();
  893. if (method.isInvalidatesSession()) {
  894. HttpSession sess = request.getSession(false);
  895. if (sess != null)
  896. sess.invalidate();
  897. }
  898. }
  899. }
  900. private String[] parseUri(String uri) {
  901. if (uri.length() < 4) { // "/a/b".length() == 4
  902. logger.debug("URI is too short to be valid, should be of the form /typeprefix/methodname, e.g. /xml/frobate");
  903. return null;
  904. }
  905. // ignore trailing /
  906. if (uri.endsWith("/"))
  907. uri = uri.substring(0, uri.length()-1);
  908. // split into the two components, the result is { "", "xml", "frobate" }
  909. String[] ret = uri.split("/");
  910. if (ret.length != 3) {
  911. logger.debug("All URIs are of the form /typeprefix/methodname, e.g. /xml/frobate, split into: " + Arrays.toString(ret));
  912. return null;
  913. }
  914. return new String[] { ret[1], ret[2] };
  915. }
  916. private RequestHandler tryHttpRequest(HttpServletRequest request) {
  917. String requestUri = request.getRequestURI();
  918. String[] uriComponents = parseUri(requestUri);
  919. if (uriComponents == null)
  920. return null;
  921. String requestedMethod = uriComponents[1];
  922. HttpMethodRepository repo = getRepository();
  923. HttpMethod m = repo.lookupMethod(requestedMethod);
  924. if (m == null)
  925. return null;
  926. else
  927. return new HttpMethodRequestHandler(uriComponents[0], m);
  928. }
  929. private RequestHandler tryLoginRequests(HttpServletRequest request) {
  930. if (!(request.getRequestURI().equals("/text/dologin") &&
  931. request.getMethod().toUpperCase().equals("POST"))
  932. && !request.getRequestURI().equals("/text/checklogin")) {
  933. return null;
  934. } else {
  935. return new RequestHandler() {
  936. public boolean getNoCache() {
  937. return true;
  938. }
  939. public boolean getRequiresTransaction() {
  940. return true;
  941. }
  942. public boolean isReadWrite() {
  943. return true;
  944. }
  945. public void handle(HttpServletRequest request, HttpServletResponse response, boolean isPost)
  946. throws HttpException, IOException {
  947. // special method that magically causes us to look at your cookie and log you
  948. // in if it's set, then return person you're logged in as or "false"
  949. User user = getUser(request);
  950. response.setContentType("text/plain");
  951. OutputStream out = response.getOutputStream();
  952. if (user != null)
  953. out.write(user.getId().getBytes());
  954. else
  955. out.write("false".getBytes());
  956. out.flush();
  957. }
  958. };
  959. }
  960. }
  961. private RequestHandler trySignoutRequest(HttpServletRequest request) {
  962. if (!request.getRequestURI().equals("/action/signout") ||
  963. !request.getMethod().toUpperCase().equals("POST")) {
  964. return null;
  965. } else {
  966. return new RequestHandler() {
  967. public boolean getNoCache() {
  968. return true;
  969. }
  970. public boolean getRequiresTransaction() {
  971. return false;
  972. }
  973. public boolean isReadWrite() {
  974. return false;
  975. }
  976. public void handle(HttpServletRequest request, HttpServletResponse response, boolean isPost)
  977. throws HttpException, IOException {
  978. HttpSession session = request.getSession();
  979. if (session != null)
  980. session.invalidate();
  981. // FIXME we need to drop the Client object when we do this,
  982. // both to save our own disk space, and in case someone stole the
  983. // cookie.
  984. response.addCookie(LoginCookie.newDeleteCookie());
  985. response.addCookie(LoginCookie.newDeleteAuthenticatedCookie());
  986. }
  987. };
  988. }
  989. }
  990. private RequestHandler tryApiDocs(HttpServletRequest request) {
  991. String requestUri = request.getRequestURI();
  992. if (requestUri.equals("/api-docs"))
  993. requestUri = "/api-docs/";
  994. if (!requestUri.startsWith("/api-docs/"))
  995. return null;
  996. final String docsOn = requestUri.substring("/api-docs/".length());
  997. return new RequestHandler() {
  998. public boolean getNoCache() {
  999. return false;
  1000. }
  1001. public boolean getRequiresTransaction() {
  1002. return false;
  1003. }
  1004. public boolean isReadWrite() {
  1005. return false;
  1006. }
  1007. public void handle(HttpServletRequest request, HttpServletResponse response, boolean isPost)
  1008. throws HttpException, IOException {
  1009. if (docsOn.length() == 0) {
  1010. handleApiDocsIndex(request, response);
  1011. } else {
  1012. handleApiDocsMethod(request, response, docsOn);
  1013. }
  1014. }
  1015. };
  1016. }
  1017. @Override
  1018. public void init() throws ServletException {
  1019. // call this for side effect of loading methods so we get
  1020. // any errors on startup
  1021. getRepository();
  1022. }
  1023. private void doRequest(HttpServletRequest request, HttpServletResponse response, boolean isPost)
  1024. throws HttpException, IOException, RetryException {
  1025. RequestHandler handler = getRequestHandler(request);
  1026. if (handler == null) {
  1027. logger.debug("Found no handler for url '{}'", request.getRequestURI());
  1028. throw new HttpException(HttpResponseCode.NOT_FOUND, "unknown URI");
  1029. }
  1030. if (handler.getNoCache())
  1031. setNoCache(response);
  1032. handler.handle(request, response, isPost);
  1033. }
  1034. @Override
  1035. protected String wrappedDoPost(HttpServletRequest request, HttpServletResponse response) throws HttpException,
  1036. IOException, RetryException {
  1037. doRequest(request, response, true);
  1038. return null;
  1039. }
  1040. @Override
  1041. protected String wrappedDoGet(HttpServletRequest request, HttpServletResponse response) throws HttpException, IOException, RetryException {
  1042. doRequest(request, response, false);
  1043. return null;
  1044. }
  1045. // the idea is to do the "request analysis" only once
  1046. private RequestHandler getRequestHandler(HttpServletRequest request) {
  1047. RequestHandler handler = (RequestHandler)request.getAttribute("request-handler");
  1048. if (handler != null)
  1049. return handler;
  1050. boolean isPost = request.getMethod().toUpperCase().equals("POST");
  1051. if (isPost) {
  1052. if (handler == null)
  1053. handler = tryLoginRequests(request);
  1054. if (handler == null)
  1055. handler = trySignoutRequest(request);
  1056. } else {
  1057. if (handler == null)
  1058. handler = tryApiDocs(request);
  1059. }
  1060. if (handler == null)
  1061. handler = tryHttpRequest(request);
  1062. if (handler != null) {
  1063. request.setAttribute("request-handler", handler);
  1064. return handler;
  1065. } else {
  1066. return null;
  1067. }
  1068. }
  1069. @Override
  1070. protected boolean requiresTransaction(HttpServletRequest request) {
  1071. RequestHandler handler = getRequestHandler(request);
  1072. if (handler != null) {
  1073. return handler.getRequiresTransaction();
  1074. } else {
  1075. // we're going to throw an error later
  1076. return false;
  1077. }
  1078. }
  1079. @Override
  1080. protected boolean isReadWrite(HttpServletRequest request) {
  1081. RequestHandler handler = getRequestHandler(request);
  1082. return handler.isReadWrite();
  1083. }
  1084. }